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

Всем привет!
Меня зовут Александр Исай, я тимлид в 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-решение

Когда мы посмотрели на профиль трафика, стало ясно: его можно разделить на три чёткие категории.

  1. Один товар (Single item)
    Самый частый сценарий во время акции: запрос на резерв одного товара — например, той самой сковородки.

  2. Смешанный (Mixed)
    Запросы на резерв от 2 до 10 товаров, часто пересекающихся между собой. Такие хорошо сжимаются в один буфер.

  3. Крупный (Wide)
    Запросы на резерв с 10+ уникальными товарами. Они обрабатываются дольше и плохо буферизуются — проще ставить их в очередь на последовательную обработку, чтобы не перегружать БД.

Мы настроили систему так, чтобы каждый тип обрабатывался по-своему:

  • Single — идеально ложится в буферизацию, минимальные накладные расходы;

  • Mixed — тоже буферизуется, но с учётом количества уникальных товаров;

  • Wide — сразу в последовательную обработку, без попыток буферизации.

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

Что это дало

  • Стабильное время ответа в зависимости от типа запроса.

  • Смешанные всегда обрабатываются за предсказуемое время.

  • Крупные идут последовательно, не создавая широких блокировок в БД.

Минус: при отсутствии пиков мы всё равно имеем повышенное время ответа из-за ожидания наполнения буфера.

Доработка 1. Улучшаем время ответа

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

Оказалось, что да.

Для этого мы убрали фиксированный тайм-аут и сделали более гибкую схему:

  1. каждый буфер идёт в свою очередь;

  2. как только обработка одного буфера завершена — берём следующий (или ждём, пока он появится).

Логика обработки

Всё зависит от количества товаров в запросе.

1 позиция:

  • Ищем уже существующий буфер для этого item_id.

  • Если нашли — добавляем в него.

  • Если нет — создаём новый и ставим в очередь на обработку.

Несколько позиций:

  • Есть один «общий» буфер, который наполняется запросами.

  • Когда он заполнен — закрываем и создаём новый.

«Широкий» резерв:

  • Идёт в очередь без буферизации. Один буфер = один запрос.

Чем это лучше MVP

Ключевое отличие — теперь мы не ждём наполнения буфера в момент обработки.


Если трафик низкий, буфер может содержать всего один запрос — и он уйдёт в работу сразу, без лишней задержки.


Если трафик растёт — буфер всё равно наполняется, и мы экономим на блокировках и подключениях к БД.

Доработка 2. Убираем конкуренцию между экземплярами

В какой-то момент объём данных в БД вырос настолько, что процесс VACUUM мог работать часами, а иногда и больше суток. Это уже начинало сказываться на производительности.

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

Как мы шардировались

Из одной большой БД сделали две:

  1. БД для операций — хранит резервы;

  2. БД для остатков — хранит данные об остатках и часть информации о резервах.

Как это сказалось на буферизации

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

Чтобы этого добиться:

  • резерв разделяется на части, каждая из которых выполняется на своём шарде;

  • каждая часть обрабатывается тем экземпляром приложения, который отвечает за этот шард.

Изначально мы распределяли эти части через Kafka, позже заменили на синхронный механизм балансировки трафика по ключу (const hash).

Результат:

  • Больше нет конкуренции между экземплярами за одни и те же данные.

  • БД спокойно обрабатывает тысячи резервов за одну транзакцию.

  • При деградации используем время простоя с пользой — обрабатываем накопившиеся запросы одной операцией.

Что получилось в итоге

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

Блокировки больше не ставят сервис на паузу, а база данных спокойно переваривает тысячи резервов за одну быструю транзакцию.

Все запросы на создание резерва проходят через буферизацию. Если что-то идёт не так, мы используем время простоя, чтобы догнать накопившиеся операции одной пачкой.

Плюсы решения

  • Система готова к любому пику трафика.

  • Не нужны ручные действия или корректировки перед распродажами.

  • Устранена конкуренция между экземплярами приложения.

Минусы решения

  • Весь трафик по конкретному товару идёт в один экземпляр приложения. Если ему не хватает ресурсов — он может «лечь».

  • Требуется ограничивать количество InFlight-запросов на вызывающем сервисе.

Когда стоит строить подобный механизм

Если у вас:

  • высокая конкуренция за одну строку в БД;

  • нет альтернативных способов разрулить блокировки;

  • объём операций — тысячи в секунду, либо каждая операция тяжёлая и держит данные долго.

Для тех, кто хочет «потыкать» рыбу решения:
мы выложили упрощённый вариант на GitHub — внутри проекта есть два теста: один с буферизацией, другой без — https://github.com/ai2user/buffer_sample#

Комментарии (22)


  1. n0isy
    26.09.2025 12:55

    Ребята, есть алгоритмы неблокирующие для таких случаев. Смотреть надо в сторону неблокирующих mem_alloc механизмов.

    Самый простой способ для вас: создайте 100 виртуальных сковородок, раскидайте на них сток, и любым вариантом rolling преобразуйте [ сковорода_id -> виртуальная_сковорода_id ]. Профит...


    1. ai_say Автор
      26.09.2025 12:55

      Есть явные причины так не делать под высокой нагрузкой.

      Первая причина: подключения к БД

      Потребуется огромное количество соединений в моменте вместо одного при буферизации

      В добавок к этому postgres не может нам гарантировать время записи, есть множество фоновых процессов про которые приложение не в курсе (бэкап / вакум и т.д.)

      Результат:

      При любой деградации БД мы упрёмся в пул подключений на приложении / пуллере БД. (фатально)

      Вторая: бесконтрольный рост данных.

      Каждая операция по установке наличия из сложности O(1) превратится в O(n)

      В БД вместо 1 записи будет постоянно удалятся и добавляться новые вместо обновления одного поля (IO)

      Результат:

      Общий объём хранимых данных будет увеличен в неизвестное число раз, что так-же сократит общую пропускную способность. Увеличится объём IO, увеличится среднее время использования подключения. (зачем вносить такой рандом?)

      Третья: проблемы из-за конкуренции.

      Запросы которым требуется больше 1 экземпляра (хочу 5 сковородок и 1 телевизор )

      Тут придётся заблокировать N бакетов если в первом не хватает. (допустим есть 5 бакетов и в каждом осталось по 1 шт)

      В итоге получаем что мы заблокировали все бакеты и заставили ждать следующие запросы по этому товару.

      Результат: ближе к окончанию товара система перестанет отвечать на запросы из-за того что все подключения заняты и ждут получения бакета (фатально)

      Саммари:
      Предложенное решение понижает пропускную способность и надёжность, при этом усложняет систему


      1. ValeriyPus
        26.09.2025 12:55

        Orleans.

        Богатые доменные модели в оперативке (и дампятся в хранилище автоматом).

        Хоть 1M RPS (упретесь в CPU\сеть) :)

        Даже с транзакциями(!).

        Но есть минусы.


        1. ai_say Автор
          26.09.2025 12:55

          Были мысли в этом направлении, но в изначальных требованиях к решению были Strong consistency и запрет на хранение бизнес данных в памяти приложения, потому дальше мыслей не ушло.


          1. ValeriyPus
            26.09.2025 12:55

            Orleans так-то ACID.

            Ну и весь код с Orleans - Положить запросы в шину,

            При обработке:

            1) дергать Orleans (bookTemporary(int count, int itemId), получаем guid)

            2) отсылаем в шину операции на бронирование заказов (там BatchInsert)

            3) отсылаем в шину операцию bookFinalize(подтверждаем бронирование)

            Все, 100+ RPS.

            За счет outboxConsistency точно не будет повисших бронирований.

            Даже если шина вдруг откажет (ага), остатки будут возвращены на склад


            1. gandjustas
              26.09.2025 12:55

              Сделаете пример с транзакционным orleans с 100+ RPS. Даже не на товар, а всего на систему, которая запускается на локальной машине и может масштабироваться?


              1. ValeriyPus
                26.09.2025 12:55

                Бесплатно - нет


      1. gandjustas
        26.09.2025 12:55

        При любой деградации БД мы упрёмся в пул подключений на приложении / пуллере БД

        Так вы в любом случае упретесь, не? У вас же несколько инстансов делают одно и то же и все они сходятся только в СУБД

        В БД вместо 1 записи будет постоянно удалятся и добавляться новые вместо обновления одного поля (IO)

        Вы же в курсе, что в postgres обновление строки = пометка удаления (запись xmax) + создание новой строки?


        1. ai_say Автор
          26.09.2025 12:55

          Так вы в любом случае упретесь, не? У вас же несколько инстансов делают одно и то же и все они сходятся только в СУБД

          Тут речь про MVP? дальше в статье мы убрали конкуренцию за хост между инстансами.

          На этапе MVP действительно каждый экземпляр работал абсолютно независимо и они могли конкурировать за БД, но в гораздо меньших масштабах чем до, т.к. Worker, который разбирает очередь буферов, был однопоточный и не мог забрать более 1 подключения в моменте. В худшем случае количество подключений = количество подов * параллелизм (на MVP был 1)

          Вы же в курсе, что в postgres обновление строки = пометка удаления (запись xmax) + создание новой строки?

          Конечно :)


  1. ValeriyPus
    26.09.2025 12:55

    А вообще забавно.

    10 RPS

    Если просто взять, записать все в шину (шины могут быть развернуты на 100 инстансах, и писать гигабиты запросов) - можно тоже получить 10 кRPS.

    Ну и как-то сложно.

    Вот генерация веб-апи, которое пишет все в шину и разбирает по батчам (для IOT).

    https://habr.com/ru/articles/906778


    1. ai_say Автор
      26.09.2025 12:55

      Это не решает задачу из статьи.

      Нам нужно вернуть ответ клиенту-сервису здесь и сейчас в ограниченное время на синхронный запрос, с конкретным результатом (вышло зарезервировать или нет).


      1. ValeriyPus
        26.09.2025 12:55

        Как не решает?

        Остается только добавить возвращение результата (тоже через шину(!))

        Почти akka.net

        Есть явные причины так не делать под высокой нагрузкой.

        Orleans offers high "actor speed" through a framework that keeps grain (actor) state in memory, providing low-latency access for real-time applications and enabling massive horizontal scalability by distributing grains across many silos (servers) and managing their lifecycles automatically. This in-memory state management is significantly faster than database roundtrips, allowing the system to achieve high throughput and sub-millisecond responses for concurrent tasks

        У вас сколько типов товаров на распродаже? Меньше миллиарда (Чтобы на одном сервере поместились все акторы)?

        Отлично.

        10 кRPS.

        Могу даже бесплатно подсказать:

        Актор у вас

        — item_id — уникальный идентификатор товара;

        — warehouse_id — склад, где товар лежит;

        — stock — общее количество (сколько товара на складе прям сейчас);

        — reserved — сколько уже зарезервировано.

        Ну а записи о резервировании опять в шину (персистентно, горизонтально масштабируемо), а оттуда - в БД (батчами).

        Поздравляю, вы построили систему бронирования для миллиарда типов товаров, которая выдерживает

        10 КRPS.

        Саммари:

        Автор вообще слабо представляет Highload, ругает стандартные подходы (вроде Orleans) и лепит системы в 10-1000 раз менее производительные, чем можно :)

        Ну а гит - вообще прелестно. Хоть Concurrent коллекцию возьмите :)


        1. gandjustas
          26.09.2025 12:55

          Как вы добьетесь того, чтобы stock >= reserved для всех записей всегда? Если правила нарушается, то резерв надо отменить, а пользователю сказать "простите, товар закончился на складе"?


          1. ValeriyPus
            26.09.2025 12:55

            • Single-threaded execution within a grain: 

              The Orleans runtime guarantees that a grain instance will never execute on more than one thread at a time. This means that within the context of a single grain, you do not need to worry about race conditions or shared state being accessed concurrently by multiple threads.

            Вы через WebApi дергаете BookItems

            В Orleans.

            Там (В методе BookItems у объекта ShopItem) в транзакции

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

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

            @ai_say

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

            https://learn.microsoft.com/ru-ru/dotnet/orleans/grains/transactions


            1. gandjustas
              26.09.2025 12:55

              Single-threaded execution within a grain

              У вас больше одного сервера, где выполняются ваши grains

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

              Ну конечно могу. Более того, так и должен делать. Иначе придется или блокировки навешивать или вы обязательно зарегистрируете больше, чем есть на складе.

              Там (В методе BookItems у объекта ShopItem) в транзакции

              В транзакции база сама проверяет ограничения ПОСЛЕ записи - уникальность (при обновлении индекса), внешние ключи, и прочие check constraints. Вам не нужно придумывать транзакционность в приложении

              https://learn.microsoft.com/ru-ru/dotnet/orleans/grains/transactions

              А вы сами читали? Транзакционность в Orleans реализована за счет сохранения состояния в транзакционном хранилище, то есть в БД.


              1. ValeriyPus
                26.09.2025 12:55

                А вы сами читали? Транзакционность в Orleans реализована за счет сохранения состояния в транзакционном хранилище, то есть в БД.

                Продолжайте читать. Если вам советуют почитать (и даже ссылки кидают) - надо говорить Спасибо за ценную информацию.

                Не надо делать какие-то невнятные заявления о транзакциях БД и прочем, в надежде что Вам сейчас все разъяснят.

                Автор вообще слабо представляет Highload, ругает стандартные подходы (вроде Orleans) и лепит системы в 10-1000 раз менее производительные, чем можно :)

                Ну а гит - вообще прелестно. Хоть Concurrent коллекцию возьмите :)


  1. gandjustas
    26.09.2025 12:55

    Я так и не понял: буфер это что? Где он хранится?

    Как работает со стороны пользователя? Он кликает оплатить и идет курить пока буфер заполнится?

    А если за время заполнения буфера клиент запрос отменит?


    1. ai_say Автор
      26.09.2025 12:55

      Я так и не понял: буфер это что? Где он хранится?

      Как работает со стороны пользователя? Он кликает оплатить и идет курить пока буфер заполнится?

      К статье приложен сэмпл с кодом, он возможно ответит на вопрос лучше всего. :)

      Буфер по сути - это массив прилетевших запросов на резерв(CancellationToken в том числе) в приложении. Этот список уходит в работу как только worker закончит работу с прошлым (большую часть времени это происходит мгновенно).

      Никакой искусственной задержки в коде нет. worker свободен - обрабатываем, нет - ждём в очереди.(это есть в сэмпле) RT обычно ниже 100мс, так-же есть верхний порог в несколько секунд по SLA, при достижении - запрос будет автоматически отменён.

      А если за время заполнения буфера клиент запрос отменит?

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

      • Если запрос отменён до старта работы с БД. Мы пропустим такой запрос (проверив CancellationToken из запроса) и даже БД трогать не будем. Через TCS выставим Cancelled и готово (клиент-сервис получит Cancelled)

      • Если это произошло после работы с БД. Резерв не будет подтверждён отдельной командой (оставляй резерв) и будет автоматически аннулирован фоновым процессом по наступлению дедлайна, который устанавливает сам сервис-клиент.
        Грубо говоря с каждым запросом на создание прилетает информация "сколько держать резерв без подтверждения".


      1. gandjustas
        26.09.2025 12:55

        То есть вы этим буфером просто батчите обновления БД, чтобы было не одно подключение к базе на каждый запрос пользователя?

        Почему тогда не воспользоваться стандартными System.Threading.Channels? Делаете консьюмеров для него столько, сколько вы хотите максимум соединений открывать. Можно даже написать свой монитор количества элементов в канале и спавнить новые задачи для открытия.


        1. ai_say Автор
          26.09.2025 12:55

          То есть вы этим буфером просто батчите обновления БД, чтобы было не одно подключение к базе на каждый запрос пользователя?

          Да, на входе получаем множество запросов на создание, на выходе батч запросов, которые можно обработать за 1 проход.

          Например

          1. Приходит 100 запросов на создание резерва по item_id 1 и 2 в буфер

          2. Из буфера мы получаем батч на 100 запросов

          3. Проверяем отменённые запросы, исключаем их

          4. (БД) Запускаем транзакцию

          5. (БД) Берём блокировку (pg_advisory … 1,2)

          6. (БД) Получаем товарные остатки для товара 1 и 2

          7. (БД) Получаем информацию по резервам, которые были ранее обработаны (запрос на создание может быть повторным)

          8. Исключаем обработанные (иначе будет оверрезерв)

          9. Последовательно рассчитываем возможность создания каждого резерва и крутим счётчик товара в памяти

          10. (БД) Сохраняем новое состояние товарных остатков

          11. (БД) Сохраняем новые резервы

          12. (БД) Комит

          13. Выставляем каждому запросу свой результат (Создан, не хватило стока или резерв уже есть)

          Почему тогда не воспользоваться стандартными System.Threading.Channels? Делаете консьюмеров для него столько, сколько вы хотите максимум соединений открывать. Можно даже написать свой монитор количества элементов в канале и спавнить новые задачи для открытия.

          Мы им и пользуемся :)

          Со стороны запроса:

          1. Приходит запрос -> кладём его в активный буфер (массив)

          2. Добавляем буфер в очередь на обработку (Channel), если его ещё там нет

          3. …В буфер продолжают добавляться новые запросы

          Со стороны workera:

          while (true){await handler.HandleAsync(await buffers.DequeueAsync());}

          Под капотом _buffersQueue.Reader.ReadAsync()

          В момент Dequeue буфер перестанет быть активным, вместо него будет создан новый.

          Если убрать промежуточное звено в виде буфера и класть запросы напрямую в Channel, то на стороне workerа надо будет как-то набирать нужное количество запросов, появятся какой-то искусственный delay при слабом трафике (если трафика мало, то запросы могут уходить в работу слишком поздно, потому кроме счётчика требуется и дедлайн на время накопления)

          Мы прошли этот шаг на стадии MVP. RT при спокойном трафике держался около 500мс (задача по наступлению дедлайна), а под нагрузкой – начинал снижаться (задача из-за накопления нужного размера) Нам не нравилась эта искусственная задержка.


          1. gandjustas
            26.09.2025 12:55

            Теперь понятно откуда такие проблемы с перфомансом. Задача по обновлению остатков при поступлении заказа в postgres решается на уровне Read Commited одним запросом, а вы на advisory сделали медленный вариант Serializable в SQL Server\MySQL и кучу раз бегаете в базу.

            1. Приходит запрос -> кладём его в активный буфер (массив)

            2. Добавляем буфер в очередь на обработку (Channel), если его ещё там нет

            3. …В буфер продолжают добавляться новые запросы

            Вам тогда не нужен буфер. Ченнел - уже очередь (LIFO буфер). Каждый запрос пишет в writer, а на стороне reader делаете:

            while(await reader.WaitToReadAsync())
            {
                List<T> batch = new();
                while(batch.Count < MAX_ITEMS_IN_BATCH 
                      && reader.TryRead(out var item)) batch.Add(item);  
                await ProcessBatch(batch);    
            }

            Если убрать промежуточное звено в виде буфера и класть запросы напрямую в Channel, то на стороне workerа надо будет как-то набирать нужное количество запросов, появятся какой-то искусственный delay при слабом трафике (если трафика мало, то запросы могут уходить в работу слишком поздно, потому кроме счётчика требуется и дедлайн на время накопления)

            В том-то и дело, что нет. Код выше выбирает просто все запросы, которые есть сейчас в очереди. Он конечно дает задержку, пока вы наберете свои 50, или сколько там у вас запросов, но она на три порядка меньше чем один раунд-трип в базу.

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


            1. ai_say Автор
              26.09.2025 12:55

              Теперь понятно откуда такие проблемы с перфомансом. Задача по обновлению остатков при поступлении заказа в postgres решается на уровне Read Commited одним запросом, а вы на advisory сделали медленный вариант Serializable в SQL Server\MySQL и кучу раз бегаете в базу.

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

              Для принятия решения о каждом резерве - требуется
              1. Знание о его существовании в БД
              2. Наличие свободного стока для всех позиций резерва

              Еслиб можно было крутить только счётчик без всей дополнительной работы - было бы значительно проще :)

              while(await reader.WaitToReadAsync()){    
                List<T> batch = new();    
                while(batch.Count < MAX_ITEMS_IN_BATCH           
                      && 
                      reader.TryRead(out var item)) batch.Add(item);      
                await ProcessBatch(batch);    
              }

              т.к. буферизация под каждую схему(шард) отдельная - сервис/клиент может попробовать создать резерв в ту схему, где нет "популярных товаров" и отвалится по таймауту.
              Мы не наберём там больше 1 запроса за требуемое время и клиент отменит запрос.