InquirerMILF: No unauthorized recruitments in the name of our military armPunchPolice arrest three for tampering with Bayelsa oil wellheadBollywood HungamaVir Hirani to voice Mowgli in upcoming AI-generated feature film inspired by The Jungle BookThe Jerusalem PostWhy Washington's efforts to weaken Iran's influence in Iraq may be doomed to failCNN TürkA Milli Takım, Fransa karşısında 8. maçına çıkacakHet Laatste Nieuws“Ik had verdriet om de dochter die ik niet had”: Dina Tersago (47) doorbreekt taboes in haar eerste boekZDF heuteAktuelle Pressemitteilungen des ZDFSportstarIndia vs Pakistan Live Score, U19 Asia Cup Final: Sehwag falls for 58, Vineeth joins Raichandani; IND 104/1 (19)Rappler5 Philippine creative cities team up to make UNESCO badge countCBS NewsU.N. watchdog agency pushes for access to key Iran nuclear siteNOS EconomieAI verandert de manier waarop we winkelen: 'Gaat enorme impact hebben'Antara NewsBPOM, EDQM team up to boost Indonesian drug quality, competitiveness
The Daily Newsstand · Free, Always
Saturday, November 14, 2026

Road to Highload #1. Наивный checkout и гонка за остатком

Translate

Я за всю свою карьеру не трогал реальные нагрузки в highload. 500-1000 RPS видел только во влажных снах. И в один из них мне пришла идея: если гора не идёт к Магомеду, то Магомед идёт к горе. Поэтому я начинаю серию статей Road to Highload. В ней я использую свои теоретические, практические и новоприобретённые навыки, чтобы исследовать, как ведёт себя функционал под высокой нагрузкой.

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

P.S. Отдельная благодарность своему лиду Сергею за редактуру)

Полигон

  • Стек: ASP.NET Core 10, Aspire, PostgreSQL.

  • Нагрузка: NBomber и bash-воркеры на полигоне.

  • Телеметрия: HTTP/endpoint RPS и latency, потребление процесса (CPU, RSS), метрики PostgreSQL (pg_stat / exporter) в Aspire Dashboard и Grafana.

Магазин на коленке

Представьте маленький интернет-магазин. Один ходовой товар, куча покупателей, которые жмут «оформить» почти одновременно. Поступлений на склад, возвратов и ручных правок остатка пока нет - только списание при успешной покупке.

Нам нужна одна операция: оформить заказ и списать товар. Пока мир простой, проверяем два свойства:

  1. Остаток не уходит в минус.

  2. Учёт сходится: сколько списали + сколько осталось = сколько было в начале.

В экспериментах ниже у всех заказов Quantity = 1, поэтому учёт упрощается до:

stock + completed == initial_stock

Две сущности

Чтобы эту логику вообще написать, хватает двух сущностей: товар и заказ.

Product

public class Product
{
    public Guid Id { get; set; }
    public required string Name { get; set; }
    public int Stock { get; set; }
    public decimal Price { get; set; }
}

Поле

Зачем

Id

Первичный ключ товара

Name

Как зовём на витрине

Stock

Сколько штук ещё можно продать

Price

Цена за единицу; на checkout уйдёт в сумму платежа

Order

public class Order
{
    public Guid Id { get; set; }
    public required string UserId { get; set; }
    public Guid ProductId { get; set; }
    public int Quantity { get; set; }
    public OrderStatus Status { get; set; } // Created | Completed | Rejected
    public DateTimeOffset CreatedAt { get; set; }
}

Поле

Зачем

Id

Идентификатор заказа

UserId

Чей заказ (для простоты - строка из auth)

ProductId

Какой товар покупаем

Quantity

Сколько штук просим

Status

Жизненный цикл: черновик → успех или отказ

CreatedAt

Когда создали

Happy-path пока такой: создали заказ в статусе Created → на checkout либо списали остаток и поставили Completed, либо отказали с Rejected.

Создаём заказ - не особо задумываясь

Сначала метод создания. Не списываем склад, не лезем в платежи - просто фиксируем намерение купить. Явного BEGIN…COMMIT тоже нет: один INSERT, SaveChanges сам откроет короткую транзакцию под капотом.

public async Task<(Order? Order, CreateOrderError Error)> CreateAsync(
    string userId, Guid productId, int quantity, CancellationToken ct)
{
    if (quantity <= 0)
        return (null, CreateOrderError.InvalidQuantity);

    var product = await db.Products.FindAsync([productId], ct);
    if (product is null)
        return (null, CreateOrderError.ProductNotFound);

    var order = new Order
    {
        Id = Guid.NewGuid(),
        UserId = userId,
        ProductId = product.Id,
        Quantity = quantity,
        Status = OrderStatus.Created,
        CreatedAt = DateTimeOffset.UtcNow
    };
    db.Orders.Add(order);
    await db.SaveChangesAsync(ct);
    return (order, CreateOrderError.None);
}

Проход по строкам:

  1. quantity <= 0 - мусор на входе, даже до БД не ходим.

  2. FindAsync - товар должен существовать.

  3. Собираем Order со статусом Created - это ещё не продажа, а «корзина стала заказом».

  4. SaveChanges - один insert.

Наивный checkout без транзакции

Теперь «оплатить и списать». Пишем в лоб, как на собеседовании после первого учебника по EF: прочитали → проверили → вычли → сохранили. Без BeginTransaction.

public async Task<(bool Ok, string? Error, Order? Order)> CheckoutAsync(
    Guid orderId, string userId, CancellationToken ct)
{
    var order = await db.Orders
        .FirstOrDefaultAsync(o => o.Id == orderId && o.UserId == userId, ct);
    if (order is null)
        return (false, "Order not found", null);
    if (order.Status != OrderStatus.Created)
        return (false, $"Order is already {order.Status}", order);

    var product = await db.Products
        .FirstOrDefaultAsync(p => p.Id == order.ProductId, ct);
    if (product is null)
        return (false, "Product not found", order);

    if (product.Stock < order.Quantity)
    {
        order.Status = OrderStatus.Rejected;
        await db.SaveChangesAsync(ct);
        return (false, "Insufficient stock", order);
    }

    db.Payments.Add(new Payment
    {
        Id = Guid.NewGuid(),
        OrderId = order.Id,
        Amount = product.Price * order.Quantity,
        Status = PaymentStatus.Captured
    });
    await db.SaveChangesAsync(ct); // платёж уже в БД

    product.Stock -= order.Quantity;
    order.Status = OrderStatus.Completed;
    await db.SaveChangesAsync(ct); // а тут можем упасть
    return (true, null, order);
}

Что тут происходит по шагам:

  1. Достаём заказ и убеждаемся, что он ещё Created - повторный checkout уже завершённого заказа не пускаем.

  2. Достаём товар. В этот момент в памяти приложения лежит снимок Stock на момент чтения.

  3. if (product.Stock < order.Quantity) - решение «хватит / не хватит» принимаем по этому снимку.

  4. Если не хватило - ставим Rejected и сохраняем. Склад не трогаем.

  5. Если хватило - сначала SaveChanges с Payment, потом отдельно списываем Stock, ставим Completed и ещё один SaveChanges.

На одном покупателе это выглядит прилично. Два покупателя на последней единице - уже рулетка.

Два параллельных checkout на одной единице

Мелочь на глаз: Stock = 1, два тапа по checkout (PARALLEL=2). Ждал честный completed=1, stock=0.

Получил:

completed=2  stock=0  initial_stock=1
assert: OVERSELL detected (Completed > initial_stock)

Два успешных заказа при одной единице на складе. Склад уже соврал - и HTTP при этом зелёный.

Рис. Naive / Endpoint, после двух параллельных checkout: Status 200, ошибок 0%

Рис. Naive / Endpoint, после двух параллельных checkout: Status 200, ошибок 0%

Не хорошо. Но гонку сразу чинить не бросаемся: на успехе и так два SaveChanges подряд. Если первый ушёл в БД, а второй упал - Payment есть, склад нет. Сначала закрываем эту дыру.

Вариант 0.568261. «А давайте обернём в транзакцию»

Первая мысль после учебника: наверное, забыли BEGIN…COMMIT. Оборачиваем тот же RMW в явную транзакцию - и спим спокойнее насчёт «половины» записи.

await using var tx = await db.Database.BeginTransactionAsync(ct);
// … тот же load / check / Stock -= / Payment / Completed …
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);

Что реально меняется: либо заказ, платёж и новое значение Stock уйдут в БД вместе, либо откатится весь наш запрос. Один HTTP-checkout перестаёт оставлять «половину» своих записей.

Чего транзакция не делает: она не говорит второму параллельному запросу «подожди, я ещё читаю Stock». Под обычным READ COMMITTED оба запроса спокойно читают одну и ту же строку Products, оба проходят if, оба готовят свой UPDATE. Координации между ними нет - есть только атомарность внутри каждого запроса.

Две поломки, которые из этого получаются и которые легко спутать:

  • Lost update - два потока прочитали один Stock, оба записали своё; часть списаний «стёрлась».

  • Oversell - успешно завершили больше заказов, чем было товара (completed > initial_stock).

Спокойный день

Пока нагрузка редкая и запросы почти не пересекаются, наивный код с транзакцией не спотыкается. Можно жить и думать, что BEGIN…COMMIT всё решил. Пора ломать.

100 параллельных checkout

Я выставил на складе 100 единиц и поднял PARALLEL=100: сто воркеров гонят цепочку create order → checkout. Ждал честный ноль и ровно 100 Completed.

Не тут-то было:

completed=100  stock=98  initial_stock=100
assert «нет oversell»: зелёный (Completed <= initial_stock)

Assert доволен: Completed не больше ста. Oversell как будто нет. А на складе 98: из 100 единиц в остатке отразилось списание только на 2, хотя успешных заказов 100. Расхождение учёта - 98 (stock + completed = 198 ≠ 100). Это как раз lost update на том самом product.Stock -= order.Quantity из наивного метода. Проверка смотрела только на Completed <= initial_stock.

В Grafana на Endpoint для POST …/checkout то же самое с другой стороны: пачка 200-х, ошибок 0%. HTTP и assert зелёные, а склад уже врёт.

Рис. Naive / Endpoint, сразу после 100 параллельных checkout

Рис. Naive / Endpoint, сразу после 100 параллельных checkout

На Resources в этот момент короткий пик.

Рис. Naive / Resources, тот же прогон

Рис. Naive / Resources, тот же прогон

300 параллельных на 100 единиц

Жёстче (INITIAL_STOCK=100, PARALLEL=300): запросов больше, чем товара. Assert уже орёт:

assert: completed=300 stock=99 initial_stock=100
assert: OVERSELL detected (Completed > initial_stock)

Наивный код уже продаёт «триста из ста». Здесь сразу две беды: oversell и развалившийся учёт. Остаток стал 99, то есть в Stock отразилось списание всего на 1, при 300 успешных заказах. Расхождение - 299 (300 + 99 ≠ 100).

Вернёмся к строкам метода. Оба запроса делают одно и то же:

  1. читают Product → видят, например, Stock = 1;

  2. проходят if (product.Stock < order.Quantity) - обоим «хватит»;

  3. в памяти каждый считает Stock = 0 и готовит Completed;

  4. оба пишут в БД. Последний UPDATE по Id просто кладёт своё значение поверх чужого - предыдущее списание исчезает (lost update). А раз оба успели стать Completed при одной единице на складе - получаем ещё и oversell.

Схематично:

Время   Request A              Request B
T1      Stock=1 → OK
T2                             Stock=1 → OK
T3      Stock=0, Completed
T4                             тоже Completed

Транзакция на один HTTP-запрос ≠ защита от конкурентного read-modify-write. Пора чинить.

Вариант 1. Лок в приложении (AppLock)

Ок, наивный RMW мы уже видели в деле. Первая мысль: раз гонка в процессе, сериализуем checkout по productId через SemaphoreSlim.

var gate = ProductLocks.GetOrAdd(productId, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(ct);
try { return await naive.CheckoutAsync(orderId, userId, ct); }
finally { gate.Release(); }

Лок живёт в памяти одного процесса и только у тех запросов, которые ходят через тот же ConcurrentDictionary. На одном shop-api oversell на полигоне пропал: completed=100, stock=0.

Рис. AppLock / Resources: пик под сериализацией через*

Рис. AppLock / Resources: пик под сериализацией через*

Но представьте два инстанса за reverse proxy. У каждого свой словарь семафоров. Request A на реплике 1 и Request B на реплике 2 снова могут одновременно сделать тот же RMW. Для фазы «один процесс» AppLock ок как рабочий костыль. Для нескольких реплик только одному богу известен результат, пока координация не уйдёт в БД или другой общий лок.

Вариант 2. Атомарный UPDATE в БД (DbAtomic)

Раз два процесса один семафор не делят, следующий шаг: пусть Postgres сам решит «хватит ли» в одном SQL-запросе.

var rows = await db.Database.ExecuteSqlInterpolatedAsync(
    $"""
     UPDATE "Products"
     SET "Stock" = "Stock" - {qty}
     WHERE "Id" = {productId} AND "Stock" >= {qty}
     """,
    ct);

if (rows == 0)
{
    order.Status = OrderStatus.Rejected;
    await db.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);
    return (false, "Insufficient stock", order);
}

Проверка Stock >= qty и уменьшение идут в одной SQL-операции. Под READ COMMITTED в PostgreSQL конкурентный UPDATE той же строки ждёт предыдущий, потом условие пересчитывается уже по актуальному значению. rows == 0 - не списали (не хватило или строки нет), rows == 1 - списание прошло.

Статус заказа и локальная запись Payment в Postgres живут в той же транзакции, что и UPDATE.

На PARALLEL=100 снова completed=100, stock=0.

Рис. DbAtomic / Resources

Рис. DbAtomic / Resources

Вариант 3. SELECT … FOR UPDATE (DbForUpdate)

Классика: row lock и чтение актуального Product одним запросом, потом обычный check/decrement. Всё внутри открытой транзакции.

var product = await db.Products
    .FromSqlInterpolated(
        $"""SELECT * FROM "Products" WHERE "Id" = {productId} FOR UPDATE""")
    .FirstOrDefaultAsync(ct);

return await CheckoutSupport.CompleteRmwAsync(db, order, product, tx, ct);

FOR UPDATE держит строку до Commit/Rollback, сущность сразу tracked с актуальным Stock. На полигоне получилось completed=100, stock=0.

Рис. DbForUpdate / Resources

Рис. DbForUpdate / Resources

Вариант 4. Optimistic concurrency token (Optimistic)

DbAtomic уже был «оптимистичным» на уровне SQL: один условный UPDATE. Здесь другой вкус той же идеи - классический EF RMW с токеном версии.

Помните Product без лишних полей? Вот тут появляется UpdatedAt. Вешаем на него concurrency token:

public class Product
{
    ...
    public DateTimeOffset UpdatedAt { get; set; }
}

// AppDbContext.OnModelCreating
e.Property(x => x.UpdatedAt).IsConcurrencyToken();

Колонка в таблице есть всегда. Перед SaveChanges интерсептор штампует время только если выбрана стратегия Optimistic - иначе токен не двигается, и Naive по-прежнему может словить lost update. Сам checkout ловит конфликт и ретраит:

product.Stock -= order.Quantity;
order.Status = OrderStatus.Completed;

try
{
    await db.SaveChangesAsync(ct); // WHERE UpdatedAt = original
    await tx.CommitAsync(ct);
    return (true, null, order);
}
catch (DbUpdateConcurrencyException)
{
    await db.Entry(product).ReloadAsync(ct);
    order.Status = OrderStatus.Created;
    // следующая попытка: снова check stock / payment / SaveChanges
}

EF пишет UPDATE … SET Stock, UpdatedAt WHERE Id AND UpdatedAt = @original. Ноль строк → DbUpdateConcurrencyException → Reload и ещё круг (до лимита попыток). Это не блокировка строки на всю tx, как FOR UPDATE, и не «хватит ли остатка» в одном SQL, как DbAtomic: проверка Stock снова в приложении, а БД лишь отказывается принять устаревшую версию строки.

На PARALLEL=100 снова completed=100, stock=0. Endpoint зелёный (200), но p50 уже около полутора секунд - часть запросов крутит retry под конкуренцией за один товар.

Рис. Optimistic / Endpoint: Status 200, latency p50 ≈ 1.5s

Рис. Optimistic / Endpoint: Status 200, latency p50 ≈ 1.5s

Рис. Optimistic / Resources: пик working set и thread pool под пачкой checkout

Рис. Optimistic / Resources: пик working set и thread pool под пачкой checkout

Сводка с полигона

Сравнил пять стратегий. Учёт и нагрузка на shop-api - с compare INITIAL_STOCK=100, PARALLEL=300 (Naive…DbForUpdate одним прогоном; Optimistic отдельно). Ресурсы Postgres - с тех же Grafana-скринов PostgreSQL после PARALLEL=100, что выше по тексту (CPU/память процесса БД, активные сессии).

Проверка инварианта

Стратегия

Oversell?

Completed

Stock

Naive

да

300

99

AppLock

нет

100

0

DbAtomic

нет

100

0

DbForUpdate

нет

100

0

Optimistic

нет

100

0

Инвариант для «зелёных» стратегий: completed=100, stock=0 (и stock + completed == initial_stock).

Метрики с БД

Стратегия

CPU макс., с

Resident Mem макс., МиБ

Active sessions (пик)

Naive

3.25

2.05

2

AppLock

3.56

2.15

2

DbAtomic

3.56

1.63

9

DbForUpdate

3.56

1.93

9

Optimistic

3.02

1.58

2

CPU здесь - накопленное CPU time процесса Postgres на графике за окно.

На DbAtomic / DbForUpdate в пике видно больше активных сессий к appdb - логично: работа уезжает в БД (условный UPDATE или FOR UPDATE).

Метрики с приложения

Стратегия

Время, с

CPU ср.

CPU макс.

RSS макс., МиБ

Naive

12.1

66.4

148.8

281

AppLock

9.7

52.7

161.7

330

DbAtomic

8.6

29.3

73.4

408

DbForUpdate

10.1

30.2

91.6

481

Optimistic

5.1

0.0

0.0

72

Метрики с приложения: latency checkout

Стратегия

p50

p99

Naive

50 ms

2.17 s

AppLock

328 ms

2.44 s

DbAtomic

178 ms

2.28 s

DbForUpdate

87 ms

925 ms

Optimistic

1.54 s

4.22 s

Вот где Optimistic «дороже»: медиана уже полторы секунды, p99 за четыре - это retry под конкуренцией за один Stock.

Вместо заключения

Что эксперимент реально показал:

  • Наивный RMW ломается уже на конкурентной пачке, без «тысячи RPS».

  • HTTP 200 и Completed <= initial_stock недостаточно: на PARALLEL=100 учёт разъехался (stock=98 при 100 Completed) при зелёном assert.

  • BEGIN…COMMIT на один запрос не заменяет координацию между запросами.

Правила, которые показал этот заезд:

  1. Транзакция на один HTTP-запрос ≠ защита от конкурентного read-modify-write.

  2. Смотреть не только HTTP 200 и Completed <= initial_stock, а stock + sold == initial_stock.

  3. Лок в памяти процесса чинит один инстанс, не кластер.

  4. Каждое из решений - лишь борьба с ограничениями либо приложения, либо БД: AppLock упирается в один процесс, DbAtomic / FOR UPDATE грузят и блокируют Postgres, optimistic token + retry раздувают latency на клиенте.

Код полигона: github.com/LittleDeveloper-CSharp/Road-to-highload.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.