PostgreSQL CookbookТранзакцииБлокировки строк и потерянные обновления
0 / 63 (0%)

Блокировки строк и потерянные обновления

В каждой кофейне 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:

sql
UPDATE seat_lab SET on_hand = on_hand - 1 WHERE id = 1;

Теперь чтение и запись — это одна команда. Postgres на время её выполнения берёт блокировку строки (неявно, сам), и конкурентный UPDATE той же строки ждёт её завершения, а потом считает уже от свежего значения. Устаревшего чтения не существует — терять нечего. Девяносто процентов «потерянных обновлений» в приложениях лечатся переносом вычисления из кода в один атомарный UPDATE.

Починка 2: FOR UPDATE, когда read-modify-write неизбежен

Иногда между чтением и записью действительно нужна логика приложения: проверить лимит, обратиться к платёжному шлюзу, принять решение. Тогда строку блокируют явно — при чтении:

sql
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:

sql
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:

sh
docker compose up -d
make lecture L=05-transactions-and-mvcc/05-03-row-locks-and-lost-updates T=db-reset

Детерминированное демо (потерянное обновление и починки):

sh
make lecture L=05-transactions-and-mvcc/05-03-row-locks-and-lost-updates
plaintext
── Часть 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)

Теперь живая очередь. В первом терминале:

sh
make lecture L=05-transactions-and-mvcc/05-03-row-locks-and-lost-updates T=session-a

A готовит очередь и забирает задачу #1, затем ждёт у подсказки:

plaintext
A1) Забираем задачу claim-запросом FOR UPDATE SKIP LOCKED — строка залочена до COMMIT:
 id |     payload      
----+------------------
  1 | сварить заказ #1
(1 row)
 
A держит задачу #1. Теперь в другом терминале запусти `make session-b`. ...

Во втором терминале, пока A держит #1:

sh
make lecture L=05-transactions-and-mvcc/05-03-row-locks-and-lost-updates T=session-b
plaintext
B1) Задача #1 залочена сессией A → SKIP LOCKED её пропускает, берём следующую (#2):
 id |     payload      
----+------------------
  2 | сварить заказ #2
(1 row)

B получил #2 сразу, не дожидаясь #1. Вернись в терминал A, нажми Enter — A завершит #1, и итог очереди покажет: задачи #1 и #2 сделаны разными воркерами, #3 ещё свободна.

plaintext
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 COMMITTEDREPEATABLE READSERIALIZABLE и аномалию write-skew, которую ловит только последний.

·Модуль 06

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Транзакции / Блокировки строк и потерянные обновления