Конкурентные остатки: блокировки, дедлоки, гонки
Списание остатка товара — самое проверяемое место в подобном тестовом на собесе: единственная точка, где несколько параллельных запросов реально спорят за одну и ту же строку БД. Разбор — на реальном коде kitchen-service из «Avito.Кухня» (см. общий разбор в «Защита тестового Avito на реальном проекте»).
Два способа списать остаток
tx, _ := pool.Begin(ctx)
var stock int
tx.QueryRow(ctx,
"SELECT stock FROM items WHERE id=$1 FOR UPDATE",
itemID).Scan(&stock)
if stock < qty {
tx.Rollback(ctx)
return ErrInsufficientStock
}
tx.Exec(ctx,
"UPDATE items SET stock = stock - $1 WHERE id = $2",
qty, itemID)
tx.Commit(ctx)tag, err := tx.Exec(ctx,
`UPDATE items SET stock = stock - $1
WHERE id = $2 AND stock >= $1`,
qty, itemID)
if tag.RowsAffected() == 0 {
tx.Rollback(ctx)
return ErrInsufficientStock
}
tx.Commit(ctx)Первый вариант — два запроса (SELECT ... FOR UPDATE, потом UPDATE) и явная блокировка строки на всё время между ними. Второй — один запрос, проверка «хватает ли» встроена прямо в WHERE: RowsAffected() == 0 значит либо строки не существует, либо stock < qty. Критическая секция короче ровно на один round-trip к БД, и держать отдельную блокировку не нужно — атомарность гарантирует сам UPDATE.
RowsAffected() == 0 — это и есть проверка «не хватило», без отдельного чтения текущего значения перед изменением.
Почему порядок позиций важен
Заказ с несколькими товарами списывает остаток по каждому в цикле внутри одной транзакции. Если два параллельных заказа берут одни и те же два товара в разном порядке — классический дедлок:
Заказ A: списывает item_1, затем ждёт lock на item_2
Заказ B: списывает item_2, затем ждёт lock на item_1
-> оба ждут друг друга вечно, Postgres рано или поздно убьёт одну из транзакций
с ошибкой "deadlock detected"
Починка дешёвая — отсортировать позиции по item_id (или любому другому детерминированному ключу) один раз перед циклом списания:
sort.Slice(items, func(i, j int) bool {
return items[i].ItemID.String() < items[j].ItemID.String()
})Теперь оба заказа A и B идут в одном и том же порядке — item_1, потом item_2 — и просто выстраиваются в очередь на первую строку вместо взаимной блокировки. Дешёвая защита (одна сортировка), убирающая целый класс дедлоков без изменения бизнес-логики.
Дубль item_id в одном заказе — реальный найденный баг
Тело запроса POST /orders может по ошибке (или невнимательности клиента) содержать два элемента с одним и тем же item_id:
{"items": [{"item_id": "X", "quantity": 2}, {"item_id": "X", "quantity": 3}]}
Ожидание — это эквивалентно одной строке {"item_id": "X", "quantity": 5}. Баг, который реально был в коде: список ID для похода в БД строился с дублями ([X, X]), а количество считалось в map[itemID]qty — без дублей. GetManyByIDs(ctx, []uuid.UUID{X, X}) в репозитории схлопывал дубли сам (обычный WHERE id = ANY($1) не возвращает две строки для одного ID), и код, ожидавший результат той же длины, что и входной список, получал расхождение и не находил часть позиций → misleading 404 not_found вместо здравой обработки дублей.
itemIDs := make([]uuid.UUID, 0, len(input.Items))qtyByItem := make(map[uuid.UUID]int)for _, it := range input.Items { itemIDs = append(itemIDs, it.ItemID) qtyByItem[it.ItemID] += it.Quantity}qtyByItem := make(map[uuid.UUID]int)for _, it := range input.Items { qtyByItem[it.ItemID] += it.Quantity}itemIDs := make([]uuid.UUID, 0, len(qtyByItem))for id := range qtyByItem { itemIDs = append(itemIDs, id)}Урок для собеса, не только для этого конкретного бага: если для одного и того же ключа в коде существует две разные коллекции (список с дублями и map без дублей), они обязаны строиться из одного источника, иначе рано или поздно разойдутся по длине. Список ID теперь строится из ключей уже готовой map — расхождение структурно невозможно, а не «проверено тестом и вроде не расходится». Запусти сам, чтобы увидеть расхождение длин вживую:
Trade-off одной большой транзакции на заказ
CreateOrder целиком (списание остатков по всем позициям, вставка заказа, вставка order_items, запись в outbox) — одна транзакция. Плюс — атомарность: частично созданного заказа быть не может. Минус — чем больше позиций в заказе, тем дольше держится транзакция, тем дольше блокировки на строках items. Для заказа из 3-5 позиций это доли миллисекунды и не имеет значения. Для гипотетического заказа на сотни позиций это стало бы узким местом — там правильный следующий шаг не разбивать транзакцию (это сломает атомарность), а списывать остаток одним batch-запросом вместо цикла:
UPDATE items
SET stock = stock - v.qty
FROM (VALUES ($1::uuid, $2::int), ($3::uuid, $4::int)) AS v(id, qty)
WHERE items.id = v.id AND items.stock >= v.qty
На собесе стоит уметь назвать это как явный, осознанный trade-off (простой цикл проще читать и отлаживать, batch-запрос быстрее при большом N), а не как то, о чём не подумал.