UPDATE в PostgreSQL не переписывает строку на месте: появляется новая версия, старая жива, пока её кто-то может видеть. Vacuum подбирает мёртвое. Долгая транзакция удерживает горизонт и раздувает bloat.
PostgreSQL postgres собеседование
MVCC (multiversion concurrency control) — несколько версий одной строки. Читатели не блокируют писателей на snapshot-данных. Вместо «выгнать всех с строки и переписать байты» Postgres кладёт новую версию tuple (кортежа). Читатель со старым снимком видит старую версию. Читатель с новым снимком видит новую.
Писатели одной строки всё равно выстраиваются в очередь. Это уже локи, не MVCC.
Пример. Заказ: status = 'new'. Делаем UPDATE на 'paid'. Старая версия не стирается. Появляется новая. Транзакция, которая начала читать раньше, всё ещё видит 'new'.
Цена модели — мусор. Каждое обновление оставляет мёртвую версию. Место можно переиспользовать только когда ни один снимок в кластере эту версию больше не видит. Пока живёт старый snapshot, vacuum бессилен. Долгая транзакция, слот репликации, idle in transaction — и таблица растёт, хотя «живых» строк столько же.
На проде это выглядит как «диск кончается», «индекс внезапно втрое больше таблицы», «autovacuum не успевает». Разбор начинают не с fillfactor, а с того, кто держит xmin horizon (горизонт видимости).
xmin и xmax
У каждого tuple есть xmin — идентификатор транзакции, создавшей версию. И xmax — транзакция, которая её удалила или обновила (тогда есть указатель на новую версию). Читатель по своему snapshot решает, видима ли эта версия: создавшая транзакция закоммичена и попала в снимок, удалившая — нет или ещё не в снимке.
DELETE не стирает байты: ставит xmax. UPDATE = DELETE старой версии + INSERT новой. HOT update (heap-only tuple) — частный случай: новая версия влезла в ту же страницу, индексируемые колонки не менялись, индекс можно не раздувать так же агрессивно. Смена колонки, которая входит в индекс, даёт новые индексные записи.
Vacuum и bloat
VACUUM (обычно autovacuum) помечает мёртвые tuple свободными, обновляет visibility map, продвигает freeze, чтобы не поймать xid wraparound (переполнение счётчика транзакций). Он не сжимает файл на диске. Чтобы отдать место ОС, нужны VACUUM FULL или pg_repack: это уже лок или перестройка копии.
Bloat — таблица и индексы раздуты мёртвыми версиями и дырами. Частые UPDATE широких строк, низкий fillfactor, и главное — горизонт xmin, который не едет. Горизонт держит самая старая транзакция, которая ещё может видеть старые версии: открытый BEGIN, сессия idle in transaction, логический слот репликации, который не читают.
-- кто держит горизонт
SELECT pid, xact_start, state, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;
-- живые и мёртвые версии
SELECT n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'orders';
Кто держит горизонт xmin и сколько мёртвых строк накопила таблица — первые два запроса при расследовании bloat.
Если n_dead_tup растёт, а last_autovacuum свежий — vacuum ходит, но не может убрать версии из-за горизонта. Смотрите самую старую транзакцию и слоты в pg_replication_slots. Если autovacuum давно не приходил — другая история: пороги, wraparound, «тяжёлая» таблица, выключенный autovacuum.
Типичная ошибка
Обернуть HTTP-вызов или чтение из очереди в «транзакцию на всякий случай». Пока соединение idle in transaction, vacuum по всему кластеру не может продвинуть xmin. Реплика с лагом и живым слотом даёт тот же эффект: WAL и мёртвые tuple копятся на primary.
На собеседовании
Связка: MVCC → несколько версий строки → vacuum убирает невидимое → xmin horizon определяет, что ещё видимо. Спросите себя, что будет, если реплика отстала и слот не продвигается. VACUUM без FULL файл не сжимает.