Конкурентная запись в PostgreSQL
Главное. Не делай «прочитал в коде — проверил — записал»: на read committed это теряет чужие правки. Выбирай средство по виду инварианта:
| Инвариант | Средство |
|---|---|
| Изменить строку, если условие ещё верно | один UPDATE ... WHERE <условие> + проверка числа строк |
| Прочитать, решить в коде, записать ту же строку | SELECT ... FOR NO KEY UPDATE и запись в одной транзакции |
| Нет дубля | уникальный индекс + INSERT ... ON CONFLICT |
| Интервалы не пересекаются | EXCLUDE USING gist (btree_gist) |
| Правило по набору строк, которое не выразить ограничением | serializable у всех участников + повтор |
| Очередь задач | FOR UPDATE SKIP LOCKED |
| Один обработчик на всех | pg_advisory_xact_lock |
Ограничение в базе надёжнее кода: оно держит инвариант при любом уровне изоляции и любом
клиенте. Go-код транзакций и повтора — скилл pgx-txmanager. Проверка утверждений —
CHECKS.md.
Уровни изоляции
- Read committed (по умолчанию): каждая команда видит свежий снимок.
UPDATEна занятой строке ждёт чужую транзакцию, потом перечитывает строку и заново проверяетWHERE. - Repeatable read и serializable не перечитывают: при конфликте — ошибка
40001(«could not serialize access»), serializable даёт её чаще. Поднял уровень — сразу пиши повтор всей транзакции, иначе пользователь получит ошибку.40001может прийти и наCOMMIT. - Снимок repeatable read строит первая команда после
BEGIN, а не самBEGIN. Важен момент — первым запросом делай чтение. - Serializable гарантирует результат только среди serializable-транзакций. Охраняешь им инвариант — на serializable идут все транзакции, которые его трогают.
- Внутри serializable смотри EXPLAIN. Seq Scan берёт предикатную блокировку на всю
таблицу и поднимает долю
40001; чтение по индексу блокирует только прочитанные строки и страницы индекса. Заведи нужные индексы. На маленькой таблице Seq Scan нормален.
Изменить, если условие верно: условие в UPDATE
Передавай в базу действие, а не готовое число: SET sent = sent + 1,
SET left = left - $1 WHERE left >= $1, WHERE id = $1 AND status = 'free'. Ноль
затронутых строк (RowsAffected() == 0 или pgx.ErrNoRows у RETURNING) — условие не
выполнено или строки нет. Это доменная ошибка, а не успех.
tag, err := q.Exec(ctx,
`UPDATE seats SET status = 'booked', customer_id = $2
WHERE id = $1 AND status = 'free'`,
seatID, customerID,
)
if err != nil {
return fmt.Errorf("book seat %d: %w", seatID, err)
}
if tag.RowsAffected() == 0 { // место уже занято или его нет
return fmt.Errorf("book seat %d: %w", seatID, ErrConflict)
}
На repeatable read и serializable тот же конфликт кончится 40001, и нужен повтор.
Прочитать и записать ту же строку
SELECT ... FOR NO KEY UPDATE (режим — в разделе «Режим блокировки») и запись — в одной
транзакции (trManager.Do). В автокоммите блокировка снимется сразу после SELECT. Строку,
которой ещё нет, блокировка не защитит: вставку двойника остановит только уникальный индекс
или ограничение.
Решение по набору строк (write skew)
«Исполнитель свободен», «бронь влезает в интервал», «в смене остаётся дежурный»: две транзакции читают один снимок, каждая пишет свою строку, и обе коммитятся. На read committed так получаются две пересекающиеся брони. Repeatable read не спасает: он ловит только запись в одну и ту же строку.
- Решение о существующих строках — заблокируй (
FOR NO KEY UPDATE/FOR UPDATE) ровно те строки, по которым решаешь. - Решение об отсутствии строки («пересекающейся брони нет») — блокировать нечего. Нужно
ограничение:
-
дубль по значению — уникальный индекс;
-
пересечение интервалов — exclusion-ограничение. На read committed вторая транзакция дождётся первой и получит
23P01, без serializable и без повторов:CREATE EXTENSION IF NOT EXISTS btree_gist; -- trusted: хватает права CREATE на базу ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap EXCLUDE USING gist (resource_id WITH =, during WITH &&); -- during tstzrange23P01(exclusion_violation) переводи в доменную ошибку конфликта.
-
- Правило не выражается ограничением — serializable для всех участников и повтор при
40001.
Режим блокировки строки
Блокируешь строку, чтобы прочитать и обновить, и не меняешь колонки с уникальным индексом,
пригодным для внешнего ключа (PK, уникальные ключи; частичные индексы и индексы по
выражению не считаются), — бери FOR NO KEY UPDATE. FOR UPDATE оставь для DELETE и смены
таких колонок. Почему: проверка внешнего ключа при вставке дочерней строки берёт на
родителе FOR KEY SHARE. Он конфликтует с FOR UPDATE и не конфликтует с
FOR NO KEY UPDATE. Лишний FOR UPDATE на родительской строке (клиент, ресурс, заказ)
останавливает вставку строк, которые на неё ссылаются.
Очередь задач
Несколько воркеров забирают задачу одним оператором:
UPDATE tasks
SET status = 'running', taken_at = now()
WHERE id = (
SELECT id FROM tasks
WHERE status = 'ready'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id
SKIP LOCKEDпропускает занятые строки, и воркеры не ждут друг друга. Пара «SELECT ... FOR UPDATE SKIP LOCKED, потомUPDATE» в одной транзакции тоже корректна. SELECT без блокировки и отдельный UPDATE — нельзя: два воркера возьмут одну задачу.ORDER BYв подзапросе — для предсказуемого порядка.- Воркер может упасть с задачей на руках. Практика: срок аренды по
taken_at— задачу вrunningдольше срока снова считают готовой. SKIP LOCKEDдаёт несогласованный вид данных — только для очередей.
Не висеть на чужой блокировке
По умолчанию запрос ждёт блокировку сколько угодно, и пользователь видит зависшее приложение.
SET LOCAL lock_timeoutвнутри транзакции, а не параметр сессии: соединение из пула потом уйдёт к другим запросам. Таймаут действует на каждое ожидание блокировки, а не на всю транзакцию.- У UPDATE и DELETE нет
NOWAIT. Нужна мгновенная ошибка — сначалаSELECT ... FOR UPDATE NOWAITпо этим строкам в той же транзакции. NOWAITиSKIP LOCKEDкасаются только строк. Табличную блокировку запрос ждёт как обычно, например заALTER TABLE. Поэтомуlock_timeoutнужен и с ними.- Ошибка в этих случаях —
55P03(lock_not_available). Повторяй всю транзакцию.
return s.trManager.Do(ctx, func(ctx context.Context) error {
q := s.getter.DefaultTrOrDB(ctx, s.pool)
// константа без ввода; значение подбери под операцию
if _, err := q.Exec(ctx, `SET LOCAL lock_timeout = '1s'`); err != nil {
return err
}
return s.repo.MoveBooking(ctx, from, to)
})
Очередь ожидания несправедливая. Новые FOR SHARE / FOR KEY SHARE могут обгонять
ждущий UPDATE на горячей строке, и поток таких блокировок держит его сколько угодно;
порядок среди ждущих не гарантирован. Для горячих строк (общий счётчик, остаток ходового
товара) ставь lock_timeout и повторяй транзакцию.
Разбор зависания. Блокировки строк в pg_locks не видны: ждущий процесс виден там как
ожидающий transactionid держателя. Кто блокирует, покажет pg_blocking_pids(pid) (в цикле
часто не вызывай). Блокировки строк таблицы показывает расширение pgrowlocks из contrib
(ставит суперпользователь).
Advisory-блокировки
«Только один обработчик» (одиночный воркер outbox, мигратор) — pg_advisory_xact_lock
внутри транзакции: замок снимется сам на commit или rollback.
const advisoryOutboxRelay int64 = 1 // фиксированный ключ подсистемы
err := s.trManager.Do(ctx, func(ctx context.Context) error {
q := s.getter.DefaultTrOrDB(ctx, s.pool)
if _, err := q.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, advisoryOutboxRelay); err != nil {
return err
}
return s.relayBatch(ctx) // одна пачка; замок уйдёт вместе с транзакцией
})
- Сеансовый
pg_advisory_lock— только на выделенном соединении изpool.Acquire; снимай на том же соединении столько раз, сколько захватывал. Сеансовый замок не снимается ни COMMIT, ни откатом, а тот же сеанс может взять его повторно. В пуле соединение вернётся с замком, unlock уйдёт на другое соединение, и замок «утечёт». - Не держи транзакцию открытой ради замка у бесконечного воркера: бери xact-замок на каждую пачку работы. Долгоживущему лидеру — сеансовый замок на выделенном соединении.
- Ключ — фиксированная числовая константа на подсистему или пара
(int, int).hashtextне бери: функция недокументированная, 32-битный хеш даёт коллизии.
Функции в запросах
Функцию на plpgsql или SQL, которая читает таблицы и вызывается в запросе рядом с чтением
таблиц, помечай STABLE. VOLATILE берёт новый снимок на каждый запрос внутри себя, и на
read committed данные таблицы и функции окажутся из разных моментов. IMMUTABLE — только
функциям, которые таблиц не читают; VOLATILE — функциям с побочными эффектами.
Коды ошибок
| Код | Что | Что делать |
|---|---|---|
40001 serialization_failure | конфликт на repeatable read / serializable | повторить всю транзакцию |
40P01 deadlock_detected | взаимная блокировка | повторить всю транзакцию; брать блокировки в одном порядке |
55P03 lock_not_available | NOWAIT, lock_timeout | повторить всю транзакцию или отдать «занято» |
23505 unique_violation | дубль | доменный конфликт; штатный дубль — ON CONFLICT |
23P01 exclusion_violation | пересечение по exclusion-ограничению | доменный конфликт |