или Почему наша система теперь не боится ни распродаж, ни внезапного наплыва любителей гречки и сковородок
Всем привет!
Меня зовут Александр Исай, я тимлид в Ozon Tech. Сегодня я расскажу историю о том, как мы спасали нашу систему резервации товаров от краха в самый горячий момент года.
Да, в этой истории есть всё:
накал страстей на распродаже,
толпы пользователей, охотящихся за суперскидками,
и наша борьба за стабильную работу сервиса.
Немного о контексте
Ozon — это маркетплейс, где можно заказать буквально всё: от лампочки до автомобиля.
За кулисами у нас множество продавцов и тысячи товаров, но для клиента всё выглядит просто: добавляешь в корзину то, что нужно, оформляешь один заказ — и не думаешь, у кого именно ты купил каждую вещь.
(Серьёзно, вы часто проверяете, у кого именно заказали сковородку?)
Почему важна резервация товара
Чтобы повысить шанс, что заказ соберут полностью, товар должен быть зарезервирован в момент покупки.
Представьте: на складе осталась одна сковородка, и кто-то оформил заказ за секунду до вас. Если резервирования нет, ваш заказ почти наверняка отменят.
В профессиональной среде это явление называется оверсейл — когда продали больше, чем есть в наличии.
Чтобы этого не происходило, у нас есть целая система, которая занимается резервацией товаров.
Что делает система StockApi

В основе работы — сервис StockApi. Его задачи:
хранить и выдавать данные об остатках — все системы, которые хотят показывать наличие товаров на сайте или в приложении, кладут информацию сюда;
обновлять информацию об остатках — в любой момент можно изменить количество товара на складе;
резервировать товар — блокировать позиции под конкретные операции (от секунды до недель), чтобы исключить гонку за товаром.
Ключевые сущности:
-
Товар
— item_id — уникальный идентификатор товара;
— warehouse_id — склад, где товар лежит;
— stock — общее количество (сколько товара на складе прям сейчас);
— reserved — сколько уже зарезервировано. -
Резерв
— ID (GUID) — уникальный идентификатор резерва.
— Список товаров — []{item_id, warehouse_id, to_reserve}.
✅ Если для каждого товара в запросе хватает свободного количества (stock - (reserved + to_reserve) >= 0), резерв создаётся.
❌ Если хотя бы по одному товару условия не выполняются — резерв не создаётся вовсе.
Технологический стек: Dotnet (c#), postgresql.
С чего всё началось
Мы готовились к одной из первых крупных распродаж. По прогнозам ожидался серьёзный трафик, поэтому мы заблаговременно провели нагрузочные тесты, выдержали целевые RPS и с уверенностью ждали старта акции.
Наступил день X. В числе рекламных товаров — сковородки с такой скидкой, что мимо них пройти невозможно.
И тут всё пошло не по плану! Вместо ожидаемых 10 RPS на товар мы получили сотни.
Все подключения к базе данных заняты.
Система замерла, не отвечая на запросы.
Через 10 секунд всё ожило — но сковородок уже не было.
Это было похоже на момент, когда вся очередь в супермаркете встаёт, потому что кассир ушёл искать цену на один товар, и никто не может двинуться дальше.
Почему так случилось?

Чтобы начать обработку операции, нужно взять блокировку на все товары из резерва. Клиенту важно: либо весь набор товаров зарезервирован, либо заказ отклонён.
В тот день почти каждый запрос содержал один и тот же товар. Запросы выстроились в очередь, ожидая освобождения блокировки, и заняли все подключения к БД.
Надо что-то делать
Сидим мы после первой волны, уставшие, но уже в режиме «боевого штаба». Очевидно одно: если ничего не изменить, следующая атака на складские сковородки положит систему снова.
А до следующей волны — меньше суток.

Вспоминаем про опцию в PostgreSQL — FOR UPDATE NOWAIT.
Её смысл прост: если блокировка уже взята другой транзакцией, не ждать, а сразу вернуть ошибку.
Да, часть запросов отвалится, но система хотя бы не встанет колом.
Мы быстро реализуем этот подход, прогоняем стресс-тесты — и он работает.
Но есть нюанс: такой режим задевает и другие товары, не только «проблемный» item_id.
Как не зацепить всё подряд?
Добавляем вайт-лист по item_id:
если товар в списке «горячих» (например, та же сковородка) — применяем NOWAIT,
для остальных — обычный процесс.
Прогоняем стресс-тесты с разными профилями трафика — и видим: да, часть запросов (примерно 70%) падает с ошибкой, но система живёт.
Так мы прошли следующие волны, держа связь с маркетингом, чтобы вовремя добавлять акционные товары в список.
График кодов ответов и времени до изменений мы не видели, так как поды складывались и не успевали репортить метрики.
После было примерно так:

Поиск долгосрочного решения
Распродажа позади. Мы выдохнули, сделали глоток кофе и посмотрели друг на друга:
«Ну что, теперь надо придумать, как жить дальше».
Временный костыль спас нас, но было понятно — это лишь «бинт», а не полноценное лечение. Мы же хотели пройти следующую распродажу без ошибок и спокойно обрабатывать сотни запросов на один и тот же товар.
Наши условия
Приложение работает в Kubernetes.
Экземпляры приложения не должны хранить бизнес-данные в памяти — деплои, перераспределения подов или отказ дата-центра не должны ломать консистентность.Трафик не регулируем.
Один резерв = один запрос, клиент не должен ничего заметить.Сущность должна быть готова к операциям сразу после ответа — никакой отложенной записи.
Счётчик должен остаться счётчиком — хранение экземпляров вместо суммы невозможно, это приведёт к неконтролируемому росту данных (представьте цифровой товар с наличием в 2 млрд экземпляров).
Идея: буферизация запросов.
А что, если обрабатывать запросы пачками, как сообщения в очереди (Kafka или другая MQ)?
Собираем запросы, пока не наберётся нужное количество или не пройдёт максимальное время накопления.

Сделали схему и прототип

Результат: запросы группируются и обрабатываются одной транзакцией с одной блокировкой на товар и одним подключением к БД.
Плюсы:
меньше блокировок на БД;
выше пропускная способность.
Минусы:
крупные резервы, попавшие в буфер с маленькими, могут «разбавить» эффективность;
чем больше экземпляров приложения, тем хуже накапливаются буферы;
при редких запросах растёт время ответа.
MVP-решение
Когда мы посмотрели на профиль трафика, стало ясно: его можно разделить на три чёткие категории.
Один товар (Single item)
Самый частый сценарий во время акции: запрос на резерв одного товара — например, той самой сковородки.Смешанный (Mixed)
Запросы на резерв от 2 до 10 товаров, часто пересекающихся между собой. Такие хорошо сжимаются в один буфер.Крупный (Wide)
Запросы на резерв с 10+ уникальными товарами. Они обрабатываются дольше и плохо буферизуются — проще ставить их в очередь на последовательную обработку, чтобы не перегружать БД.
Мы настроили систему так, чтобы каждый тип обрабатывался по-своему:
Single — идеально ложится в буферизацию, минимальные накладные расходы;
Mixed — тоже буферизуется, но с учётом количества уникальных товаров;
Wide — сразу в последовательную обработку, без попыток буферизации.


Фоновые процессы

Что это дало
Стабильное время ответа в зависимости от типа запроса.
Смешанные всегда обрабатываются за предсказуемое время.
Крупные идут последовательно, не создавая широких блокировок в БД.
Минус: при отсутствии пиков мы всё равно имеем повышенное время ответа из-за ожидания наполнения буфера.
Доработка 1. Улучшаем время ответа
А можно ли сделать так, чтобы при низкой нагрузке не ждать заполнения буфера, а отправлять запросы сразу, как только освободились ресурсы?
Оказалось, что да.
Для этого мы убрали фиксированный тайм-аут и сделали более гибкую схему:
каждый буфер идёт в свою очередь;
как только обработка одного буфера завершена — берём следующий (или ждём, пока он появится).
Логика обработки
Всё зависит от количества товаров в запросе.
1 позиция:
Ищем уже существующий буфер для этого item_id.
Если нашли — добавляем в него.
Если нет — создаём новый и ставим в очередь на обработку.
Несколько позиций:
Есть один «общий» буфер, который наполняется запросами.
Когда он заполнен — закрываем и создаём новый.
«Широкий» резерв:
Идёт в очередь без буферизации. Один буфер = один запрос.
Чем это лучше MVP

Ключевое отличие — теперь мы не ждём наполнения буфера в момент обработки.
Если трафик низкий, буфер может содержать всего один запрос — и он уйдёт в работу сразу, без лишней задержки.
Если трафик растёт — буфер всё равно наполняется, и мы экономим на блокировках и подключениях к БД.
Доработка 2. Убираем конкуренцию между экземплярами
В какой-то момент объём данных в БД вырос настолько, что процесс VACUUM мог работать часами, а иногда и больше суток. Это уже начинало сказываться на производительности.
Мы поняли, что нужно разделить нагрузку. Партицирование таблиц рассматривали, но по трудозатратам оно выходило почти так же, как полноценное шардирование. Поэтому выбрали второй вариант.
Как мы шардировались
Из одной большой БД сделали две:
БД для операций — хранит резервы;
БД для остатков — хранит данные об остатках и часть информации о резервах.

Как это сказалось на буфериза��ии
После шардирования мы убрали буферизацию по item_id в её прежнем виде. Теперь для каждого шарда действуют два вида буферизации, и трафик по созданию резервов для конкретной виртуальной схемы идёт в один и тот же экземпляр приложения.
Чтобы этого добиться:
резерв разделяется на части, каждая из которых выполняется на своём шарде;
каждая часть обрабатывается тем экземпляром приложения, который отвечает за этот шард.
Изначально мы распределяли эти части через Kafka, позже заменили на синхронный механизм балансировки трафика по ключу (const hash).
Результат:
Больше нет конкуренции между экземплярами за одни и те же данные.
БД спокойно обрабатывает тысячи резервов за одну транзакцию.
При деградации используем время простоя с пользой — обрабатываем накопившиеся запросы одной операцией.

Что получилось в итоге
Теперь наша система больше не боится ни распродаж, ни внезапного наплыва любителей гречки и сковородок.
Блокировки больше не ставят сервис на паузу, а база данных спокойно переваривает тысячи резервов за одну быструю транзакцию.
Все запросы на создание резерва проходят через буферизацию. Если что-то идёт не так, мы используем время простоя, чтобы догнать накопившиеся операции одной пачкой.
Плюсы решения
Система готова к любому пику трафика.
Не нужны ручные действия или корректировки перед распродажами.
Устранена конкуренция между экземплярами приложения.
Минусы решения
Весь трафик по конкретному товару идёт в один экземпляр приложения. Если ему не хватает ресурсов — он может «лечь».
Требуется ограничивать количество InFlight-запросов на вызывающем сервисе.
Когда стоит строить подобный механизм
Если у вас:
высокая конкуренция за одну строку в БД;
нет альтернативных способов разрулить блокировки;
объём операций — тысячи в секунду, либо каждая операция тяжёлая и держит данные долго.
? Для тех, кто хочет «потыкать» рыбу решения:
мы выложили упрощённый вариант на GitHub — внутри проекта есть два теста: один с буферизацией, другой без — https://github.com/ai2user/buffer_sample#
n0isy
Ребята, есть алгоритмы неблокирующие для таких случаев. Смотреть надо в сторону неблокирующих mem_alloc механизмов.
Самый простой способ для вас: создайте 100 виртуальных сковородок, раскидайте на них сток, и любым вариантом rolling преобразуйте [ сковорода_id -> виртуальная_сковорода_id ]. Профит...