6.3MVCC и внутренности PostgreSQL
Свяжи MVCC с повседневным поведением PostgreSQL: обновление создаёт новую версию строки, а старая остаётся видимой уже начатым снимкам. Из этого следуют работа autovacuum, влияние долгих транзакций и причины разрастания таблиц и индексов.
MVCC: чтение без блокировок
MVCC устроен так: вместо того чтобы менять строку на месте и блокировать читателей, создаём новую версию строки, а читатели продолжают видеть старую. Отсюда главное свойство PostgreSQL: читатели не блокируют писателей, писатели не блокируют читателей. Ждать приходится только писателю, который хочет изменить строку, уже изменяемую другим писателем.
Возьмём бухгалтерскую книгу. Проще всего обновить остаток, стерев старое число и вписав новое. Но тогда коллега, который прямо сейчас читает эту страницу, увидит полустёртое значение - значит, на время правки книгу придётся у него отобрать. Так и работают блокировочные СУБД: писатель заставляет читателей ждать.
PostgreSQL делает иначе: старую запись не стирает, а перечёркивает и дописывает новую рядом, пометив у обеих, с какого момента какая действует. Читатель, пришедший раньше, спокойно читает перечёркнутую - она никуда не делась и для его момента времени всё ещё верна. Это и есть MVCC: никто никого не ждёт, но в книге копятся перечёркнутые записи.
Где аналогия перестаёт работать: в бухгалтерской книге перечёркнутое остаётся навсегда, это и есть смысл книги. В базе перечёркнутое превращается в мусор, который надо периодически вычищать, иначе файл будет только расти. Уборке этого мусора отдана половина главы.
Теперь точнее. У каждого кортежа - то есть у каждой версии строки - в служебном заголовке лежат поля:
xminхранит идентификатор транзакции, которая создала эту версию;xmaxхранит идентификатор транзакции, которая её удалила или заменила (0, если версия ещё актуальна);ctidуказывает физический адрес версии: (номер страницы, номер строки в странице);cmin/cmaxнумеруют команды внутри транзакции, чтобы транзакция корректно видела собственные изменения.
xmin/xmax каждой версии со своим снапшотом и сама решает, какую
видеть. Платить приходится мёртвыми версиями, которые кто-то потом должен убрать.Что физически происходит при INSERT / UPDATE / DELETE
| Операция | Что делает движок | Что остаётся мусором |
|---|---|---|
INSERT | новая версия с xmin = мой xid, xmax = 0; записи во все индексы | ничего (если транзакция закоммичена) |
UPDATE | новая версия целиком + старой проставляется xmax; записи во все индексы таблицы, а не только в те, чьи колонки менялись (новая версия лежит по новому ctid) - если это не HOT-обновление | старая версия строки + старые индексные записи |
DELETE | только проставляется xmax; строка физически на месте | вся версия строки + все её индексные записи |
ROLLBACK | ничего не откатывается физически: транзакция помечается aborted | всё, что она успела записать |
«В PostgreSQL UPDATE - это по сути DELETE + INSERT: старая версия строки не меняется, а помечается устаревшей, и создаётся новая целиком, даже если изменилось одно поле из тридцати.» Из этой фразы выводится всё: почему обновление широкой строки дорогое, почему таблица растёт при «просто обновлениях», почему нужен VACUUM, почему счётчик, обновляемый 1000 раз в секунду, создаёт 1000 мёртвых версий в секунду, и почему такой счётчик лучше держать в Redis.
fillfactor ниже 100 (например 85) оставляет в странице место под будущие
версии и резко повышает долю HOT-обновлений на часто обновляемых таблицах.VACUUM, autovacuum и bloat
Мёртвой версией становится кортеж, которому уже проставили xmax закоммиченной
транзакции и который больше не нужен ни одному живому снапшоту. Сам он не исчезает:
UPDATE и DELETE в PostgreSQL физически ничего не стирают. Убирает
мёртвые версии VACUUM, и только тогда, когда версия перестала быть нужна
самому старому активному снапшоту во всей базе. Отсюда прямая связь «долгая транзакция →
VACUUM не может ничего почистить → таблицы пухнут»: одна забытая открытая транзакция копит
мусор по всей базе, а не только в тех таблицах, которых она касалась.
Место, занятое такими версиями, и называют bloat: из-за него таблица с 1 ГБ полезных данных запросто занимает 10 ГБ на диске - данные на месте, просто между ними дыры от мёртвых версий. Почти вся эксплуатационная головная боль PostgreSQL сводится к одному вопросу: успевает ли VACUUM за темпом появления мусора.
Попутно VACUUM обновляет карту видимости: по два бита на каждую страницу таблицы: «в этой странице все версии видны всем и мёртвых нет» и «все версии заморожены». Карта понадобится в главе про индексы: с ней ответ можно отдать прямо из индекса, вообще не заглядывая в таблицу.
| Форма | Что делает | Блокировка | Возвращает место ОС |
|---|---|---|---|
VACUUM | помечает место мёртвых версий свободным для переиспользования, чистит индексы, обновляет visibility map | SHARE 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).
Таблица на 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);
Идентификатор транзакции 32-битный, то есть счётчик обходит круг примерно каждые 4 млрд
транзакций. Сравнение xid кольцевое: «половина круга назад - прошлое, половина вперёд -
будущее». Чтобы очень старые строки не оказались «в будущем» и не исчезли, VACUUM их
замораживает. Если заморозка отстаёт, PostgreSQL
сначала предупреждает в логе, затем переходит в аварийный режим и отказывается принимать
новые транзакции, пока не выполнишь VACUUM вручную. Мониторить надо
age(datfrozenxid) по базам - классический вопрос уровня «а ты эксплуатировал
PostgreSQL на самом деле?». По таблицам то же показывает age(relfrozenxid), а 64-битные xid до сих пор остаются мечтой.
WAL: журнал упреждающей записи
WAL ведётся в отдельном файле, куда база дописывает короткие заметки вида «в такой-то странице такие-то байты стали такими». Слово «упреждающей» означает порядок: заметка в журнал пишется раньше, чем изменение доходит до файлов данных. Никогда не наоборот.
Зачем такой крюк. Изменённые страницы разбросаны по огромному файлу - сбрасывать их на диск
сразу значит писать в случайные места, а это медленно даже на SSD. Журнал же пишется
подряд, в конец файла, а дешевле записи не бывает. Поэтому база
делает так: страницы правит в памяти (shared_buffers) и оставляет «грязными», а на диск гарантированно кладёт только
короткую запись в журнал. Клиенту «COMMIT прошёл» отвечают после fsync журнала и до записи самих страниц.
Отсюда и живучесть: если сервер выключат по питанию, грязные страницы из памяти пропадут, но журнал на диске цел. При старте база прочитает его и повторно применит все заметки после последней контрольной точки - это называется восстановлением по журналу. Тот же журнал даёт репликацию (реплика просто непрерывно применяет чужой WAL) и восстановление на момент времени (PITR).
synchronous_commit, разменяв последние сотни миллисекунд транзакций на пропускную способность, а таблицу, которую не жалко потерять, сделать UNLOGGED: она пишется мимо 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 | нет | нет | типы фиксированной длины |
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(); -- обнулить перед экспериментом
Запрос на 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) не менялись.Пошагово
- Движок находит текущую версию строки и берёт на неё блокировку.
- Формирует новую версию целиком - со всеми колонками, даже неизменёнными.
- Пишет её в ту же страницу, если есть место, иначе в другую.
- Старой версии проставляет
xmax= мой xid и связывает еёctidс новой. - Добавляет записи во все индексы, если это не HOT-обновление.
- Пишет всё это в WAL (а после контрольной точки - ещё и полную страницу целиком).
Отсюда неочевидные следствия: обновление одного boolean в строке с
jsonb на 500 КБ создаёт новую версию строки (правда, неизменённые
TOAST-чанки переиспользуются); таблица, где 1000 раз в секунду инкрементируется счётчик,
растёт со скоростью 1000 мёртвых версий в секунду; ROLLBACK ничего не
откатывает физически - записанные версии просто остаются мусором.
HOT (Heap-Only Tuple)
Если новая версия помещается в ту же страницу и ни одна проиндексированная колонка не изменилась, PostgreSQL не трогает индексы вообще: индексная запись продолжает указывать на старую версию, а внутри страницы выстраивается HOT-цепочка, по которой движок доходит до актуальной. Так получается меньше записи в WAL, индексы не пухнут, а очистка цепочки может произойти прямо при обращении к странице, без полноценного VACUUM. С PG 16 HOT не ломается и при изменении колонок, на которых есть только BRIN-индекс: обновится лишь его сводка.
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, битовую карту «в этой странице все версии видны всем», без которой index-only scan теряет смысл: ему приходится ходить в таблицу.
- Замораживает старые кортежи, отодвигая угрозу transaction ID wraparound.
- Попутно (через autovacuum) запускается
ANALYZE, обновляющий статистику.
Обычный VACUUM берёт SHARE UPDATE EXCLUSIVE и не мешает
SELECT/INSERT/UPDATE. VACUUM FULL
устроен совсем иначе: переписывает таблицу в новый файл, возвращает место ОС, но берёт
ACCESS EXCLUSIVE, то есть таблица недоступна полностью. На проде вместо него
используют pg_repack.
Почему возникает bloat
- Долгая транзакция или
idle in transactionудерживает горизонт, и VACUUM не имеет права удалять новые мёртвые версии. - Забытый replication slot держит
xminбессрочно. Причина самая коварная: реплики уже может не быть, а слот остался. - Дефолтный
autovacuum_vacuum_scale_factor = 0.2на большой таблице: уборка начнётся, когда мёртвых накопится 20 %. - Autovacuum душат настройками (
cost_delay, мало воркеров) - он не успевает за нагрузкой. - Массовые
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, и только потом может быть сброшено в
файл данных. Страницы меняются в 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 и позволяет
восстановиться на любой момент времени. Один механизм закрывает три задачи, и это хороший
способ показать системное понимание.
Теряются целые последние транзакции (до wal_writer_delay × 3, при значении по умолчанию - до ~600 мс), но база остаётся согласованной - не бывает «половина транзакции
применилась». Поэтому настройка годится для потоков вроде метрик и логов, где
потерять секунду данных не страшно, и категорически не годится для платежей.
Настройку можно менять на уровне отдельной транзакции: SET LOCAL
synchronous_commit = off.
Строка в 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; старые версии и индексные записи остаются до очистки. Проверь, нужны ли все индексы, можно ли хранить частые отметки отдельно или обновлять их реже, и сравни стоимость записи с пользой индекса для запросов.