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