Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
🧱
Данные
Опубликовано:
19.06.2026
Обновлено:
09.07.2026

PostgreSQL: транзакции, блокировки и миграции без аварий на проде

Данил Мануйлов

Три темы убивают прод чаще всего: разработчик не понимает, что происходит внутри транзакции, не знает, почему база «встаёт», и делает миграции наугад. Эта статья закрывает все три пробела - с кодом, без воды.

Транзакции: что на самом деле происходит

Транзакция - это не просто BEGIN ... COMMIT. Это контракт: база гарантирует, что либо все изменения применятся, либо не применится ни одно.

Этот контракт описывает ACID:

  • Atomicity (атомарность) - всё или ничего
  • Consistency (согласованность) - база остаётся корректной до и после
  • Isolation (изоляция) - параллельные транзакции не мешают друг другу
  • Durability (надёжность) - после COMMIT данные сохраняются даже при падении

Самый частый вопрос новичка: «Зачем явный BEGIN? ORM же сам транзакции делает?» - Да, но неявные транзакции не дают контроля. Если ORM открывает транзакцию на каждый запрос и вы делаете 10 запросов в одной HTTP-ручке, это 10 отдельных транзакций. При ошибке на 7-м шаге первые 6 уже зафиксированы.

BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
COMMIT;
-- При ошибке - ROLLBACK автоматически

Как MVCC убирает блокировки

PostgreSQL реализует MVCC - Multi-Version Concurrency Control, многоверсионное управление конкурентностью. Механизм простой: вместо блокировки строки при чтении база хранит несколько версий каждой строки.

Когда транзакция А обновляет строку, PostgreSQL создаёт новую версию с новыми значениями. Транзакция Б, запущенная параллельно, видит старую версию - ту, что существовала до старта Б. Читатели не ждут писателей, писатели не ждут читателей.

Каждая строка хранит системные поля xmin и xmax - идентификаторы транзакций, которые создали и удалили эту версию строки. По ним PostgreSQL определяет, какую версию показывать конкретной транзакции. Старые версии убирает фоновый процесс VACUUM.

Авторская ремарка. Когда я впервые увидел SELECT ... FOR UPDATE в prod-коде без таймаута - транзакция висела 40 секунд и держала блокировку на 12 строках. Весь сервис деградировал. Одна строчка SET lock_timeout = '3s' решила проблему. Об этом - в разделе про блокировки.

SAVEPOINT: откат внутри транзакции

SAVEPOINT - точка сохранения внутри транзакции. Можно откатиться к ней, не прерывая всю транзакцию.

BEGIN;
INSERT INTO orders (user_id, total) VALUES (1, 500);
SAVEPOINT before_notification;

-- Рискованная операция
INSERT INTO notifications (order_id) VALUES (currval('orders_id_seq'));

-- Что-то пошло не так
ROLLBACK TO SAVEPOINT before_notification;

-- Заказ создан, уведомление - нет
COMMIT;

Это полезно при сложной бизнес-логике, где часть операций - некритична.

Подробнее о SAVEPOINT в официальной документации.

Уровни изоляции: выбираем правильный

PostgreSQL поддерживает четыре уровня изоляции транзакций, определённых стандартом SQL. На практике используют три - READ UNCOMMITTED ведёт себя как READ COMMITTED.

READ COMMITTED - по умолчанию

READ COMMITTED - дефолтный уровень PostgreSQL. Каждый запрос внутри транзакции видит только уже зафиксированные данные. Защищает от грязного чтения (dirty read), но не от неповторяемого (non-repeatable read).

Пример проблемы: транзакция читает баланс - 1000 ₽. Параллельная транзакция списывает 500 ₽ и делает COMMIT. Первая снова читает баланс - уже 500 ₽. Это и есть неповторяемое чтение.

Для большинства CRUD-операций READ COMMITTED - правильный выбор. Он даёт хорошую производительность и достаточную защиту.

REPEATABLE READ и SERIALIZABLE

REPEATABLE READ фиксирует снимок данных на момент старта транзакции. Все последующие чтения внутри транзакции видят одно и то же состояние. Защищает от dirty read и non-repeatable read.

SERIALIZABLE - самый строгий уровень. PostgreSQL реализует его через Serializable Snapshot Isolation (SSI), а не через блокировки. Это означает, что транзакции выполняются так, будто идут последовательно. При конфликте PostgreSQL прерывает одну транзакцию с ошибкой ERROR 40001 (serialization failure) - её нужно повторить.

-- Установить уровень изоляции для текущей транзакции
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
-- Никакая другая транзакция не изменит этот баланс до COMMIT
COMMIT;

Таблица аномалий по уровням

Уровень изоляции Dirty read Non-repeatable read Phantom read
READ COMMITTED ✅ защита ❌ возможна ❌ возможен
REPEATABLE READ ✅ защита ✅ защита ✅ защита*
SERIALIZABLE ✅ защита ✅ защита ✅ защита

*В PostgreSQL REPEATABLE READ через MVCC также предотвращает фантомное чтение - это отличие от стандарта SQL, где защита от phantom гарантирована только на SERIALIZABLE.

Подробнее о таблице аномалий в официальной документации postgrespro.ru.

Блокировки: диагностика и устранение

Блокировки - не зло. Зло - это блокировки без таймаута и без мониторинга.

Типы блокировок: строка vs таблица

Строковые блокировки захватываются командами SELECT ... FOR UPDATE и SELECT ... FOR SHARE. Они блокируют только конкретные строки.

-- Читаем и сразу блокируем строку для обновления
SELECT id, balance FROM accounts WHERE id = 1 FOR UPDATE;
-- Другая транзакция будет ждать, пока мы не сделаем COMMIT/ROLLBACK

Табличные блокировки возникают при DDL-командах. AccessExclusiveLock - самый тяжёлый: блокирует любой доступ к таблице. Именно его создаёт ALTER TABLE, обычный CREATE INDEX, DROP TABLE.

Advisory locks - рекомендательные блокировки на уровне приложения. Полезны для распределённых очередей и заданий.

-- Захватить advisory lock с ключом 42
SELECT pg_advisory_lock(42);
-- ... работа ...
SELECT pg_advisory_unlock(42);

Как возникает deadlock и как его избежать

Deadlock (взаимоблокировка) возникает, когда транзакция А ждёт ресурс, который держит транзакция Б, а Б ждёт ресурс, который держит А. PostgreSQL обнаруживает тупик автоматически и откатывает одну транзакцию.

Классический сценарий:

-- Транзакция А:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- блокирует строку 1
-- ... пауза ...
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- ждёт строку 2

-- Транзакция Б (параллельно):
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2; -- блокирует строку 2
UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- ждёт строку 1 → DEADLOCK

Решение: всегда захватывать строки в одном порядке - сначала строку с меньшим id. Или использовать SELECT ... FOR UPDATE заранее, чтобы сразу захватить все нужные строки.

Дополнительная защита - таймауты:

SET lock_timeout = '3s';    -- прервать, если ждём блокировку дольше 3 сек
SET statement_timeout = '30s'; -- прервать запрос, если он длится дольше 30 сек

Диагностика: pg_locks и pg_stat_activity

Когда база «тормозит» - первым делом смотри активные блокировки:

SELECT
    pid,
    now() - pg_stat_activity.query_start AS duration,
    query,
    state
FROM pg_stat_activity
WHERE state != 'idle'
AND (now() - pg_stat_activity.query_start) > interval '5 seconds';

Чтобы найти блокировщика:

SELECT
    blocked_locks.pid AS blocked_pid,
    blocked_activity.query AS blocked_query,
    blocking_locks.pid AS blocking_pid,
    blocking_activity.query AS blocking_query
FROM pg_catalog.pg_locks AS blocked_locks
JOIN pg_catalog.pg_stat_activity AS blocked_activity
    ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks AS blocking_locks
    ON blocking_locks.locktype = blocked_locks.locktype
    AND blocking_locks.pid != blocked_locks.pid
    AND blocking_locks.granted
JOIN pg_catalog.pg_stat_activity AS blocking_activity
    ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

Состояние idle in transaction в pg_stat_activity - сигнал тревоги. Транзакция открыта, но ничего не делает. Она держит блокировки и мешает VACUUM убирать мёртвые версии строк.

Скрипты диагностики блокировок - platformv.sbertech.ru.

Миграции без аварий на проде

Миграция на проде - это хирургия на работающем сердце. Ошибка стоит дорого.

Идемпотентность и down-скрипты

Каждая миграция должна нормально выполняться повторно. Это идемпотентность:

-- Хорошо
CREATE TABLE IF NOT EXISTS users (
    id BIGSERIAL PRIMARY KEY,
    email TEXT NOT NULL
);

-- Плохо - упадёт при повторном запуске
CREATE TABLE users (...);

Down-скрипт - это миграция отката. Без него нет возможности быстро откатить релиз при инциденте.

-- up
ALTER TABLE orders ADD COLUMN status TEXT DEFAULT 'new';

-- down
ALTER TABLE orders DROP COLUMN status;

Хорошая практика от Habr/Beget: миграция не должна выполняться дольше 3–5 секунд даже без блокировок. Всё, что дольше - выносить в отдельный фоновый скрипт.

Операции, блокирующие таблицу

Вот что блокирует таблицу AccessExclusiveLock и не должно быть в обычной миграции на загруженном проде:

Операция Блокирует? Альтернатива
CREATE INDEX ✅ да CREATE INDEX CONCURRENTLY
DROP INDEX ✅ да DROP INDEX CONCURRENTLY
ALTER TABLE ADD COLUMN NOT NULL без DEFAULT ✅ до PG 11 Добавить с DEFAULT, потом NOT NULL
ALTER COLUMN TYPE ✅ да Expand-Contract паттерн
ADD CONSTRAINT ... VALIDATE ✅ да ADD CONSTRAINT ... NOT VALID, потом VALIDATE отдельно

CREATE INDEX CONCURRENTLY не требует AccessExclusiveLock, но его нельзя запускать внутри транзакции - это ещё одна ловушка:

-- НЕЛЬЗЯ:
BEGIN;
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id); -- Ошибка!
COMMIT;

-- МОЖНО (вне транзакции):
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);

Expand-Contract: миграция без даунтайма

Expand-Contract - паттерн для изменения схемы без остановки сервиса. Состоит из трёх фаз:

  • Фаза 1 - Expand (расширяем): добавляем новый столбец, триггер, деплоим код.
  • Фаза 2 - Migrate (переносим данные): батчами копируем данные.
  • Фаза 3 - Contract (сжимаем): переключаем код, убираем триггер, удаляем старый столбец.

Пример батчевого обновления:

DO $$
DECLARE
    last_id BIGINT := 0;
BEGIN
    LOOP
        UPDATE orders
        SET status_new = status
        WHERE id IN (
            SELECT id FROM orders WHERE status_new IS NULL AND id > last_id
            ORDER BY id LIMIT 1000
        )
        RETURNING MAX(id) INTO last_id;

        EXIT WHEN NOT FOUND OR last_id IS NULL;
        PERFORM pg_sleep(0.01); -- небольшая пауза, чтобы не давить реплики
    END LOOP;
END $$;

Авторская ремарка. Expand-Contract - единственный способ изменить тип столбца на таблице в 200 млн строк без окна обслуживания. Да, это дольше. Но альтернатива - 30-минутный даунтайм в пятницу вечером.

Инструменты миграций: что выбрать под свой стек

Инструмент Язык/Экосистема Форматы Особенность
Flyway JVM (Java, Kotlin, Scala) SQL, Java Простой, файловый, популярен в Spring
Liquibase JVM XML, YAML, JSON, SQL Гибкий, поддерживает rollback из коробки
Alembic Python (SQLAlchemy) Python, SQL Автогенерация по изменениям модели
golang-migrate Go SQL CLI + библиотека, минималистичный

Для Python-бэкенда с SQLAlchemy - Alembic. Для Go - golang-migrate. Для Django - встроенные миграции с флагом --fake для CONCURRENTLY-операций.

Чеклист перед деплоем:

  • Миграция идемпотентна (IF NOT EXISTS / проверки)
  • Написан down-скрипт и протестирован
  • Нет DDL-операций без CONCURRENTLY на больших таблицах
  • CREATE INDEX CONCURRENTLY - вне транзакции
  • Долгие операции разбиты на батчи с LIMIT
  • Старый код работает после применения миграции (совместимость схемы)
  • Проверен план запросов EXPLAIN ANALYZE для новых запросов

Шпаргалка по миграциям PostgreSQL - Habr/Beget.

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

Бонус для тех, кто делает очереди на PostgreSQL. SKIP LOCKED - команда, которая пропускает уже заблокированные строки вместо ожидания:

-- Воркер забирает одну задачу из очереди, не мешая другим воркерам
SELECT id, payload
FROM jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1;

Это стандартный паттерн для конкурентных воркеров без внешней очереди (Redis, Kafka). Работает начиная с PostgreSQL 9.5.

Документация: SELECT FOR UPDATE SKIP LOCKED - postgrespro.ru.

FAQ

Какой уровень изоляции используется в PostgreSQL по умолчанию?

READ COMMITTED. Защищает от грязного чтения, но не от неповторяемого. Подходит для большинства CRUD-операций.

Что такое MVCC и зачем он нужен?

MVCC (Multi-Version Concurrency Control) - механизм хранения нескольких версий строк. Читатели видят снимок данных на момент своей транзакции и не блокируют писателей. PostgreSQL реализует MVCC через системные поля xmin/xmax в каждой строке.

Как избежать deadlock в PostgreSQL?

Всегда захватывать строки в одном порядке (например, по возрастанию id). Дополнительно - устанавливать lock_timeout, чтобы транзакция не висела бесконечно.

Можно ли создать индекс без блокировки таблицы?

Да, через CREATE INDEX CONCURRENTLY. Он не блокирует чтение и запись, но выполняется дольше. Важно: нельзя запускать внутри BEGIN/COMMIT.

Что такое Expand-Contract и когда применять?

Трёхфазный паттерн для изменения схемы без даунтайма: добавить новый столбец → перенести данные батчами → удалить старый. Необходим при изменении типа столбца на больших таблицах в production.

Чем Flyway отличается от Liquibase?

Flyway проще: SQL-файлы, минималистичный API, популярен в Spring. Liquibase гибче: XML/YAML/JSON форматы, rollback из коробки. Оба поддерживают PostgreSQL. Для Python - Alembic, для Go - golang-migrate.

Почему idle in transaction опасен?

Открытая, но бездействующая транзакция держит блокировки и мешает VACUUM убирать мёртвые версии строк. Это приводит к раздуванию таблиц (table bloat). Решение - statement_timeout и явные COMMIT/ROLLBACK при любом исходе.

Источники

  1. Beget / Habr - «Шпаргалка по работе с PostgreSQL для бэкенд-разработчиков» - https://habr.com/ru/companies/beget/articles/920772/

  2. Postgres Pro / postgrespro.ru - Документация PostgreSQL 16: «Введение в MVCC» - https://postgrespro.ru/docs/postgresql/9.6/mvcc-intro.html

  3. Postgres Pro / postgrespro.ru - Документация PostgreSQL: «Изоляция транзакций» - https://postgrespro.ru/docs/postgresql/16/transaction-iso.html

  4. proselyte.net - «Что backend-разработчик должен знать про PostgreSQL» - https://proselyte.net/postgres-for-devs/

  5. Habr / Haulmont - «Инструментарий для рефакторинга баз данных: Flyway и Liquibase» - https://habr.com/ru/companies/haulmont/articles/440696/

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также