Однажды я решил проверить на практике байку, которую слышал на десятке собеседований: «у нас на проде было такое‑то место продано дважды, потому что…». Оказалось, воспроизвести это не просто легко — это воспроизводится настолько надёжно и настолько наглядно, что грех было не довести дело до конца: взять систему бронирования мест, специально сломать её, замерить, насколько плохо, а потом последовательно починить тремя разными способами — и честно сравнить, что каждый из них стоит по производительности.
Спойлер: один из результатов оказался прямо противоположным тому, что я ожидал увидеть.
Постановка задачи
Домен максимально простой и знакомый каждому: бронирование мест на мероприятие. Есть 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, прежде чем первый поток закоммитит свою транзакцию.
Как я тестировал:
Чтобы доказать баг, а не просто заявить о нём, я использовал два независимых способа его поймать:
-
JUnit‑тест с
CyclicBarrier— 50 потоков специально синхронизируются, чтобы стартовать в одну и ту же микросекунду, и все одновременно бьют в один и тот же REST‑эндпоинт. Это детерминированный, воспроизводимый способ доказать баг — не «иногда бывает», а «воспроизводится каждый раз». -
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 |
|
0 |
107мс |
Нет |
|
Optimistic |
|
0 |
114мс |
Нет |
|
Redis |
распределённый |
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/