Ваши программы не однопоточные
English Español Français Deutsch 日本語 한국어 Português 中文
При написании кода мы склонны мыслить синхронно. Мы пишем одну инструкцию за другой, и среда выполнения исполняет их по порядку. Эта ментальная модель отлично работает для скриптов и инструментов командной строки, но может быть опасно обманчивой при работе с фреймворками и серверными приложениями.
Даже если вы никогда явно не создаёте потоки и не используете примитивы параллелизма, ваше веб-приложение почти наверняка выполняет код параллельно. Фреймворк делает это за вас, что удобно, если вы помните об этом и правильно используете модель потоков и строительные блоки, которые предоставляет фреймворк.
В этой статье мы рассмотрим именно тот тип ошибки, который может возникнуть, когда кто-то ошибочно следует однопоточной ментальной модели в, казалось бы, синхронном коде. Мы найдём причину, воспроизведём её с помощью отладчика и рассмотрим несколько возможных решений.
Проблема
Представьте, что у вас есть приложение на Spring Boot, которое отлично работает в разработке и продакшене. Долгое время всё работало гладко, но затем бухгалтерия сообщает о серьёзной проблеме: несколько счетов имеют одинаковый номер. Это плохо. Номера счетов должны быть уникальными по юридическим и бухгалтерским причинам. Дубликаты появились только один раз, и вы не можете воспроизвести проблему локально.
Чтобы сделать это упражнение интереснее, вы можете попробовать найти проблему самостоятельно, прежде чем читать дальше. Проект доступен на GitHub.
Вы проверяете базу данных и видите разные записи с одинаковыми номерами счетов. Конфигурация выглядит нормально, но явно что-то не так. Отслеживая поток создания счетов, вы находите сервис, отвечающий за генерацию номеров счетов.
Вот как выглядит код:
@Service
public class InvoiceService {
private InvoiceNumberGenerator generator;
public Invoice createInvoice(Order order) {
if (generator == null) {
generator = createGenerator();
}
String invoiceNumber = generator.nextNumber();
return new Invoice(invoiceNumber, order);
}
private InvoiceNumberGenerator createGenerator() {
// Reads last used number from database, initializes counter
int lastNumber = invoiceRepository.findMaxInvoiceNumber();
return new InvoiceNumberGenerator(lastNumber);
}
}
Это не то, как бы вы написали этот код. Можете найти проблему?
Что пошло не так?
Ментальная модель автора, вероятно, была такой: “Spring управляет моими бинами, так что я в безопасности.”
И это правда: Spring действительно управляет жизненным циклом бинов и гарантирует,
что InvoiceService создаётся один раз и правильно разделяется.
Но Spring не знает о полях, которые вы инициализируете сами.
Поле generator — это ваша ответственность, и здесь кроется проблема.
Последовательность событий
Бины Spring по умолчанию являются синглтонами.
Это означает, что существует только один экземпляр InvoiceService , разделяемый между всеми запросами.
Когда несколько HTTP-запросов приходят одновременно,
они обрабатываются разными потоками, все обращаются к одному и тому же экземпляру сервиса.
Теперь давайте проследим, что происходит, если генератор ещё не был инициализирован,
и два потока вызывают createInvoice() примерно в одно время:
- Поток A проверяет
generator == null, результатtrue - Поток A входит в блок
ifи начинает создавать генератор - Поток B проверяет
generator == null, всё ещёtrue(A ещё не создал генератор) - Поток B тоже входит в блок
if - Оба потока запрашивают у базы данных
findMaxInvoiceNumber()и получают одно значение, например,1000 - Оба потока создают свой
InvoiceNumberGenerator, начиная с1000 - Оба вызывают
nextNumber()и получают1001 - Создаются два счёта с одинаковым номером
Ленивая инициализация не является потокобезопасной, и при параллельном доступе оба потока могут прочитать один и тот же “последний номер счёта” из базы данных и сгенерировать одинаковый следующий номер.
Воспроизведение с помощью отладчика
Проблемный код, который мы только что нашли, прост, так что вы можете понять последовательность событий, просто посмотрев на него. В более сложных случаях вы можете захотеть воспроизвести баг пошагово, чтобы точно понять, что происходит. В других случаях вы можете просто захотеть симулировать неудачный тайминг и проверить, не ломается ли программа под нагрузкой.
Для этого мы будем использовать отладчик IntelliJ IDEA. Он может управлять отдельными потоками — именно то, что нам нужно.
Сначала установите точку останова на строке generator = createGenerator(); ,
щёлкните по ней правой кнопкой мыши и установите Suspend
в Thread.
Это говорит отладчику приостанавливать только тот поток, который достиг точки останова, а не все.
Запустите приложение в режиме отладки и отправьте два запроса из разных терминалов, используя следующую команду curl:
curl -X POST http://localhost:8080/api/diagnostics/create-order
Один из запросов достигнет точки останова: это ожидаемо. Когда второй запрос тоже достигнет точки останова, вы только что доказали, что несколько потоков могут одновременно войти в критическую секцию — это и есть определение данного бага.
Вы можете просмотреть вкладку Threads, чтобы увидеть оба потока, ожидающих в одном месте:
Оба потока прошли проверку null ,
и когда вы их освободите, они создадут свои собственные экземпляры генератора.
Альтернативно, вы можете использовать неприостанавливающую точку останова для логирования с небольшой задержкой, чтобы симулировать более медленное выполнение:
Этот тип точки останова предназначен для отладочного логирования, но как мы уже знаем, его можно использовать для введения практически любого побочного эффекта. С этой искусственной задержкой нам не нужно приостанавливать приложение, потому что запросы, скорее всего, столкнутся в любом случае.
Исправление проблемы
Есть несколько способов исправить проблему в зависимости от ваших требований:
Вариант 1: Использовать базу данных
Для номеров счетов конкретно правильное решение — позволить базе данных обеспечивать уникальность:
@Service
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
public InvoiceService(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
public Invoice createInvoice(Order order) {
// Database generates the number via sequence or auto-increment
return invoiceRepository.save(new Invoice(order));
}
}
Это полностью устраняет счётчик в памяти. База данных гарантирует уникальность даже между несколькими экземплярами приложения.
Вариант 2: Немедленная инициализация
Если вам нужен генератор в памяти, сделайте его Spring-бином:
@Component
public class InvoiceNumberGenerator {
private final AtomicInteger counter;
public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
int startingNumber = (maxNumber != null) ? maxNumber : 0;
this.counter = new AtomicInteger(startingNumber);
}
public String nextNumber() {
int next = counter.incrementAndGet();
return String.format("INV-%05d", next);
}
}
@Service
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
private final InvoiceNumberGenerator generator;
public InvoiceService(InvoiceRepository invoiceRepository,
InvoiceNumberGenerator generator) {
this.invoiceRepository = invoiceRepository;
this.generator = generator;
}
public Invoice createInvoice(Order order) {
String invoiceNumber = generator.nextNumber();
Invoice invoice = new Invoice(invoiceNumber, order);
return invoiceRepository.save(invoice);
}
}
Это стандартный подход в Spring. Синглтон-бины создаются при запуске и безопасно разделяются между всеми запросами.
Вариант 3: Использовать @Lazy
Если вам действительно нужна ленивая инициализация
(например, когда создание бина занимает много времени из-за загрузки данных или установки соединений),
вы можете использовать @Lazy, чтобы Spring сделал это безопасно:
@Component @Lazy
public class InvoiceNumberGenerator {
private final AtomicInteger counter;
public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
int startingNumber = (maxNumber != null) ? maxNumber : 0;
this.counter = new AtomicInteger(startingNumber);
}
public String nextNumber() {
int next = counter.incrementAndGet();
return String.format("INV-%05d", next);
}
}
@Service
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
private final InvoiceNumberGenerator generator;
public InvoiceService(InvoiceRepository invoiceRepository,
@Lazy InvoiceNumberGenerator generator) {
this.invoiceRepository = invoiceRepository;
this.generator = generator;
}
public Invoice createInvoice(Order order) {
String invoiceNumber = generator.nextNumber();
Invoice invoice = new Invoice(invoiceNumber, order);
return invoiceRepository.save(invoice);
}
}
Как правило, если вы обнаруживаете, что вручную реализуете ленивую инициализацию, кэширование, синхронизацию или подобные паттерны — проверьте, не предоставляет ли это уже фреймворк. Делать это самостоятельно часто означает бороться с фреймворком и вносить накладные расходы на поддержку и неуловимые баги.
Заключение
Даже когда мы явно не создаём потоки, наши приложения часто работают параллельно. Этот паттерн не уникален для Spring или даже веб-разработки. Везде, где есть неявная модель потоков, вы в зоне риска: веб-фреймворки, UI-тулкиты и даже API стандартной библиотеки — все они управляют потоками за кулисами.
В следующий раз, когда вы увидите периодическое, необъяснимое поведение в вашем приложении, спросите себя: может ли это быть проблемой потоков? Чаще всего ответ — да.
Удачной отладки!