Транзакции в PostgreSQL: почему счетчик может «сойти с ума» и как работает ложная вложенность
Мы привыкли воспринимать транзакции как должное: `BEGIN`, `COMMIT`, готово. Но под капотом Postgres скрывается механизм, который может остановить весь кластер, и архитектурная иллюзия, на которую ловятся даже сеньоры.
1. Счетчик транзакций — это бомба с часовым механизмом.
Каждая транзакция получает ID (XID). В старых версиях Postgres под него было выделено всего 32 бита — это чуть больше 4 миллиардов.
В высоконагруженной системе этот счетчик может прокрутиться за пару месяцев. Если это произойдет, база не сможет понять, какая транзакция была раньше, а какая позже. Это грозит потерей данных.
Чтобы этого не случилось, существует заморозка (Freezing). Процесс автоочистки (`autovacuum`) заранее помечает очень старые, но актуальные строки как «замороженные». Для них ID больше не нужен.
Фишка: если `autovacuum` не справляется (например, из-за долгой транзакции-аналитика), база начнет жестко тормозить, а при достижении лимита `autovacuum_freeze_max_age` просто откажется писать данные, чтобы избежать катастрофы.
2. Вложенных транзакций не существует
Вы пишете `BEGIN` внутри другого `BEGIN`? В большинстве СУБД это вызовет ошибку. В PostgreSQL верхний `BEGIN` просто молча проигнорируется.
Настоящей вложенности нет. Есть только Savepoints (точки сохранения).
Если внутри большой транзакции упал тяжелый шаг (например, отправка письма через API), вы делаете `ROLLBACK TO SAVEPOINT`. Это откатит только работу с письмом, но сохранит всё, что было сделано до точки сохранения.
Ошибка думать, что `BEGIN` внутри транзакции создал изолированный контекст. Если вы сделаете `COMMIT` во «вложенном» блоке, зафиксируется вся внешняя транзакция целиком.
3. Транзакция не завершена, пока не записан WAL
Многие думают, что `COMMIT` — это фиксация в таблицах. На самом деле Postgres сначала записывает изменения в журнал упреждающей записи (WAL) на диск, и только потом сообщает приложению: «Я всё».
Если делать миллионы крошечных транзакций (например, вставлять записи по одной в цикле), диск умрет от записи WAL раньше, чем кончится память.
Для массовой загрузки данных используйте `COPY` с параметром `FREEZE` (если таблица пустая) или группируйте операции в пакеты по 5-10 тысяч строк. Это сократит количество записей в WAL в сотни раз.
4. Долгая транзакция — киллер диска
Самая частая причина, по которой база раздувается с 50 ГБ до 500 ГБ за неделю — забытый `BEGIN` в Python-скрипте аналитика.
Пока транзакция открыта, `VACUUM` не имеет права удалять старые версии строк (Dead Tuples), потому что «древняя» транзакция всё еще может их захотеть увидеть.
Таблица превращается в швейцарский сыр, место на SSD заканчивается, а производительность падает до нуля. Золотое правило: открыли — сделали — закрыли. Никаких HTTP-запросов внутри транзакции.
Чек-лист:
1. Мониторьте возраст транзакций (`SELECT datname, age(datfrozenxid) FROM pg_database;`).
2. Никогда не делайте сетевые вызовы (HTTP, запросы к другим БД) внутри блока `BEGIN...COMMIT`.
3. Используйте `SAVEPOINT` для частичного отката сложной логики.
Мы привыкли воспринимать транзакции как должное: `BEGIN`, `COMMIT`, готово. Но под капотом Postgres скрывается механизм, который может остановить весь кластер, и архитектурная иллюзия, на которую ловятся даже сеньоры.
1. Счетчик транзакций — это бомба с часовым механизмом.
Каждая транзакция получает ID (XID). В старых версиях Postgres под него было выделено всего 32 бита — это чуть больше 4 миллиардов.
В высоконагруженной системе этот счетчик может прокрутиться за пару месяцев. Если это произойдет, база не сможет понять, какая транзакция была раньше, а какая позже. Это грозит потерей данных.
Чтобы этого не случилось, существует заморозка (Freezing). Процесс автоочистки (`autovacuum`) заранее помечает очень старые, но актуальные строки как «замороженные». Для них ID больше не нужен.
Фишка: если `autovacuum` не справляется (например, из-за долгой транзакции-аналитика), база начнет жестко тормозить, а при достижении лимита `autovacuum_freeze_max_age` просто откажется писать данные, чтобы избежать катастрофы.
2. Вложенных транзакций не существует
Вы пишете `BEGIN` внутри другого `BEGIN`? В большинстве СУБД это вызовет ошибку. В PostgreSQL верхний `BEGIN` просто молча проигнорируется.
Настоящей вложенности нет. Есть только Savepoints (точки сохранения).
Если внутри большой транзакции упал тяжелый шаг (например, отправка письма через API), вы делаете `ROLLBACK TO SAVEPOINT`. Это откатит только работу с письмом, но сохранит всё, что было сделано до точки сохранения.
Ошибка думать, что `BEGIN` внутри транзакции создал изолированный контекст. Если вы сделаете `COMMIT` во «вложенном» блоке, зафиксируется вся внешняя транзакция целиком.
3. Транзакция не завершена, пока не записан WAL
Многие думают, что `COMMIT` — это фиксация в таблицах. На самом деле Postgres сначала записывает изменения в журнал упреждающей записи (WAL) на диск, и только потом сообщает приложению: «Я всё».
Если делать миллионы крошечных транзакций (например, вставлять записи по одной в цикле), диск умрет от записи WAL раньше, чем кончится память.
Для массовой загрузки данных используйте `COPY` с параметром `FREEZE` (если таблица пустая) или группируйте операции в пакеты по 5-10 тысяч строк. Это сократит количество записей в WAL в сотни раз.
4. Долгая транзакция — киллер диска
Самая частая причина, по которой база раздувается с 50 ГБ до 500 ГБ за неделю — забытый `BEGIN` в Python-скрипте аналитика.
Пока транзакция открыта, `VACUUM` не имеет права удалять старые версии строк (Dead Tuples), потому что «древняя» транзакция всё еще может их захотеть увидеть.
Таблица превращается в швейцарский сыр, место на SSD заканчивается, а производительность падает до нуля. Золотое правило: открыли — сделали — закрыли. Никаких HTTP-запросов внутри транзакции.
Чек-лист:
1. Мониторьте возраст транзакций (`SELECT datname, age(datfrozenxid) FROM pg_database;`).
2. Никогда не делайте сетевые вызовы (HTTP, запросы к другим БД) внутри блока `BEGIN...COMMIT`.
3. Используйте `SAVEPOINT` для частичного отката сложной логики.