Я продал одно место на концерт 20 раз. Разбираемся, как правильно защититься от race condition в Spring Boot

от автора

Однажды я решил проверить на практике байку, которую слышал на десятке собеседований: «у нас на проде было такое‑то место продано дважды, потому что…». Оказалось, воспроизвести это не просто легко — это воспроизводится настолько надёжно и настолько наглядно, что грех было не довести дело до конца: взять систему бронирования мест, специально сломать её, замерить, насколько плохо, а потом последовательно починить тремя разными способами — и честно сравнить, что каждый из них стоит по производительности.

Спойлер: один из результатов оказался прямо противоположным тому, что я ожидал увидеть.

Постановка задачи

Домен максимально простой и знакомый каждому: бронирование мест на мероприятие. Есть Event, у него есть Seat“ы, каждое место можно забронировать ровно один раз — как только статус меняется на BOOKED, второй попытке достаться уже нечего.”

@Entitypublic class Seat {    private Long id;    private SeatStatus status; // AVAILABLE или BOOKED    private Integer version;}

Казалось бы, чего тут сложного:

Seat seat = seatRepository.findById(seatId).orElseThrow();if (seat.getStatus() != SeatStatus.AVAILABLE) {    throw new SeatAlreadyBookedException(seatId);}seat.setStatus(SeatStatus.BOOKED);seatRepository.save(seat);bookingRepository.save(new Booking(seat, userId))

Именно так выглядит первая версия сервиса в большинстве пет‑проектов и, чего уж скрывать, в некоторых боевых системах тоже. Проблема этого кода не в синтаксисе — он абсолютно корректный, но проблема в том, что между строкой if и строкой save() может вклиниться ещё один поток, который успеет прочитать то же самое AVAILABLE, прежде чем первый поток закоммитит свою транзакцию.

Как я тестировал:

Чтобы доказать баг, а не просто заявить о нём, я использовал два независимых способа его поймать:

  1. JUnit‑тест с CyclicBarrier — 50 потоков специально синхронизируются, чтобы стартовать в одну и ту же микросекунду, и все одновременно бьют в один и тот же REST‑эндпоинт. Это детерминированный, воспроизводимый способ доказать баг — не «иногда бывает», а «воспроизводится каждый раз».

  2. Gatling — для замера throughput и latency под нагрузкой, отдельно для «горячего» места (все пользователи хотят одно и то же место) и «распределённой» нагрузки (у каждого — своё).

Про второй инструмент сразу важная методологическая деталь, которая по касательной сама стала частью исследования: Gatling умеет создавать нагрузку двумя принципиально разными способами — ramp (rampUsers(200).during(30), пользователи растянуты во времени) и burst (atOnceUsers(200), все одновременно). Я по неопытности сперва прогнал ramp — и получил всего 2 дубля из 200 попыток вместо ожидаемых десятков. Причина простая: race condition требует реального пересечения запросов по времени в пределах миллисекунд, а не «в течение получаса». Ramp отвечает на вопрос «как система ведёт себя под растущей реалистичной нагрузкой», а не «что будет, если 200 человек одновременно нажмут купить». Для демонстрации гонки нужен именно burst — с этого момента все нагрузочные тесты в статье используют его.

Этап 1: Наивная реализация — и почему она ломается настолько сильно

Прогоняю burst‑тест: 200 одновременных запросов на одно и то же место.

HTTP 201 (success) responses : 20HTTP 409 (conflict) responses : 30 (в JUnit-версии на 50 потоках)Actual Booking rows in DB     : 20

40% параллельных запросов «успешно» забронировали одно и то же место. При Gatling burst на 200 потоков картина похожая по порядку величины.

Неожиданная находка № 1: дублей ровно столько, сколько соединений в пуле

Я специально проверил гипотезу, а не просто зафиксировал число. Погонял с разным maximum-pool-size у HikariCP:

Pool size

Дублей на hot seat (burst, 200 запросов)

20

20

50

50

Совпадение точное, не приблизительное. Механика прозрачна: при полностью синхронном налёте реально работать с базой одновременно может ровно pool_size транзакций. Именно столько успевает прочитать AVAILABLE до того, как хоть одна из них закоммитит UPDATE — отсюда ровно pool_size дублей. Все запросы сверх этого числа корректно получают 409, потому что к моменту, когда они получают соединение, место уже занято.

Практический вывод: размер connection pool — это не просто про throughput, это ещё и про размер «окна уязвимости» для подобных гонок. На эту же тему всплыл и побочный урок: подняв пул до 100, я упёрся в лимит Postgres по умолчанию (max_connections=100) и словил FATAL: sorry, too many clients already, пытаясь просто зайти через psql рядом. Пул приложения нельзя настраивать в отрыве от лимита самой БД — всегда нужен запас на служебные подключения.

Неожиданная находка № 2: сериализация в БД — не защита от бага

При burst‑тесте latency у «горячего» места оказалась ощутимо выше, чем у случайных мест (127мс против 6мс по mean) — хотя в коде нет вообще никакого явного лока. Причина в том, что UPDATE в Postgres всегда берёт эксклюзивную блокировку строки на время транзакции — это встроенное поведение MVCC, а не наша логика. Конкурирующие записи в одну строку физически выстраиваются в очередь.

Но это никак не предотвращает баг. SELECT (момент, когда принимается решение «место свободно») происходит до этой точки сериализации — несколько транзакций успевают прочитать устаревшие данные ещё до того, как встанут в очередь на запись. Сериализация записи ≠ защита от race condition на уровне бизнес‑логики. Это стоит запомнить: если вы видите, что конкурентные запросы к одной строке в базе замедляются, это не значит, что корректность гарантирована.

Этап 2: Pessimistic Locking — лок на чтении, а не только на записи

Простейший осмысленный фикс — SELECT ... FOR UPDATE:

@Lock(LockModeType.PESSIMISTIC_WRITE)@Query("select s from Seat s where s.id = :id")Optional<Seat> findByIdForUpdate(@Param("id") Long id);
Seat seat = seatRepository.findByIdForUpdate(seatId)        .orElseThrow(() -> new SeatNotFoundException(seatId));if (seat.getStatus() != SeatStatus.AVAILABLE) {    throw new SeatAlreadyBookedException(seatId);}seat.setStatus(SeatStatus.BOOKED);seatRepository.save(seat);

Разница с naive минимальна по коду, но принципиальна по эффекту: теперь лок берётся на чтении, а не только неявно на записи. Тот же JUnit‑тест (50 потоков, CyclicBarrier) даёт:

HTTP 201 (success) responses : 1HTTP 409 (conflict) responses : 49Actual Booking rows in DB     : 1

Идеально ровно. Механизм качественно другой: naive — это гонка (несколько параллельных «победителей»), pessimistic — это очередь (один победитель, остальные физически блокируются на SELECT, а получив лок — сразу видят актуальный статус и отваливаются).

Неожиданная находка № 3: pessimistic оказался быстрее naive

Burst‑тест на hot seat:

min

mean

max

Naive (pool=50)

117мс

148мс

184мс

Pessimistic

56мс

107мс

155мс

Интуиция подсказывает обратное — явная сериализация должна быть дороже «свободной» гонки. На деле дело в объёме реально выполненной работы: у naive до pool_size потоков реально доходят до полного (дорогого) цикла UPDATE + INSERT, все конкурируя за одну строку. У pessimistic реально пишет только один поток — остальные блокируются на дешёвом чтении и сразу кидают исключение, не доходя до записи. Меньше конкурирующих писателей — меньше суммарной работы движка, несмотря на явную блокировку.

Это хороший контрпример к расхожему тезису «корректность стоит производительности» — иногда она её улучшает, убирая лишнюю конкурентную работу.

Ограничения, о которых стоит помнить: throughput на конкретную строку жёстко ограничен последовательностью независимо от числа ядер/соединений; лок не масштабируется горизонтально сам по себе (несколько инстансов приложения всё равно упрутся в один и тот же лок одной БД); долго держит соединение из пула, пока жива транзакция.

Этап 3: Optimistic Locking — не блокировать чтение, ловить конфликт на записи

@Versionprivate Integer version;
@Transactionalpublic Booking tryBook(Long seatId, String userId) {    Seat seat = seatRepository.findById(seatId).orElseThrow(...);    if (seat.getStatus() != SeatStatus.AVAILABLE) {        throw new SeatAlreadyBookedException(seatId);    }    seat.setStatus(SeatStatus.BOOKED);    seatRepository.save(seat); // Hibernate добавит AND version = ? — и бросит исключение при конфликте}

Поверх — retry‑цикл (до 10 попыток), перехватывающий ObjectOptimisticLockingFailureException. JUnit‑результат идентичен pessimistic: 1 успех, 49 конфликтов, 0 дублей.

Latency чуть выше pessimistic — и понятно, почему

min

mean

max

Pessimistic

56мс

107мс

155мс

Optimistic

79мс

114мс

154мс

min вырос с 56 до 79мс — не шум, а логичное следствие: у pessimistic проигравший поток делает один round‑trip к БД (блокирующий SELECT, который сразу возвращает актуальное состояние). У optimistic проигравший делает минимум два: читает устаревший AVAILABLE, пытается записать, ловит конфликт версии, и только на повторном чтении видит актуальный BOOKED. Лишний round‑trip — прямая, объяснимая цена.

Операционная деталь, которую легко упустить

Каждый конфликт версии Hibernate логирует как ERROR:

HHH100501: Exception executing batch [org.hibernate.StaleStateException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1]

даже если приложение полностью корректно перехватывает исключение и обрабатывает retry. При 49 конфликтах на один burst‑запрос — 49 ERROR‑строк в логах за долю секунды. Если у вас алертинг настроен по количеству ERROR‑логов (частая практика) — optimistic locking при высокой конкуренции создаст лавину ложных срабатываний. Это реальная эксплуатационная цена, не только теоретическая, и pessimistic locking такого шума не производит вообще.

Этап 4: распределённый лок через Redis (Redisson)

RLock lock = redissonClient.getLock("seat-lock:" + seatId);boolean acquired = lock.tryLock(5, 10, TimeUnit.SECONDS);if (!acquired) {    throw new SeatAlreadyBookedException(seatId);}try {    return bookingAttempt.book(seatId, userId); // та же простая read-check-write логика} finally {    lock.unlock();}

Ключевое отличие от предыдущих двух этапов: корректность здесь обеспечивает не Postgres, а внешний координирующий сервис. Это единственная из трёх стратегий, которая продолжит работать корректно, если приложение масштабировать на несколько инстансов за балансировщиком — pessimistic и optimistic locking в такой конфигурации всё равно упрутся в лок одной и той же БД.

JUnit — снова идеальные 1/49/0 дублей. А вот с latency вышло интереснее:

min

mean

max

Pessimistic

56мс

107мс

155мс

Optimistic

79мс

114мс

154мс

Redis

95мс

236мс

353мс

Почти вдвое медленнее по mean, и это ожидаемая, объяснимая цена, а не случайность. Pessimistic и optimistic решают конкуренцию внутри Postgres, используя его собственный in‑process lock manager — координация не покидает процесс. Redis‑лок — отдельный сетевой сервис: каждая попытка взять лок — это сетевой round‑trip, помимо работы с самой БД. Плюс механизм ожидания у Redisson построен на pub/sub: при освобождении лока все ожидающие клиенты уведомляются и одновременно пытаются захватить его заново — мини‑«thundering herd» внутри самого Redis на каждое освобождение.

Важно понимать контекст этого результата: в тесте с одной БД и одним инстансом приложения мы платим полную цену координации, не получая взамен той выгоды (горизонтальное масштабирование), ради которой обычно на это идут. Распределённый лок — не «Redis хуже БД», а другой инструмент для другой задачи.

Итоговое сравнение

Стратегия

Механизм

Дублей (50 потоков, burst)

Hot‑seat mean

Масштабируется между инстансами?

Naive

нет защиты

20 из 50

148мс

— (баг)

Pessimistic

SELECT ... FOR UPDATE

0

107мс

Нет

Optimistic

@Version + retry

0

114мс

Нет

Redis

распределённый RLock

0

236мс

Да

Когда что использовать

  • Одна БД, умеренная конкуренция за конкретные строки — pessimistic locking. Самый быстрый и предсказуемый вариант из измеренных, если конкуренция реально есть.

  • Низкая ожидаемая конкуренция, важна пропускная способность на happy path — optimistic locking. Не платит за лок вообще, если конфликтов почти нет — наш бенчмарк специально бил в контеншн, поэтому не показывает эту сильную сторону подхода.

  • Несколько инстансов приложения, БД не является общей точкой координации — распределённый лок оправдан, несмотря на латентность, потому что это единственная из трёх стратегий, которая вообще работает в таком сценарии.

Что осталось за скобками

Изначально в исследование также входило устранение гонки архитектурно — через партиционирование Kafka по seatId (гарантия «одна партиция — один консьюмер» убирает конкуренцию по построению, без единого лока). Результаты получились интересными, но я решил разнести материал на две статьи: Kafka‑подход принципиально другого рода, и заслуживает честного асинхронного API (запрос → 202 Accepted → поллинг статуса), а не подгонки под синхронный HTTP‑контракт ради сравнимости с остальными тремя стратегиями.

Весь код, тесты и нагрузочные сценарии — в репозитории: https://github.com/pgs‑dev‑j/high‑load‑seat‑booking. Каждый этап — отдельный коммит/тег, можно посмотреть эволюцию от бага до каждого из трёх решений.

ссылка на оригинал статьи https://habr.com/ru/articles/1063642/