Ваши программы не однопоточные

English Español Français Deutsch 日本語 한국어 Português 中文

При написании кода мы склонны мыслить синхронно. Мы пишем одну инструкцию за другой, и среда выполнения исполняет их по порядку. Эта ментальная модель отлично работает для скриптов и инструментов командной строки, но может быть опасно обманчивой при работе с фреймворками и серверными приложениями.

Даже если вы никогда явно не создаёте потоки и не используете примитивы параллелизма, ваше веб-приложение почти наверняка выполняет код параллельно. Фреймворк делает это за вас, что удобно, если вы помните об этом и правильно используете модель потоков и строительные блоки, которые предоставляет фреймворк.

В этой статье мы рассмотрим именно тот тип ошибки, который может возникнуть, когда кто-то ошибочно следует однопоточной ментальной модели в, казалось бы, синхронном коде. Мы найдём причину, воспроизведём её с помощью отладчика и рассмотрим несколько возможных решений.

Проблема

Представьте, что у вас есть приложение на Spring Boot, которое отлично работает в разработке и продакшене. Долгое время всё работало гладко, но затем бухгалтерия сообщает о серьёзной проблеме: несколько счетов имеют одинаковый номер. Это плохо. Номера счетов должны быть уникальными по юридическим и бухгалтерским причинам. Дубликаты появились только один раз, и вы не можете воспроизвести проблему локально.

Tip icon

Чтобы сделать это упражнение интереснее, вы можете попробовать найти проблему самостоятельно, прежде чем читать дальше. Проект доступен на 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() примерно в одно время:

  1. Поток A проверяет generator == null , результат true
  2. Поток A входит в блок if и начинает создавать генератор
  3. Поток B проверяет generator == null , всё ещё true (A ещё не создал генератор)
  4. Поток B тоже входит в блок if
  5. Оба потока запрашивают у базы данных findMaxInvoiceNumber() и получают одно значение, например, 1000
  6. Оба потока создают свой InvoiceNumberGenerator , начиная с 1000
  7. Оба вызывают nextNumber() и получают 1001
  8. Создаются два счёта с одинаковым номером

Ленивая инициализация не является потокобезопасной, и при параллельном доступе оба потока могут прочитать один и тот же “последний номер счёта” из базы данных и сгенерировать одинаковый следующий номер.

Воспроизведение с помощью отладчика

Проблемный код, который мы только что нашли, прост, так что вы можете понять последовательность событий, просто посмотрев на него. В более сложных случаях вы можете захотеть воспроизвести баг пошагово, чтобы точно понять, что происходит. В других случаях вы можете просто захотеть симулировать неудачный тайминг и проверить, не ломается ли программа под нагрузкой.

Для этого мы будем использовать отладчик IntelliJ IDEA. Он может управлять отдельными потоками — именно то, что нам нужно.

Сначала установите точку останова на строке generator = createGenerator(); , щёлкните по ней правой кнопкой мыши и установите Suspend в Thread. Это говорит отладчику приостанавливать только тот поток, который достиг точки останова, а не все.

Диалог настроек точки останова с политикой приостановки, установленной на Thread Диалог настроек точки останова с политикой приостановки, установленной на Thread

Запустите приложение в режиме отладки и отправьте два запроса из разных терминалов, используя следующую команду curl:

curl -X POST http://localhost:8080/api/diagnostics/create-order

Один из запросов достигнет точки останова: это ожидаемо. Когда второй запрос тоже достигнет точки останова, вы только что доказали, что несколько потоков могут одновременно войти в критическую секцию — это и есть определение данного бага.

Вы можете просмотреть вкладку Threads, чтобы увидеть оба потока, ожидающих в одном месте:

Вкладка Threads показывает два потока, остановленных в одной точке останова Вкладка Threads показывает два потока, остановленных в одной точке останова

Оба потока прошли проверку null , и когда вы их освободите, они создадут свои собственные экземпляры генератора.

Альтернативно, вы можете использовать неприостанавливающую точку останова для логирования с небольшой задержкой, чтобы симулировать более медленное выполнение:

Точка останова с выражением оценки и логирования, содержащим Thread.sleep для симуляции медленного выполнения Точка останова с выражением оценки и логирования, содержащим Thread.sleep для симуляции медленного выполнения

Этот тип точки останова предназначен для отладочного логирования, но как мы уже знаем, его можно использовать для введения практически любого побочного эффекта. С этой искусственной задержкой нам не нужно приостанавливать приложение, потому что запросы, скорее всего, столкнутся в любом случае.

Исправление проблемы

Есть несколько способов исправить проблему в зависимости от ваших требований:

Вариант 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-бином:

InvoiceNumberGenerator.java:
@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);
    }
}
InvoiceService.java:
@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 сделал это безопасно:

InvoiceNumberGenerator.java:
@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);
    }
}
InvoiceService.java:
@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 стандартной библиотеки — все они управляют потоками за кулисами.

В следующий раз, когда вы увидите периодическое, необъяснимое поведение в вашем приложении, спросите себя: может ли это быть проблемой потоков? Чаще всего ответ — да.

Удачной отладки!

all posts ->