voidlearn
 

6.3MVCC и внутренности PostgreSQL

Свяжи MVCC с повседневным поведением PostgreSQL: обновление создаёт новую версию строки, а старая остаётся видимой уже начатым снимкам. Из этого следуют работа autovacuum, влияние долгих транзакций и причины разрастания таблиц и индексов.

MVCC: чтение без блокировок

MVCC устроен так: вместо того чтобы менять строку на месте и блокировать читателей, создаём новую версию строки, а читатели продолжают видеть старую. Отсюда главное свойство PostgreSQL: читатели не блокируют писателей, писатели не блокируют читателей. Ждать приходится только писателю, который хочет изменить строку, уже изменяемую другим писателем.

Возьмём бухгалтерскую книгу. Проще всего обновить остаток, стерев старое число и вписав новое. Но тогда коллега, который прямо сейчас читает эту страницу, увидит полустёртое значение - значит, на время правки книгу придётся у него отобрать. Так и работают блокировочные СУБД: писатель заставляет читателей ждать.

PostgreSQL делает иначе: старую запись не стирает, а перечёркивает и дописывает новую рядом, пометив у обеих, с какого момента какая действует. Читатель, пришедший раньше, спокойно читает перечёркнутую - она никуда не делась и для его момента времени всё ещё верна. Это и есть MVCC: никто никого не ждёт, но в книге копятся перечёркнутые записи.

Где аналогия перестаёт работать: в бухгалтерской книге перечёркнутое остаётся навсегда, это и есть смысл книги. В базе перечёркнутое превращается в мусор, который надо периодически вычищать, иначе файл будет только расти. Уборке этого мусора отдана половина главы.

Теперь точнее. У каждого кортежа - то есть у каждой версии строки - в служебном заголовке лежат поля:

  • xmin хранит идентификатор транзакции, которая создала эту версию;
  • xmax хранит идентификатор транзакции, которая её удалила или заменила (0, если версия ещё актуальна);
  • ctid указывает физический адрес версии: (номер страницы, номер строки в странице);
  • cmin/cmax нумеруют команды внутри транзакции, чтобы транзакция корректно видела собственные изменения.
Одна логическая строка = цепочка версий в куче (heap) Страница таблицы, 8 КБ xmin = 100 xmax = 150 balance = 500 мёртвая заменена tx 150 ctid указывает на новую версию xmin = 150 xmax = 0 balance = 400 живая актуальная версия UPDATE не изменил старую версию: он поставил ей xmax и дописал новую. Место старой освободит только VACUUM. Снапшот и правила видимости snapshot = { xmin: 140, xmax: 210, xip: [160] } xmin - всё, что старше, уже завершено xmax - всё, что новее, ещё не начиналось xip - список активных на момент снимка Версия видна, если одновременно: 1) её xmin закоммичен и не «в будущем» и не в xip 2) её xmax равен 0, либо не закоммичен, либо «в будущем» относительно снапшота Отсюда: незакоммиченное не видно НИКОМУ - грязного чтения нет. Read Committed берёт новый снапшот на каждый оператор; Repeatable Read и Serializable - один на всю транзакцию. Вся разница уровней изоляции в PG - это момент взятия снапшота.
Механика MVCC. Никаких блокировок для чтения не нужно: транзакция сравнивает xmin/xmax каждой версии со своим снапшотом и сама решает, какую видеть. Платить приходится мёртвыми версиями, которые кто-то потом должен убрать.

Что физически происходит при INSERT / UPDATE / DELETE

ОперацияЧто делает движокЧто остаётся мусором
INSERTновая версия с xmin = мой xid, xmax = 0; записи во все индексыничего (если транзакция закоммичена)
UPDATEновая версия целиком + старой проставляется xmax; записи во все индексы таблицы, а не только в те, чьи колонки менялись (новая версия лежит по новому ctid) - если это не HOT-обновлениестарая версия строки + старые индексные записи
DELETEтолько проставляется xmax; строка физически на местевся версия строки + все её индексные записи
ROLLBACKничего не откатывается физически: транзакция помечается abortedвсё, что она успела записать
UPDATE одной фразой

«В PostgreSQL UPDATE - это по сути DELETE + INSERT: старая версия строки не меняется, а помечается устаревшей, и создаётся новая целиком, даже если изменилось одно поле из тридцати.» Из этой фразы выводится всё: почему обновление широкой строки дорогое, почему таблица растёт при «просто обновлениях», почему нужен VACUUM, почему счётчик, обновляемый 1000 раз в секунду, создаёт 1000 мёртвых версий в секунду, и почему такой счётчик лучше держать в Redis.

Обычный UPDATE против HOT-обновления Обычный UPDATE: изменилась индексируемая колонка индекс по email 2 записи индекс по name 2 записи версия v1 (мёртвая) xmax = 150 версия v2 (живая) xmin = 150 Каждый индекс получил новую запись: запись в WAL растёт, индексы пухнут, VACUUM больше работы HOT-обновление: индексируемые колонки не тронуты индекс по email 1 запись версия v1 (мёртвая) HOT-цепочка версия v2 (живая) та же страница Индекс не трогаем вообще: он указывает на v1, а движок доходит по цепочке до v2 внутри страницы
HOT. Срабатывает, только если новая версия помещается в ту же страницу и ни одна индексируемая колонка не изменилась (с PG 16 не в счёт колонки, на которых только BRIN-индекс, о нём в главе 6.4). Отсюда практический приём: fillfactor ниже 100 (например 85) оставляет в странице место под будущие версии и резко повышает долю HOT-обновлений на часто обновляемых таблицах.

VACUUM, autovacuum и bloat

Мёртвой версией становится кортеж, которому уже проставили xmax закоммиченной транзакции и который больше не нужен ни одному живому снапшоту. Сам он не исчезает: UPDATE и DELETE в PostgreSQL физически ничего не стирают. Убирает мёртвые версии VACUUM, и только тогда, когда версия перестала быть нужна самому старому активному снапшоту во всей базе. Отсюда прямая связь «долгая транзакция → VACUUM не может ничего почистить → таблицы пухнут»: одна забытая открытая транзакция копит мусор по всей базе, а не только в тех таблицах, которых она касалась.

Место, занятое такими версиями, и называют bloat: из-за него таблица с 1 ГБ полезных данных запросто занимает 10 ГБ на диске - данные на месте, просто между ними дыры от мёртвых версий. Почти вся эксплуатационная головная боль PostgreSQL сводится к одному вопросу: успевает ли VACUUM за темпом появления мусора.

Попутно VACUUM обновляет карту видимости: по два бита на каждую страницу таблицы: «в этой странице все версии видны всем и мёртвых нет» и «все версии заморожены». Карта понадобится в главе про индексы: с ней ответ можно отдать прямо из индекса, вообще не заглядывая в таблицу.

ФормаЧто делаетБлокировкаВозвращает место ОС
VACUUMпомечает место мёртвых версий свободным для переиспользования, чистит индексы, обновляет visibility mapSHARE UPDATE EXCLUSIVE - не мешает DMLнет (только если мусор в самом конце файла)
VACUUM FULLпереписывает таблицу в новый файл без дырACCESS EXCLUSIVE - таблица недоступнада
ANALYZEпересобирает статистику для планировщикаSHARE UPDATE EXCLUSIVE-
pg_repackто же, что VACUUM FULL, но почти без блокировки (через триггеры и подмену файла)короткий эксклюзив в концеда

Autovacuum запускает VACUUM и ANALYZE по порогам. Основные параметры: autovacuum_vacuum_scale_factor (по умолчанию 0.2, то есть «чистим, когда мёртвых стало 20 % от таблицы») и autovacuum_vacuum_threshold. На больших таблицах дефолт плох: 20 % от 500 млн строк дают 100 млн мёртвых версий до первой уборки. Поэтому на горячих таблицах scale factor снижают до 0.01–0.05 или задают autovacuum_vacuum_max_threshold (PG 18).

Table bloat: как выглядит инцидент

Таблица на 10 ГБ полезных данных занимает 60 ГБ, все запросы по ней замедлились втрое, диск кончается. Причины почти всегда из этого списка: (1) висела долгая транзакция или idle in transaction; (2) незакрытый replication slot, который тоже держит xmin, причём вечно, даже если реплики давно нет; (3) часто обновляемая таблица с дефолтным scale factor 0.2; (4) autovacuum не успевает из-за autovacuum_vacuum_cost_delay и малого числа воркеров. Диагностируют по pg_stat_user_tables.n_dead_tup и last_autovacuum, лечат через pg_repack плюс устранение причины.

-- Мёртвые кортежи и когда последний раз убирались
SELECT relname, n_live_tup, n_dead_tup,
       round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
       last_autovacuum, last_autoanalyze
FROM   pg_stat_user_tables
ORDER  BY n_dead_tup DESC
LIMIT  20;

-- Кто держит горизонт очистки: транзакции, слоты, prepared transactions
SELECT slot_name, active, restart_lsn, xmin FROM pg_replication_slots;
SELECT max(age(backend_xmin)) FROM pg_stat_activity;

-- Настройка для горячей таблицы: чистим чаще и оставляем место под HOT
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02, fillfactor = 85);
Transaction ID wraparound

Идентификатор транзакции 32-битный, то есть счётчик обходит круг примерно каждые 4 млрд транзакций. Сравнение xid кольцевое: «половина круга назад - прошлое, половина вперёд - будущее». Чтобы очень старые строки не оказались «в будущем» и не исчезли, VACUUM их замораживает. Если заморозка отстаёт, PostgreSQL сначала предупреждает в логе, затем переходит в аварийный режим и отказывается принимать новые транзакции, пока не выполнишь VACUUM вручную. Мониторить надо age(datfrozenxid) по базам - классический вопрос уровня «а ты эксплуатировал PostgreSQL на самом деле?». По таблицам то же показывает age(relfrozenxid), а 64-битные xid до сих пор остаются мечтой.

WAL: журнал упреждающей записи

WAL ведётся в отдельном файле, куда база дописывает короткие заметки вида «в такой-то странице такие-то байты стали такими». Слово «упреждающей» означает порядок: заметка в журнал пишется раньше, чем изменение доходит до файлов данных. Никогда не наоборот.

Зачем такой крюк. Изменённые страницы разбросаны по огромному файлу - сбрасывать их на диск сразу значит писать в случайные места, а это медленно даже на SSD. Журнал же пишется подряд, в конец файла, а дешевле записи не бывает. Поэтому база делает так: страницы правит в памяти (shared_buffers) и оставляет «грязными», а на диск гарантированно кладёт только короткую запись в журнал. Клиенту «COMMIT прошёл» отвечают после fsync журнала и до записи самих страниц.

Отсюда и живучесть: если сервер выключат по питанию, грязные страницы из памяти пропадут, но журнал на диске цел. При старте база прочитает его и повторно применит все заметки после последней контрольной точки - это называется восстановлением по журналу. Тот же журнал даёт репликацию (реплика просто непрерывно применяет чужой WAL) и восстановление на момент времени (PITR).

Что происходит при UPDATE и COMMIT Backend UPDATE ... COMMIT shared_buffers страница стала грязной позже, асинхронно checkpointer / bgwriter Файлы данных base/*/16384 СНАЧАЛА WAL buffer запись об изменении pg_wal/, fsync только после этого «OK» walsender → реплика физическая репликация Зачем такой порядок 1. Крах после COMMIT: при старте PostgreSQL проигрывает WAL с последней контрольной точки - данные восстановлены. 2. Последовательная запись в журнал на порядок дешевле случайной записи страниц по всему файлу. 3. Тот же поток WAL - основа физической репликации и PITR. 4. CHECKPOINT сбрасывает грязные страницы и двигает точку, с которой начнётся восстановление. Реже = быстрее работа, но дольше recovery.
Write-Ahead Logging. Один и тот же поток WAL решает три задачи: восстановление после аварии, репликацию и point-in-time recovery. Поэтому «выключить WAL ради скорости» для всей базы нельзя. Можно ослабить synchronous_commit, разменяв последние сотни миллисекунд транзакций на пропускную способность, а таблицу, которую не жалко потерять, сделать UNLOGGED: она пишется мимо WAL, но после сбоя очищается и на реплики не попадает.
Три настройки WAL, которые надо знать

synchronous_commit: on - fsync перед ответом клиенту (по умолчанию); off - ответ сразу, потеря последних транзакций при аварии (до трёх wal_writer_delay, по умолчанию до ~600 мс), но база остаётся согласованной (теряются целые транзакции, а не половинки); remote_apply - ждать применения на синхронной реплике. wal_level: replica для физической репликации, logical для логической. full_page_writes: после каждой контрольной точки первая модификация страницы пишет в WAL её целиком. Это защита от torn pages и главная причина всплесков объёма WAL сразу после checkpoint.

TOAST

Строка в PostgreSQL не может занимать больше одной страницы (8 КБ). Что делать со строкой, где лежит JSON на 2 МБ? Этим занимается встроенный механизм TOAST: он прячет слишком крупные значения в отдельное хранилище. Ничего настраивать для этого не надо, он работает всегда и молча. Когда версия строки не влезает в ~2 КБ (порог TOAST_TUPLE_THRESHOLD), движок сначала сжимает самые крупные значения переменной длины, а если строка всё ещё не влезает, выносит их в отдельную служебную таблицу pg_toast.pg_toast_NNNN, нарезая на чанки примерно по 2 КБ, а в самой строке оставляет указатель.

Стратегия колонкиСжиматьВыноситьКогда ставить
EXTENDED (дефолт для text/jsonb)дадаобычные большие тексты и JSON
EXTERNALнетдаесли нужны быстрые срезы substring() без распаковки
MAINдатолько в крайнем случаезначения, которые читают почти всегда
PLAINнетнеттипы фиксированной длины
Практические следствия TOAST

1) SELECT id, status FROM t по таблице с гигантским jsonb работает быстро: TOAST-значения не читаются, пока их не запросили. А SELECT * вытащит и распакует всё - вот одна из настоящих причин, почему SELECT * дорог. 2) Обновление любой колонки строки с TOAST-значением создаёт новую версию строки, но неизменённые TOAST-чанки переиспользуются, их не копируют. 3) По умолчанию сжимает pglz; с PG 14 доступен lz4 (default_toast_compression = lz4), он заметно быстрее при чуть худшем сжатии. 4) Размер TOAST-таблицы виден отдельно: pg_total_relation_size включает её, pg_relation_size - нет. Расхождение этих двух цифр часто и отвечает на «куда делось место».

Поиск проблемных запросов

-- pg_stat_statements: топ по суммарному времени, с него начинают оптимизацию.
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;   -- + shared_preload_libraries

SELECT round(total_exec_time::numeric, 1) AS total_ms,
       calls,
       round(mean_exec_time::numeric, 2)  AS mean_ms,
       rows,
       round(100.0 * shared_blks_hit / NULLIF(shared_blks_hit + shared_blks_read, 0), 1) AS hit_pct,
       left(query, 100)
FROM   pg_stat_statements
ORDER  BY total_exec_time DESC
LIMIT  20;

-- Что происходит прямо сейчас
SELECT pid, state, now() - query_start AS dur, wait_event_type, wait_event, left(query, 90)
FROM   pg_stat_activity
WHERE  state = 'active' AND backend_type = 'client backend'
ORDER  BY dur DESC;

SELECT pg_stat_statements_reset();   -- обнулить перед экспериментом
Сортировать по total_exec_time, а не по mean_exec_time

Запрос на 3 секунды раз в час серверу не страшен. Запрос на 8 миллисекунд, вызываемый 40 тысяч раз в минуту, съедает сервер. Суммарное время показывает, куда реально уходит ресурс, и по нему же находят N+1: одинаковый нормализованный запрос с гигантским calls. Полезные соседние колонки: rows / calls (сколько строк возвращает в среднем), shared_blks_read (сколько читает мимо кэша) и stddev_exec_time (нестабильность плана).

Вопросы

6
Суть: вместо изменения строки на месте создаётся новая версия; каждая транзакция по своему снапшоту решает, какую версию видеть. Поэтому читатели не блокируют писателей, а писатели - читателей.

Служебные поля версии

У каждого кортежа в заголовке есть xmin (xid транзакции, создавшей версию), xmax (xid транзакции, удалившей или заменившей её; 0, если версия актуальна), ctid (физический адрес: страница, смещение), плюс cmin/ cmax для видимости внутри собственной транзакции. Их можно прямо посмотреть: SELECT xmin, xmax, ctid, * FROM accounts; - отличная демонстрация на собесе.

Снапшот

Снапшот состоит из трёх частей: xmin (все транзакции старше уже завершены), xmax (всё новее ещё не начиналось) и xip, список активных транзакций на момент снимка. Версия видна, если её создатель закоммичен и не входит в «активные или будущие», и при этом её xmax либо нулевой, либо принадлежит транзакции, которая для нас ещё не завершилась.

Почему из этого следует отсутствие грязного чтения

У незакоммиченной версии xmin принадлежит активной транзакции - правило видимости отбрасывает её у всех, кроме самой этой транзакции. Показать грязные данные в PostgreSQL физически нечем, поэтому READ UNCOMMITTED совпадает с READ COMMITTED.

Чем платим

  • Мёртвые версии копятся в таблице → нужен VACUUM, возможен bloat.
  • Раздувание индексов: каждая новая версия строки (кроме HOT) добавляет записи во все индексы.
  • count(*) не может быть мгновенным: видимость строки зависит от снапшота, поэтому приходится сканировать (спасает частично index-only scan через visibility map).
  • Долгая транзакция вредит всей базе, удерживая горизонт очистки.
Чем добить

«MVCC бывает разный: PostgreSQL хранит версии в самой таблице (heap), InnoDB и Oracle - в undo-логе. Отсюда разный профиль эксплуатации: у PG есть autovacuum и bloat, зато мгновенный ROLLBACK; у InnoDB нет VACUUM, зато откат большой транзакции физически дорог, а долгие чтения замедляются из-за длинных цепочек undo.»

Суть: UPDATE = вставка новой версии строки целиком плюс пометка старой (xmax). Старая версия остаётся в файле до VACUUM. HOT - оптимизация, когда новая версия влезла в ту же страницу и индексируемые колонки (кроме тех, что только под BRIN) не менялись.

Пошагово

  1. Движок находит текущую версию строки и берёт на неё блокировку.
  2. Формирует новую версию целиком - со всеми колонками, даже неизменёнными.
  3. Пишет её в ту же страницу, если есть место, иначе в другую.
  4. Старой версии проставляет xmax = мой xid и связывает её ctid с новой.
  5. Добавляет записи во все индексы, если это не HOT-обновление.
  6. Пишет всё это в WAL (а после контрольной точки - ещё и полную страницу целиком).

Отсюда неочевидные следствия: обновление одного boolean в строке с jsonb на 500 КБ создаёт новую версию строки (правда, неизменённые TOAST-чанки переиспользуются); таблица, где 1000 раз в секунду инкрементируется счётчик, растёт со скоростью 1000 мёртвых версий в секунду; ROLLBACK ничего не откатывает физически - записанные версии просто остаются мусором.

HOT (Heap-Only Tuple)

Если новая версия помещается в ту же страницу и ни одна проиндексированная колонка не изменилась, PostgreSQL не трогает индексы вообще: индексная запись продолжает указывать на старую версию, а внутри страницы выстраивается HOT-цепочка, по которой движок доходит до актуальной. Так получается меньше записи в WAL, индексы не пухнут, а очистка цепочки может произойти прямо при обращении к странице, без полноценного VACUUM. С PG 16 HOT не ломается и при изменении колонок, на которых есть только BRIN-индекс: обновится лишь его сводка.

Как повысить долю HOT-обновлений

1) ALTER TABLE t SET (fillfactor = 85) оставит место в странице под будущие версии (по умолчанию 100, то есть места нет вообще). 2) Не индексировать часто обновляемые колонки. Индекс на updated_at или на status, который меняется каждую минуту, убивает HOT для всей таблицы. В разговоре про индексы это сильный аргумент. 3) Проверять эффект по pg_stat_user_tables.n_tup_hot_upd относительно n_tup_upd. В PG 16+ появились и другие оптимизации записи, но главным рычагом остаётся HOT.

Суть: VACUUM убирает мёртвые версии строк, чистит индексы, обновляет visibility map и замораживает старые xid. Bloat - это когда он не успевает или не может работать, и файл таблицы становится в разы больше полезных данных.

Что делает VACUUM

  • Помечает место мёртвых версий свободным для повторного использования (файл при этом не уменьшается).
  • Удаляет соответствующие записи из всех индексов.
  • Обновляет visibility map, битовую карту «в этой странице все версии видны всем», без которой index-only scan теряет смысл: ему приходится ходить в таблицу.
  • Замораживает старые кортежи, отодвигая угрозу transaction ID wraparound.
  • Попутно (через autovacuum) запускается ANALYZE, обновляющий статистику.

Обычный VACUUM берёт SHARE UPDATE EXCLUSIVE и не мешает SELECT/INSERT/UPDATE. VACUUM FULL устроен совсем иначе: переписывает таблицу в новый файл, возвращает место ОС, но берёт ACCESS EXCLUSIVE, то есть таблица недоступна полностью. На проде вместо него используют pg_repack.

Почему возникает bloat

  1. Долгая транзакция или idle in transaction удерживает горизонт, и VACUUM не имеет права удалять новые мёртвые версии.
  2. Забытый replication slot держит xmin бессрочно. Причина самая коварная: реплики уже может не быть, а слот остался.
  3. Дефолтный autovacuum_vacuum_scale_factor = 0.2 на большой таблице: уборка начнётся, когда мёртвых накопится 20 %.
  4. Autovacuum душат настройками (cost_delay, мало воркеров) - он не успевает за нагрузкой.
  5. Массовые UPDATE/DELETE одной транзакцией.
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum,
       pg_size_pretty(pg_total_relation_size(relid)) AS total
FROM   pg_stat_user_tables
ORDER  BY n_dead_tup DESC LIMIT 20;

ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02);  -- чистить чаще
ALTER TABLE orders SET (fillfactor = 85);                        -- больше HOT
Частая ошибка в ответе

«VACUUM освобождает место на диске» - неверно для обычного VACUUM. Он делает место переиспользуемым внутри файла; файл сожмётся, только если мусор оказался в самом конце. Вернуть место операционной системе может VACUUM FULL, pg_repack или пересоздание таблицы. Поэтому «удалил половину строк, а диск не освободился» не баг, а нормальное поведение.

Суть: WAL - журнал, куда изменение попадает до файлов данных. Он даёт восстановление после аварии, делает коммит дешёвым (последовательная запись вместо случайной) и служит потоком для репликации и PITR.

Правило упреждающей записи

Любое изменение сначала описывается записью в WAL, и только потом может быть сброшено в файл данных. Страницы меняются в shared_buffers и остаются «грязными»; COMMIT ждёт fsync журнала, а не страниц. Если сервер упадёт, при старте PostgreSQL проиграет WAL с последней контрольной точки и восстановит все закоммиченные изменения.

Почему это ещё и быстро

В WAL пишут последовательно, в один файл. Изменённые страницы сбрасываются случайной записью по всему файлу таблицы. Разница на порядок даже на SSD и на два порядка на HDD. Плюс несколько транзакций объединяют свои fsync в один (group commit). Так что WAL отвечает и за надёжность, и за производительность записи.

Checkpoint

Контрольная точка сбрасывает все грязные страницы на диск и записывает в журнал отметку «до этого места всё гарантированно в файлах данных». Она определяет, откуда начнётся восстановление. Настраивается через checkpoint_timeout и max_wal_size: чем реже контрольные точки, тем меньше нагрузка на диск в обычном режиме, но тем дольше recovery после аварии и больше объём WAL. checkpoint_completion_target размазывает запись во времени, чтобы не было всплеска I/O. Отдельно запомни full_page_writes: первая модификация страницы после контрольной точки пишет в WAL всю страницу. Так база защищается от частично записанных страниц, и отсюда же пики объёма WAL сразу после checkpoint.

Связь с репликацией и бэкапами

Физическая репликация буквально передаёт поток WAL на реплику, где он проигрывается. Логическая декодирует тот же WAL в логические изменения строк (wal_level = logical). PITR строится на базовой копии и архиве WAL и позволяет восстановиться на любой момент времени. Один механизм закрывает три задачи, и это хороший способ показать системное понимание.

synchronous_commit = off: что именно теряем

Теряются целые последние транзакции (до wal_writer_delay × 3, при значении по умолчанию - до ~600 мс), но база остаётся согласованной - не бывает «половина транзакции применилась». Поэтому настройка годится для потоков вроде метрик и логов, где потерять секунду данных не страшно, и категорически не годится для платежей. Настройку можно менять на уровне отдельной транзакции: SET LOCAL synchronous_commit = off.

Суть: механизм хранения значений, которые не влезают в страницу 8 КБ: сначала сжатие, потом вынос в отдельную служебную таблицу чанками, а в строке остаётся указатель.

Строка в PostgreSQL не может пересекать границу страницы, а страница - 8 КБ. Как только версия строки перестаёт влезать примерно в 2 КБ, включается TOAST: движок берёт самые крупные колонки переменной длины и сначала пытается их сжать (по умолчанию pglz, с PG 14 доступен lz4). Если этого мало - выносит значение в таблицу pg_toast.pg_toast_NNNN, нарезая на чанки примерно по 2 КБ, а в самой строке оставляет указатель. Одно TOAST-значение ограничено 1 ГБ.

Почему это важно знать на практике

  • Ленивое чтение. SELECT id, status по таблице с гигантским jsonb не читает TOAST вообще, а SELECT * вытащит и распакует всё. Это одна из самых конкретных причин не писать «звёздочку».
  • Место. pg_relation_size не включает TOAST, а pg_total_relation_size включает. В расхождении этих цифр обычно и кроется ответ на «куда делось место».
  • Обновления. Новая версия строки создаётся всегда, но неизменённые TOAST-чанки переиспользуются, а не копируются.
  • Стратегии хранения задаются на колонку: EXTENDED (сжимать и выносить, дефолт), EXTERNAL (только выносить - быстрее substring()), MAIN (сжимать, выносить в крайнем случае), PLAIN (ничего).
Связь с проектированием

Если в горячей OLTP-таблице лежит колонка с большими JSON-документами, её почти всегда стоит вынести в отдельную таблицу. Формально TOAST и так «выносит» её, но накладные расходы остаются: строка шире, страниц больше, любой SELECT * тянет всё, а обновления создают лишнюю работу. Если вынести её в таблицу 1:1, горячая часть станет узкой и плотной - больше строк на страницу, лучше попадание в кэш.

Суть: pg_stat_activity - что происходит прямо сейчас (кто висит, кто кого блокирует). pg_stat_statements - накопленная статистика по нормализованным запросам, сортировать по total_exec_time.

pg_stat_activity - здесь и сейчас

SELECT pid, usename, state,
       now() - xact_start  AS tx_age,
       now() - query_start AS query_age,
       wait_event_type, wait_event,
       pg_blocking_pids(pid) AS blocked_by,
       left(query, 90)
FROM   pg_stat_activity
WHERE  backend_type = 'client backend' AND state <> 'idle'
ORDER  BY tx_age DESC NULLS LAST;

Что смотреть: state = 'active' с большим query_age выдаёт тормозящие запросы, state = 'idle in transaction' показывает забытые транзакции, которые блокируют VACUUM, а непустой blocked_by означает цепочки блокировок; wait_event_type подскажет природу ожидания (Lock, IO, LWLock, Client). На аварийный случай есть pg_cancel_backend (отменить запрос) и pg_terminate_backend (убить соединение).

pg_stat_statements - накопленная картина

SELECT calls,
       round(total_exec_time::numeric, 1) AS total_ms,
       round(mean_exec_time::numeric, 2)  AS mean_ms,
       rows / GREATEST(calls, 1)          AS rows_per_call,
       shared_blks_read, shared_blks_hit,
       left(query, 100)
FROM   pg_stat_statements
ORDER  BY total_exec_time DESC
LIMIT  20;

Расширение требует shared_preload_libraries = 'pg_stat_statements' и рестарта. Оно нормализует запросы: константы заменяются на $1, поэтому миллион однотипных запросов схлопывается в одну строку с большим calls.

Как читать

  • Сортировать по total_exec_time, а не по среднему: запрос на 8 мс, вызываемый 40 тыс. раз в минуту, вреднее запроса на 3 секунды раз в час.
  • Огромный calls при крошечном mean - почти наверняка N+1 из ORM.
  • Большой rows_per_call значит, что запрос тащит в приложение лишнее.
  • Если shared_blks_read сравним с hit, данные не помещаются в кэш и чтение идёт с диска; повод посмотреть индексы или shared_buffers.
  • Большой stddev_exec_time - план нестабилен: разные параметры дают разные планы.
  • pg_stat_statements_reset() перед экспериментом, чтобы видеть эффект изменения чисто.
Чем добить

Рядом полезно назвать: auto_explain (автоматически пишет план запросов дольше порога прямо в лог - незаменим, когда медленный запрос воспроизводится только на проде), log_min_duration_statement, pg_stat_user_tables (seq_scan против idx_scan, мёртвые кортежи), pg_stat_user_indexes (неиспользуемые индексы) и pgBadger для разбора логов.

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

  • MVCC даёт читателям согласованный снимок без блокировки писателя, сохраняя старые версии строк.
  • VACUUM освобождает место для повторного использования, но обычно не уменьшает файл таблицы операционной системе.
  • Долгоживущие снимки могут удерживать старые версии нужными и мешать очистке.

Частые ошибки

  • Не считай UPDATE изменением строки на месте: он создаёт новую версию и может затронуть индексы.
  • Не отключай autovacuum как лечение роста таблицы: сначала выясни, почему очистка не успевает или не может удалить версии.
  • Не путай dead tuples с утечкой: важны темп их появления, очистка и удерживающие горизонты транзакций.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Что увидят два снимка

В PostgreSQL транзакция A начала SELECT, затем транзакция B обновила users.name и выполнила COMMIT. Какую версию увидит повторный SELECT в A при REPEATABLE READ, и почему старая версия пока нужна?

-- A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT name FROM users WHERE id = 7;
-- B
UPDATE users SET name = 'new' WHERE id = 7; COMMIT;
-- A
SELECT name FROM users WHERE id = 7;
Ответ и разбор

Транзакция A продолжит видеть версию строки из своего снимка REPEATABLE READ. Обновление B создало новую версию, но старая нужна активному снимку A, поэтому очистка не может безопасно убрать её до завершения A.

Код-ревью

2. Оцени совет про рост таблицы

Handler держит транзакцию REPEATABLE READ открытой, пока ждёт HTTP-ответа от внешнего сервиса. Autovacuum отстаёт, а команда предлагает выключить его и запускать VACUUM FULL ночью. Найди причину в показанном коде и оцени совет.

tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
if err != nil { return err }
defer tx.Rollback()
if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM orders").Scan(&count); err != nil { return err }
if err := partner.WaitForResponse(ctx); err != nil { return err }
return tx.Commit()
Ответ и разбор

Долгий REPEATABLE READ snapshot удерживает старые версии строк, нужные транзакции; autovacuum не может удалить их и dead tuples накапливаются. Не держи DB-транзакцию во время сетевого ожидания: прочитай нужные данные, закрой транзакцию, вызови партнёра, затем открой короткую транзакцию для записи результата с проверкой актуальности. Не отключай autovacuum; VACUUM FULL переписывает таблицу и требует сильной блокировки, поэтому его выбирают только после измерений и с отдельным окном обслуживания.

Применение

3. Найди удерживающий снимок

Autovacuum регулярно запускается, но число мёртвых версий растёт. Какие причины из механики MVCC стоит проверить в первую очередь?

Ответ и разбор

Проверь долгие транзакции и idle-in-transaction сессии, старые snapshot horizons, replication slots и задержавшуюся реплику. Они могут требовать старые версии даже при работающем autovacuum. Затем сопоставь темп UPDATE/DELETE с частотой и длительностью очистки и проверь, не мешают ли блокировки.

Применение

4. Оцени цену горячей колонки

Сервис часто обновляет поле last_seen в большой таблице, и оно включено в несколько индексов. Объясни, почему таблица и индексы могут быстро расти.

Ответ и разбор

Каждый UPDATE создаёт новую версию строки. Изменение индексируемого поля требует обновления соответствующих индексов и обычно мешает HOT update; старые версии и индексные записи остаются до очистки. Проверь, нужны ли все индексы, можно ли хранить частые отметки отдельно или обновлять их реже, и сравни стоимость записи с пользой индекса для запросов.