Снимки данных, уровни изоляции, блокирование, взаимоблокировки и повторы
Транзакция — группа команд, которая фиксируется целиком через COMMIT или
отменяется целиком через ROLLBACK. Это свойство называют атомарностью:
внешний наблюдатель не должен увидеть половину перевода денег.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;Но атомарности недостаточно. Два клиента могут одновременно прочитать и изменить одни данные. Уровень изоляции определяет, какие результаты чужих транзакций видит текущая транзакция.
При UPDATE PostgreSQL обычно создаёт новую версию строки. Снимок данных
(snapshot) определяет, какие версии видимы конкретной команде или транзакции.
Так чтение часто не блокирует изменение, а изменение — чтение.
Служебные сведения можно увидеть так:
SELECT pg_current_xact_id(), pg_current_snapshot();У версий строк есть системные поля xmin, xmax и физический адрес ctid.
Они полезны для исследования внутреннего устройства, но не являются
прикладными идентификаторами: ctid меняется после обновления и переписывания.
READ COMMITTED — стандартный уровень PostgreSQL. Каждая SQL-команда получает
снимок зафиксированных данных на момент своего начала.
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1;
-- Здесь другая транзакция может изменить и зафиксировать баланс.
SELECT balance FROM accounts WHERE id = 1;
COMMIT;Два SELECT одной транзакции могут увидеть разные значения. Они также могут
увидеть разный набор строк, подходящих под одинаковый WHERE.
Название READ UNCOMMITTED PostgreSQL принимает, но обрабатывает так же, как
READ COMMITTED: незавершённые чужие изменения не становятся видимыми.
Если UPDATE встречает строку, которую меняет другая транзакция, он ждёт. После
фиксации конкурента PostgreSQL заново проверяет условие для актуальной версии.
Поэтому безопасное списание можно выразить одной командой:
UPDATE accounts
SET balance = balance - 100
WHERE id = $1
AND balance >= 100
RETURNING balance;Если денег уже не хватает, команда изменит ноль строк. Проверка и списание не разорваны промежутком, в который мог бы вмешаться другой клиент.
На уровне REPEATABLE READ снимок фиксируется первой обычной командой и
используется до конца транзакции. Повторный запрос не увидит новые фиксации
других транзакций.
PostgreSQL не допускает здесь грязного чтения, неповторяемого чтения и фантомов — новых строк, внезапно попавших под уже проверенное условие.
Но возможна аномалия разнесённой записи (write skew). Представим двух
дежурных врачей. Каждая транзакция видит второго врача на дежурстве и снимает
себя. Они меняют разные строки, поэтому прямого конфликта записи нет, но после
обеих фиксаций не остаётся ни одного дежурного.
Значит, один устойчивый снимок не защищает автоматически правило между несколькими строками.
SERIALIZABLE — самый строгий уровень. PostgreSQL отслеживает зависимости
чтения и записи. Если одновременный результат нельзя объяснить никаким
последовательным порядком транзакций, одна из них получает ошибку SQLSTATE
40001.
Это ожидаемая часть работы уровня, а не поломка сервера. Приложение должно откатить и повторить всю транзакцию с новым снимком:
предел попыток → BEGIN → чтения и изменения → COMMIT
↘ 40001 → ROLLBACK → пауза → новый BEGINПовторять только последнюю команду внутри старой транзакции нельзя. Пауза обычно увеличивается с каждой попыткой и получает небольшую случайную добавку, чтобы конкуренты снова не столкнулись одновременно.
Сетевой вызов внутри повторяемой транзакции опасен: ROLLBACK базы не отменит
уже отправленное письмо или платёж. Внешняя операция должна иметь ключ
идемпотентности либо выполняться после записи в транзакционную исходящую
очередь.
Если решение зависит от прочитанного состояния и не помещается в один
условный UPDATE, строку можно заблокировать:
BEGIN;
SELECT available, reserved
FROM inventory
WHERE product_id = $1
FOR UPDATE;
UPDATE inventory
SET reserved = reserved + $2
WHERE product_id = $1;
COMMIT;FOR UPDATE не даёт другой транзакции одновременно изменить выбранную строку.
Держите блокировку как можно меньше: не ждите внутри транзакции ответа сети или
действия пользователя.
Если нужно заблокировать несколько строк, всегда берите их в одном порядке,
например по возрастанию id. Более слабые режимы FOR NO KEY UPDATE,
FOR SHARE и FOR KEY SHARE конфликтуют с меньшим числом действий; выбирайте
минимальный режим, который защищает правило.
Блокирование (blocking) означает, что один процесс ждёт ресурс,
удерживаемый другим:
SELECT
blocked.pid AS blocked_pid,
blocked.wait_event_type,
blocked.wait_event,
pg_blocking_pids(blocked.pid) AS blockers,
blocked.query
FROM pg_stat_activity AS blocked
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;Проверьте возраст транзакции, состояние idle in transaction, команду и
владельца сеанса. Не завершайте процесс автоматически только из-за большого
времени: откат огромной транзакции тоже может быть дорогим.
Взаимоблокировка (deadlock) — цикл ожиданий:
T1 держит счёт 1 и ждёт счёт 2
T2 держит счёт 2 и ждёт счёт 1Обе транзакции не могут продолжить. PostgreSQL обнаруживает цикл и отменяет одну
с кодом 40P01. Основное исправление — брать объекты в одинаковом порядке.
Редкая гонка всё равно возможна, поэтому безопасный повтор всей транзакции
нужен и для 40P01.
Несколько обработчиков могут забирать разные задачи, пропуская уже занятые:
WITH next_job AS (
SELECT id
FROM jobs
WHERE status = 'pending'
ORDER BY priority DESC, id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE jobs AS j
SET status = 'running',
locked_by = $1,
locked_at = clock_timestamp()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;SKIP LOCKED намеренно показывает неполный набор: занятые строки не ждутся, а
пропускаются. Это правильно для очереди, но неправильно для полного отчёта.
Рабочая очередь также требует срока аренды задачи, повторов, идемпотентности, ограничения голодания и отдельного места для окончательно неудачных задач.
Рекомендательная блокировка (advisory lock) связана не со строкой, а с
числовым ключом, выбранным приложением:
SELECT pg_advisory_xact_lock(hashtextextended($1, 0));PostgreSQL не знает смысл ключа. Блокировку соблюдают только участники одного
соглашения. Транзакционный вариант освобождается при COMMIT или ROLLBACK.
Сеансовый вариант живёт до явного освобождения или разрыва соединения и плохо
сочетается с пулом транзакций.
Хеш может дать одинаковое число для разных бизнес-ключей. Это не нарушит данные, если результатом будет лишь лишняя блокировка, но увеличит задержку.
В лаборатории 18 вы реализуете получение задачи из очереди. Отдельная проверка конкурентности запустит два настоящих сеанса и докажет, что они получили разные задачи.
Подробнее: уровни изоляции, явные блокировки, MVCC.
Далее: Как PostgreSQL хранит строки и WAL