Я за всю свою карьеру не трогал реальные нагрузки в 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.
Магазин на коленке
Представьте маленький интернет-магазин. Один ходовой товар, куча покупателей, которые жмут «оформить» почти одновременно. Поступлений на склад, возвратов и ручных правок остатка пока нет — только списание при успешной покупке.
Нам нужна одна операция: оформить заказ и списать товар. Пока мир простой, проверяем два свойства:
-
Остаток не уходит в минус.
-
Учёт сходится: сколько списали + сколько осталось = сколько было в начале.
В экспериментах ниже у всех заказов 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 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);}
Проход по строкам:
-
quantity <= 0— мусор на входе, даже до БД не ходим. -
FindAsync— товар должен существовать. -
Собираем
Orderсо статусомCreated— это ещё не продажа, а «корзина стала заказом». -
SaveChanges— один insert.
Наивный checkout без транзакции
Теперь «оплатить и списать». Пишем в лоб, как на собеседовании после первого учебника по EF: прочитали → проверили → вычли → сохранили. Без BeginTransaction.
public async Task 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);}
Что тут происходит по шагам:
-
Достаём заказ и убеждаемся, что он ещё
Created— повторный checkout уже завершённого заказа не пускаем. -
Достаём товар. В этот момент в памяти приложения лежит снимок
Stockна момент чтения. -
if (product.Stock < order.Quantity)— решение «хватит / не хватит» принимаем по этому снимку. -
Если не хватило — ставим
Rejectedи сохраняем. Склад не трогаем. -
Если хватило — сначала
SaveChangesсPayment, потом отдельно списываемStock, ставимCompletedи ещё одинSaveChanges.
На одном покупателе это выглядит прилично. Два покупателя на последней единице — уже рулетка.
Два параллельных checkout на одной единице
Мелочь на глаз: Stock = 1, два тапа по checkout (PARALLEL=2). Ждал честный completed=1, stock=0.
Получил:
completed=2 stock=0 initial_stock=1assert: OVERSELL detected (Completed > initial_stock)
Два успешных заказа при одной единице на складе. Склад уже соврал — и HTTP при этом зелёный.
Рис. 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=100assert «нет 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
На Resources в этот момент короткий пик.
Рис. Naive / Resources, тот же прогон300 параллельных на 100 единиц
Жёстче (INITIAL_STOCK=100, PARALLEL=300): запросов больше, чем товара. Assert уже орёт:
assert: completed=300 stock=99 initial_stock=100assert: OVERSELL detected (Completed > initial_stock)
Наивный код уже продаёт «триста из ста». Здесь сразу две беды: oversell и развалившийся учёт. Остаток стал 99, то есть в Stock отразилось списание всего на 1, при 300 успешных заказах. Расхождение — 299 (300 + 99 ≠ 100).
Вернёмся к строкам метода. Оба запроса делают одно и то же:
-
читают
Product→ видят, например,Stock = 1; -
проходят
if (product.Stock < order.Quantity)— обоим «хватит»; -
в памяти каждый считает
Stock = 0и готовитCompleted; -
оба пишут в БД. Последний
UPDATEпоIdпросто кладёт своё значение поверх чужого — предыдущее списание исчезает (lost update). А раз оба успели статьCompletedпри одной единице на складе — получаем ещё и oversell.
Схематично:
Время Request A Request BT1 Stock=1 → OKT2 Stock=1 → OKT3 Stock=0, CompletedT4 тоже 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: пик под сериализацией через*
Но представьте два инстанса за 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
Вариант 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
Вариант 4. Optimistic concurrency token (Optimistic)
DbAtomic уже был «оптимистичным» на уровне SQL: один условный UPDATE. Здесь другой вкус той же идеи — классический EF RMW с токеном версии.
Помните Product без лишних полей? Вот тут появляется UpdatedAt. Вешаем на него concurrency token:
public class Product{ ... public DateTimeOffset UpdatedAt { get; set; }}// AppDbContext.OnModelCreatinge.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 / 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на один запрос не заменяет координацию между запросами.
Правила, которые показал этот заезд:
-
Транзакция на один HTTP-запрос ≠ защита от конкурентного read-modify-write.
-
Смотреть не только HTTP 200 и
Completed <= initial_stock, аstock + sold == initial_stock. -
Лок в памяти процесса чинит один инстанс, не кластер.
-
Каждое из решений — лишь борьба с ограничениями либо приложения, либо БД:
AppLockупирается в один процесс,DbAtomic/FOR UPDATEгрузят и блокируют Postgres, optimistic token + retry раздувают latency на клиенте.
Код полигона: github.com/LittleDeveloper-CSharp/Road-to-highload.
Источник: habr.com