Блокировки строк и потерянные обновления
В каждой кофейне Brew установлено мобильное приложение бариста: оно показывает остаток напитка и при продаже уменьшает его на единицу. Код невинный: прочитать on_hand, вычесть 1, записать обратно. На одной кассе всё работает. Но в час пик два бариста на двух кассах продают последний колд брю одновременно: оба прочитали остаток 10, оба записали 9. Продали два — а остаток упал на один. Один декремент потерян. Через неделю инвентаризация не сходится, и никто не понимает почему.
Это потерянное обновление (lost update) — классическая ошибка конкурентного доступа. Её корень — паттерн «прочитал в приложении, посчитал, записал назад» (read-modify-write): между чтением и записью значение успевает устареть. В этом юните — три способа с ним справиться, от самого простого к самому общему.
Это escape-hatch-юнит (как 05-02): урок про две конкурентные сессии, и ведём мы его psql-скриптами, а не query.sql + кодоген.
Починка 1: пусть арифметику делает база
Самый частый случай read-modify-write вообще не нужен. Вместо «прочитать on_hand, вычесть в Go, записать» пиши арифметику прямо в UPDATE:
UPDATE seat_lab SET on_hand = on_hand - 1 WHERE id = 1;Теперь чтение и запись — это одна команда. Postgres на время её выполнения берёт блокировку строки (неявно, сам), и конкурентный UPDATE той же строки ждёт её завершения, а потом считает уже от свежего значения. Устаревшего чтения не существует — терять нечего. Девяносто процентов «потерянных обновлений» в приложениях лечатся переносом вычисления из кода в один атомарный UPDATE.
Починка 2: FOR UPDATE, когда read-modify-write неизбежен
Иногда между чтением и записью действительно нужна логика приложения: проверить лимит, обратиться к платёжному шлюзу, принять решение. Тогда строку блокируют явно — при чтении:
BEGIN;
SELECT on_hand FROM seat_lab WHERE id = 1 FOR UPDATE; -- залочили строку до COMMIT
-- ... логика в приложении ...
UPDATE seat_lab SET on_hand = on_hand - 1 WHERE id = 1;
COMMIT; -- блокировка снятаSELECT ... FOR UPDATE берёт ту же блокировку строки, что и UPDATE, но раньше — на чтении, и держит её до конца транзакции. Конкурент, читающий ту же строку с FOR UPDATE, заблокируется и будет ждать твоего COMMIT. Получив управление, он прочитает уже обновлённое значение. Цена — конкуренты выстраиваются в очередь на горячую строку.
SKIP LOCKED: очередь задач без очереди ожидания
Иногда выстраиваться в очередь — ровно то, чего не надо. Представь таблицу задач (job_queue) и пул воркеров: каждый хочет взять свободную задачу, а не ждать ту, что уже забрал сосед. Тут к FOR UPDATE добавляют SKIP LOCKED:
SELECT id, payload FROM job_queue
WHERE status = 'pending'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;SKIP LOCKED означает «не жди залоченные строки — пропусти их и возьми следующую свободную». Десять воркеров, исполняя один и тот же запрос, разберут десять разных задач, никто никого не ждёт и никто не обработает чужую. Это идиоматическая очередь задач на чистом Postgres (вернёмся к ней в 09-02).
Три починки в одной таблице — от самой частой к самой специальной:
| Починка | Как | Конкурент на той же строке | Когда брать |
|---|---|---|---|
Атомарный UPDATE (SET x = x - 1) | чтение и запись в одной команде, неявная блокировка строки | ждёт завершения команды, считает от свежего значения | по умолчанию: вычисление выразимо в SQL |
SELECT … FOR UPDATE | явная блокировка строки на чтении, держится до COMMIT | ждёт твой COMMIT, читает уже обновлённое | read-modify-write неизбежен: логика между чтением и записью |
FOR UPDATE SKIP LOCKED | пропускает залоченные строки, берёт следующую свободную | не ждёт — забирает другую строку | очередь задач: N воркеров делят работу без дублей |
Что показывает наш код
demo.sql (цель run) воспроизводит арифметику потерянного обновления на одной сессии: два «воркера» захватывают остаток 10 в psql-переменные до любой записи (имитация одновременности), оба пишут 9 — и мы видим потерю. Затем те же две продажи через атомарный UPDATE дают верный остаток 8, а FOR UPDATE показывает явную блокировку.
session-a.sql / session-b.sql — живая очередь на SKIP LOCKED в двух терминалах. A забирает задачу #1 и держит её (транзакция открыта); B исполняет тот же claim-запрос — и SKIP LOCKED уводит его от залоченной #1 к #2, без ожидания.
Запуск
Подними песочницу (из корня репозитория) и восстанови схему Brew:
docker compose up -d
make lecture L=05-transactions-and-mvcc/05-03-row-locks-and-lost-updates T=db-resetДетерминированное демо (потерянное обновление и починки):
make lecture L=05-transactions-and-mvcc/05-03-row-locks-and-lost-updates── Часть 1. Потерянное обновление: два воркера прочитали остаток ДО записи ──
Воркер 1 прочитал остаток = 10 ; воркер 2 прочитал остаток = 10
остаток после двух продаж
---------------------------
9
(1 row)
→ продали ДВА колд брю, а остаток упал лишь на единицу: один декремент потерян.
── Часть 2. Атомарный UPDATE: арифметику делает БД, потери нет ──
остаток после двух продаж
---------------------------
8
(1 row)
→ две продажи — остаток 8. Оба декремента на месте.
── Часть 3. FOR UPDATE: явная блокировка строки на время транзакции ──
id | name | on_hand
----+----------+---------
1 | Колд брю | 8
(1 row)
остаток после третьей продажи
-------------------------------
7
(1 row)Теперь живая очередь. В первом терминале:
make lecture L=05-transactions-and-mvcc/05-03-row-locks-and-lost-updates T=session-aA готовит очередь и забирает задачу #1, затем ждёт у подсказки:
A1) Забираем задачу claim-запросом FOR UPDATE SKIP LOCKED — строка залочена до COMMIT:
id | payload
----+------------------
1 | сварить заказ #1
(1 row)
A держит задачу #1. Теперь в другом терминале запусти `make session-b`. ...Во втором терминале, пока A держит #1:
make lecture L=05-transactions-and-mvcc/05-03-row-locks-and-lost-updates T=session-bB1) Задача #1 залочена сессией A → SKIP LOCKED её пропускает, берём следующую (#2):
id | payload
----+------------------
2 | сварить заказ #2
(1 row)B получил #2 сразу, не дожидаясь #1. Вернись в терминал A, нажми Enter — A завершит #1, и итог очереди покажет: задачи #1 и #2 сделаны разными воркерами, #3 ещё свободна.
A3) Итог очереди — B взял ДРУГУЮ задачу (#2), не дожидаясь #1. Двойной обработки нет:
id | payload | status
----+------------------+---------
1 | сварить заказ #1 | done
2 | сварить заказ #2 | done
3 | сварить заказ #3 | pendingПосле демо восстанови схему Brew: make ... T=db-reset (таблицу job_queue можно удалить вручную).
Заборчик
Порядок шагов в сценарии двух сессий держат \prompt — без них это была бы гонка, и кто кого «обогнал» зависело бы от планировщика. В реальной очереди воркеры именно соревнуются, и SKIP LOCKED для того и нужен. А вот что мы упростили:
- Блокировка строки живёт до конца транзакции. Держать транзакцию открытой, пока приложение ходит во внешний сервис, опасно: горячая строка заблокирована, конкуренты копятся в очереди, а долгая транзакция ещё и держит горизонт видимости (bloat — см. 05-02). Критическую секцию под
FOR UPDATEделают как можно короче. - Дедлок мы здесь не трогали. Две транзакции, блокирующие строки во встречном порядке, встают намертво — об этом 05-06.
FOR UPDATEблокирует строки, а не предотвращает все аномалии. Write-skew (когда транзакции читают и пишут разные строки) им не лечится — для него нуженSERIALIZABLE(05-04).
Что забрать с собой
- Потерянное обновление — два конкурентных read-modify-write, прочитавших одно значение: вторая запись затирает первую.
- Починка по умолчанию — атомарный
UPDATE(SET x = x - 1): чтение и запись в одной команде под неявной блокировкой строки. - Если read-modify-write неизбежен —
SELECT ... FOR UPDATE: явно блокирует строку на чтении доCOMMIT, конкурент ждёт. FOR UPDATE SKIP LOCKED— наоборот, пропускает залоченные строки: идиома очереди задач, N воркеров делят работу без ожидания и без двойной обработки.- Блокировка живёт до конца транзакции → держи критическую секцию короткой, не ходи под блокировкой во внешние сервисы.
Дальше — юнит 05-04 «уровни изоляции на практике»: FOR UPDATE блокирует строки явно, но у Postgres есть и второй рычаг против аномалий — уровень изоляции транзакции. Разберём READ COMMITTED → REPEATABLE READ → SERIALIZABLE и аномалию write-skew, которую ловит только последний.