Средний ~55 мин чтения

Транзакции, MVCC и конкурентный доступ

Урок 7 из 10 в курсе PostgreSQL для backend-разработчика

Транзакции, MVCC и конкурентный доступ

Транзакции и MVCC — это ядро PostgreSQL для обеспечения корректности при одновременном доступе многих клиентов. MVCC (Multi-Version Concurrency Control) позволяет читателям никогда не блокировать писателей: каждая транзакция видит согласованный снимок данных на момент своего старта, а конкурентные изменения невидимы до COMMIT. Уровни изоляции (READ COMMITTED, REPEATABLE READ, SERIALIZABLE) определяют какие аномалии допустимы. SELECT FOR UPDATE и SKIP LOCKED дают инструменты для пессимистичного лока и построения очередей задач. Advisory locks — для распределённых мьютексов. Idle in transaction — скрытый убийца производительности. Понимание этих механизмов позволяет строить корректные concurrent системы без гонок, дедлоков и потери данных.

Почему это важно: Каждое production-приложение работает с конкурентным доступом: десятки/сотни пользователей одновременно читают и пишут данные. Без правильного понимания транзакций и MVCC возникают тонкие баги: двойное списание баланса при параллельных платежах, дублированные задачи в очереди, lost updates при одновременном редактировании, phantom reads в отчётах. Эти баги проявляются только при нагрузке, сложно воспроизводятся локально и могут приводить к потере денег или данных. PostgreSQL предоставляет все нужные инструменты, но разработчик должен знать как правильно их применять: какой уровень изоляции выбрать, когда нужен FOR UPDATE, как построить очередь задач без дедлоков.

Главная идея

MVCC работает через версионирование строк: каждая строка в PostgreSQL хранит xmin (transaction ID создания) и xmax (transaction ID удаления или обновления). SELECT видит строки где xmin <= текущий snapshot и xmax = 0 (не удалена) или xmax > snapshot (удалена позже). UPDATE не изменяет строку на месте — он помечает старую версию как удалённую (xmax) и создаёт новую версию. VACUUM убирает версии строк, невидимые ни одной транзакции. Уровни изоляции определяют какой snapshot видит транзакция. READ COMMITTED (дефолт): каждый SELECT получает свежий snapshot на момент своего запуска — можно увидеть изменения других транзакций между двумя SELECT. REPEATABLE READ: один snapshot на всю транзакцию — два SELECT к одной таблице видят одни данные. SERIALIZABLE: транзакции как будто выполняются последовательно (через predicate locking), но при конфликте одна откатывается с ошибкой serialization failure.

Как это выглядит на практике

  1. Система онлайн-платежей: пользователь нажимает 'оплатить' два раза быстро — два запроса приходят параллельно. Оба читают баланс (1000 руб), оба проверяют достаточность для оплаты 800 руб, оба списывают. Итого: баланс -600 руб. Классическая race condition без транзакций.
  2. Решение: BEGIN; SELECT balance FOR UPDATE; проверка; UPDATE; COMMIT. SELECT FOR UPDATE блокирует строку — второй параллельный запрос ждёт пока первый завершится. Баланс корректен.
  3. Появляется очередь фоновых задач: email-рассылка, генерация отчётов. 3 воркера пытаются брать задачи. Без SKIP LOCKED: все три lock'ают одну строку, два ждут, потом все три обрабатывают одно и то же — дублирование.
  4. Добавляешь FOR UPDATE SKIP LOCKED: каждый воркер атомарно берёт следующую незаблокированную задачу. Три воркера параллельно обрабатывают три разные задачи без конфликтов.
  5. Инцидент: мониторинг показывает что запросы к таблице orders стали медленными. SELECT из pg_stat_activity показывает процесс с wait_event_type = 'Lock' — кто-то держит блокировку. Запускаешь диагностический запрос — видишь что транзакция 'idle in transaction' держит lock уже 5 минут.
  6. Добавляешь в postgresql.conf: idle_in_transaction_session_timeout = '30s'. Теперь транзакции без активности автоматически завершаются через 30 секунд.
  7. Новое требование: SERIALIZABLE транзакции для финансового отчёта — нужна абсолютная консистентность. Добавляешь BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE. В нагрузочном тесте замечаешь ошибки serialization_failure. Добавляешь retry-логику в приложении.
  8. Задача: одновременно два процесса могут обновлять кеш статистики — нужен мьютекс без таблицы. Используешь pg_try_advisory_lock с числовым ключом. Первый процесс получает lock, второй получает false и пропускает работу.
  9. Deadlock: транзакция A блокирует строку пользователя 1 затем пользователя 2, транзакция B — наоборот. PostgreSQL обнаруживает и откатывает одну. Решение: всегда блокировать строки в одном порядке (ORDER BY id).
  10. Мониторинг: запрос в pg_stat_user_tables показывает огромный n_dead_tup в таблице orders. Оказывается, долгая аналитическая транзакция (REPEATABLE READ) держит старый snapshot — VACUUM не может убирать мёртвые туплы. Ограничиваешь время транзакций через statement_timeout.
  11. Table bloat: таблица events занимает 50GB, из них 30GB — мёртвые туплы. VACUUM не справляется. Ищешь долгие транзакции в pg_stat_activity, находишь ETL-процесс с open транзакцией на 2 часа. Исправляешь ETL — добавляешь явные COMMIT между батчами.
  12. Проектируешь систему с оптимистичным локом: версионирование через столбец version INT. При UPDATE: WHERE id = $1 AND version = $old_version; проверяешь rows_affected = 1. Если 0 — конфликт, retry. Это альтернатива FOR UPDATE для low-contention сценариев.

Примеры кода

Уровни изоляции и аномалии

-- Демонстрация non-repeatable read при READ COMMITTED
-- Транзакция A (READ COMMITTED — дефолт):
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- Результат: 1000

-- В это время Транзакция B:
-- UPDATE accounts SET balance = 500 WHERE id = 1; COMMIT;

SELECT balance FROM accounts WHERE id = 1;
-- Результат: 500 (другое значение!) — non-repeatable read
COMMIT;

-- Решение: REPEATABLE READ — один snapshot на всю транзакцию
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
-- Результат: 1000

-- Транзакция B изменяет и коммитит...

SELECT balance FROM accounts WHERE id = 1;
-- Результат: 1000 (тот же snapshot) — non-repeatable read не возможен
COMMIT;

-- Безопасный перевод денег с оптимистичной логикой
-- Паттерн: SELECT FOR UPDATE чтобы заблокировать строки для изменения
BEGIN;
SELECT balance FROM accounts WHERE id IN (1, 2)
ORDER BY id  -- ВАЖНО: всегда один порядок блокировки, иначе deadlock!
FOR UPDATE;

-- Проверяем достаточность средств
-- Выполняем перевод
UPDATE accounts SET balance = balance - 500 WHERE id = 1;
UPDATE accounts SET balance = balance + 500 WHERE id = 2;
COMMIT;

READ COMMITTED — дефолтный уровень PostgreSQL. Non-repeatable read возможен: если между двумя SELECT другая транзакция коммитит изменение, второй SELECT видит новые данные. REPEATABLE READ решает это — весь snapshot фиксируется в начале транзакции. FOR UPDATE блокирует строки: другая транзакция не может их изменить до COMMIT. ВАЖНО: всегда блокируй строки в одном и том же порядке (ORDER BY id) чтобы избежать deadlock.

FOR UPDATE SKIP LOCKED: очередь задач без дедлоков

-- Таблица очереди задач
CREATE TABLE jobs (
  id          BIGSERIAL PRIMARY KEY,
  type        TEXT NOT NULL,
  payload     JSONB,
  status      TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'done', 'failed')),
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  started_at  TIMESTAMPTZ,
  attempts    INT DEFAULT 0
);

-- Воркер атомарно берёт задачу (atomic dequeue)
BEGIN;

WITH next_job AS (
  SELECT id
  FROM jobs
  WHERE status = 'pending'
    AND attempts < 3
  ORDER BY created_at ASC
  LIMIT 1
  FOR UPDATE SKIP LOCKED  -- пропускаем уже заблокированные строки
)
UPDATE jobs
SET
  status     = 'processing',
  started_at = NOW(),
  attempts   = attempts + 1
WHERE id = (SELECT id FROM next_job)
RETURNING id, type, payload;

-- Если возвращено NULL — нет доступных задач
-- Обрабатываем задачу...
-- При успехе:
UPDATE jobs SET status = 'done' WHERE id = $job_id;
COMMIT;

-- При ошибке:
UPDATE jobs SET status = 'failed' WHERE id = $job_id;
COMMIT;

-- Мониторинг очереди
SELECT
  status,
  COUNT(*),
  MIN(created_at) AS oldest,
  AVG(EXTRACT(EPOCH FROM (NOW() - created_at))) AS avg_age_sec
FROM jobs
GROUP BY status;

SKIP LOCKED — ключевой инструмент для очередей. Без него: два воркера пытаются взять одну задачу → один ждёт другого → дедлок или дублирование. С SKIP LOCKED: каждый воркер атомарно пропускает заблокированные строки и берёт следующую свободную. Несколько воркеров работают параллельно без конфликтов. Всё в одной транзакции — атомарность гарантирована.

Advisory locks и диагностика блокировок

-- Advisory lock: распределённый мьютекс без таблицы
-- Полезно для 'только один процесс должен делать X'
SELECT pg_try_advisory_lock(12345);
-- Возвращает TRUE если блокировка получена, FALSE если уже занята

-- Паттерн: try-or-skip для cron-задач
DO $$
BEGIN
  IF pg_try_advisory_lock(42) THEN
    -- Выполняем работу: обновление статистики, отправка email и т.д.
    PERFORM expensive_operation();
    PERFORM pg_advisory_unlock(42);
  ELSE
    RAISE NOTICE 'Another process is running, skipping';
  END IF;
END $$;

-- Транзакционный advisory lock: освобождается автоматически при COMMIT/ROLLBACK
SELECT pg_try_advisory_xact_lock(99);

-- Диагностика: кто кого блокирует?
SELECT
  blocked.pid                                AS blocked_pid,
  blocked.query                              AS blocked_query,
  blocking.pid                               AS blocking_pid,
  blocking.query                             AS blocking_query,
  now() - blocked.query_start               AS blocked_duration,
  blocked.wait_event_type,
  blocked.wait_event
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(
    SELECT pid FROM pg_locks l
    WHERE NOT l.granted
      AND l.pid = blocked.pid
  )
WHERE NOT blocked.granted
  AND blocked.pid != pg_backend_pid();

-- Найти idle in transaction (опасные долгоживущие транзакции)
SELECT
  pid,
  usename,
  now() - xact_start AS transaction_age,
  state,
  left(query, 80) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND xact_start < NOW() - INTERVAL '30 seconds'
ORDER BY transaction_age DESC;

Advisory locks — лёгкие пользовательские блокировки по числовому ключу. Не требуют таблицы, освобождаются при завершении сессии. pg_try_advisory_lock vs pg_advisory_lock: try возвращает false немедленно, без try блокирует до освобождения. Idle in transaction — транзакция открыта, ничего не делает, держит snapshot и блокирует VACUUM. idle_in_transaction_session_timeout в postgresql.conf автоматически завершает такие транзакции.

Что происходит под капотом

  • MVCC внутри: каждая строка (tuple) в heap файле содержит t_xmin (xid транзакции-создателя) и t_xmax (xid транзакции, пометившей строку как удалённую). UPDATE создаёт новую версию строки с новым t_xmin и помечает старую через t_xmax. Visibility check при каждом SELECT: строка видима если t_xmin <= snapshot_xmin AND (t_xmax = 0 OR t_xmax > snapshot_xmin).
  • Table bloat: UPDATE/DELETE не удаляют данные физически — они создают мёртвые версии строк (dead tuples). VACUUM проходит по таблице и помечает пространство dead tuples как доступное для повторного использования (не возвращает в OS). VACUUM FULL дефрагментирует таблицу но блокирует её полностью.
  • Dead tuples не убираются пока есть хотя бы одна транзакция, чей snapshot старше t_xmax мёртвых строк. Поэтому долгая транзакция (idle in transaction) блокирует VACUUM для всей базы данных, не только для таблиц, которые она использует.
  • Row-level locking: FOR UPDATE добавляет запись в pg_locks. При конфликте второй запрос ждёт. FOR NO KEY UPDATE — слабее FOR UPDATE, не блокирует FOR KEY SHARE (полезно при FK constraints). FOR SHARE — блокирует изменения но позволяет другим FOR SHARE.
  • Deadlock detection: PostgreSQL периодически (каждые deadlock_timeout мс, дефолт 1s) проверяет граф ожидания блокировок. При обнаружении цикла откатывает транзакцию с ERROR: deadlock detected. Приложение должно обрабатывать эту ошибку и retry.
  • SERIALIZABLE использует SSI (Serializable Snapshot Isolation): отслеживает зависимости чтение-запись между транзакциями через predicate locks. При обнаружении потенциального конфликта откатывает одну транзакцию с ERROR: could not serialize access due to concurrent update. Нужна retry-логика.
  • Advisory locks хранятся в общей памяти PostgreSQL (shared memory). Сессионные advisory locks освобождаются при завершении сессии или явном pg_advisory_unlock. Транзакционные (pg_advisory_xact_lock) — при COMMIT/ROLLBACK. Ключ — int8 или два int4 — можно кодировать тип операции в старших битах.
  • XID wraparound — критическая проблема: transaction ID (32-bit) может переполниться через ~2 миллиарда транзакций. PostgreSQL запускает aggressive autovacuum при приближении к порогу. Мониторь: SELECT age(datfrozenxid) FROM pg_database — опасно если > 1.5 миллиарда.

Типичные ошибки и заблуждения

  • Ошибка: SELECT FOR UPDATE блокирует чтение таблицы. Нет — MVCC позволяет другим транзакциям читать те же строки через SELECT без FOR UPDATE в любое время. Заблокированы только изменения этих строк другими транзакциями с FOR UPDATE/UPDATE/DELETE.
  • Ошибка: SERIALIZABLE всегда правильный выбор для safety. SERIALIZABLE может откатывать транзакции с serialization_failure при любом конфликте предикатов. Нужна retry-логика в каждом приложении. Для большинства задач достаточно READ COMMITTED + SELECT FOR UPDATE для критических операций.
  • Ошибка: deadlock — признак плохого кода, которого можно избежать. Deadlock возможен при любом параллельном обновлении нескольких строк. PostgreSQL обнаруживает и разрешает его автоматически. Важно: обрабатывать ошибку deadlock detected в приложении через retry.
  • Ошибка: длинная транзакция только удерживает блокировки нужных строк. Длинная транзакция удерживает snapshot xmin — VACUUM не может убирать мёртвые туплы для всей базы данных. Одна транзакция на 2 часа может вызвать table bloat во всех активных таблицах.
  • Ошибка: idle in transaction = нормальное состояние при паузах в коде. idle in transaction = открытая транзакция без активности. Она удерживает snapshot, блокирует VACUUM и может держать row locks. Обязателен idle_in_transaction_session_timeout в postgresql.conf.
  • Ошибка: MVCC устраняет все проблемы конкурентности. MVCC устраняет необходимость блокировать читателей. Но lost update (два UPDATE на основе одного прочитанного значения), write skew (два concurrent SELECT + UPDATE на основе условия), phantom reads при READ COMMITTED — всё это возможно и требует правильного уровня изоляции или FOR UPDATE.

Ключевые выводы

  • MVCC: читатели никогда не блокируют писателей — каждый видит свой снимок данных.
  • SELECT FOR UPDATE блокирует строки для изменения; ORDER BY id в FOR UPDATE предотвращает deadlock.
  • FOR UPDATE SKIP LOCKED — стандарт для очередей задач: несколько воркеров без конфликтов.
  • Idle in transaction = открытая транзакция = bloat угроза; устанавливай idle_in_transaction_session_timeout.
  • SERIALIZABLE требует retry-логики в приложении для serialization_failure ошибок.

Термины урока

MVCC (Multi-Version Concurrency Control): механизм изоляции через хранение нескольких версий строк.
xmin/xmax: system columns в каждой строке — transaction ID создания и 'удаления' версии строки.
Dead tuples: старые версии строк после UPDATE/DELETE, не видимые ни одной транзакцией.
FOR UPDATE: строчная блокировка для изменения; другие FOR UPDATE ждут до COMMIT.
SKIP LOCKED: пропуск заблокированных строк при SELECT FOR UPDATE — основа очередей задач.
Deadlock: взаимное ожидание блокировок; PostgreSQL откатывает одну транзакцию автоматически.
Advisory lock: явная пользовательская блокировка по числовому ключу; без таблицы.
Serialization failure: ошибка при SERIALIZABLE уровне — транзакция должна быть повторена.

Связь с работой backend-разработчика

Три главных паттерна конкурентного доступа: (1) SELECT FOR UPDATE для критических операций типа 'прочитай и измени' (баланс, статус) — ORDER BY id чтобы избежать deadlock; (2) FOR UPDATE SKIP LOCKED для очередей задач — несколько воркеров без конфликтов; (3) pg_try_advisory_lock для распределённых мьютексов — один процесс обрабатывает задачу, остальные пропускают. Мониторь idle in transaction через pg_stat_activity и устанавливай idle_in_transaction_session_timeout.

Мини-разбор реальной ситуации

Email-рассылка: таблица email_jobs, 5 воркеров. Первая реализация без правильного лока: SELECT WHERE status = 'pending' LIMIT 1, затем UPDATE status = 'processing'. При высокой нагрузке одно письмо отправляется 3-4 раза — воркеры берут одну строку до UPDATE. Вторая попытка: BEGIN; SELECT FOR UPDATE; UPDATE; — дедлоки при попытке взять следующую задачу. Финальное решение: WITH next AS (SELECT id FROM email_jobs WHERE status = 'pending' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED) UPDATE email_jobs SET status = 'processing' WHERE id = (SELECT id FROM next) RETURNING id. Нулевые дедлоки, нулевые дубликаты, пять воркеров параллельно обрабатывают пять разных задач.

Что запомнить

  • FOR UPDATE + ORDER BY id — всегда один порядок блокировки, иначе deadlock.
  • SKIP LOCKED = очередь задач без дедлоков и дублирования.
  • Idle in transaction держит xmin snapshot → bloat для ВСЕЙ базы, не только нужных таблиц.

Итог

MVCC — это философия PostgreSQL: оптимизм вместо пессимизма, версии вместо блокировок. Читатели никогда не ждут писателей. Понимание xmin/xmax, уровней изоляции и блокировок превращает разработчика, который 'просто пишет запросы', в инженера, который строит корректные concurrent системы — без гонок, без deadlock, без потери данных.