Я за всю свою карьеру не трогал реальные нагрузки в 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; }
}
Поле | Зачем |
|---|---|
| Первичный ключ товара |
| Как зовём на витрине |
| Сколько штук ещё можно продать |
| Цена за единицу; на 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; }
}
Поле | Зачем |
|---|---|
| Идентификатор заказа |
| Чей заказ (для простоты - строка из auth) |
| Какой товар покупаем |
| Сколько штук просим |
| Жизненный цикл: черновик → успех или отказ |
| Когда создали |
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);
}
Проход по строкам:
quantity <= 0- мусор на входе, даже до БД не ходим.FindAsync- товар должен существовать.Собираем
Orderсо статусомCreated- это ещё не продажа, а «корзина стала заказом».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);
}
Что тут происходит по шагам:
Достаём заказ и убеждаемся, что он ещё
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=1
assert: OVERSELL detected (Completed > initial_stock)
Два успешных заказа при одной единице на складе. Склад уже соврал - и HTTP при этом зелёный.

Не хорошо. Но гонку сразу чинить не бросаемся: на успехе и так два 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 зелёные, а склад уже врёт.

На 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).
Вернёмся к строкам метода. Оба запроса делают одно и то же:
читают
Product→ видят, например,Stock = 1;проходят
if (product.Stock < order.Quantity)- обоим «хватит»;в памяти каждый считает
Stock = 0и готовитCompleted;оба пишут в БД. Последний
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.

Но представьте два инстанса за 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.

Вариант 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.

Вариант 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 под конкуренцией за один товар.


Сводка с полигона
Сравнил пять стратегий. Учёт и нагрузка на 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.

Комментарии 0