PostgreSQL изнутри

Что лежит в памяти и что на диске, зачем нужны WAL и VACUUM, как работают индексы, где база упирается в потолок и как сделать, чтобы она не теряла данные.

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

Процессы, память, диск

Postgres — это набор процессов ОС вокруг общей памяти. Каждое соединение — отдельный процесс. Все данные проходят через общий кэш страниц, а на диск попадают в двух местах: в журнал и в файлы данных.

Нажмите на элемент схемы, чтобы узнать, что он делает.

Сколько памяти и на что

  • shared_buffers — общий кэш. Начинают с 25% RAM: больше обычно не нужно, ведь остальное всё равно кэширует ОС.
  • work_mem выделяется на каждую сортировку и хеш в каждом запросе. 500 соединений × 64 МБ × несколько операций — и память кончилась.
  • Каждое соединение — процесс со своей памятью, даже когда простаивает.

Оценка худшего случая: все соединения одновременно выполняют запрос с двумя сортировками. В жизни так бывает редко — но именно тогда приходит OOM killer.

Страница и версии строк (MVCC)

Таблица — файл из страниц по 8 КБ. UPDATE не меняет строку на месте: он записывает новую версию, а старую помечает удалённой. У каждой версии есть xmin — кто создал — и xmax — кто удалил.

Так читатели не блокируют писателей: каждая транзакция видит версии, актуальные на момент её снимка. Откройте долгую транзакцию, сделайте несколько UPDATE и запустите VACUUM — старые версии он убрать не сможет.

VACUUM и раздувание таблиц

  • VACUUM находит мёртвые версии, которые уже никому не видны, и отмечает их место свободным в карте свободного места. Файл при этом не уменьшается.
  • Autovacuum запускается, когда мёртвых строк больше 50 + 20% таблицы. Для больших таблиц это поздно — порог настраивают для каждой таблицы отдельно.
  • Если чистка не успевает или её держит долгая транзакция, таблица разрастается. Вернуть место может только перезапись: VACUUM FULL (блокирует таблицу целиком) или pg_repack (почти без блокировки).

Переполнение счётчика транзакций

Номер транзакции 32-битный, а сравнивать «раньше — позже» можно только в окне ~2,1 млрд. Чтобы старые строки не оказались вдруг «из будущего», VACUUM замораживает их: помечает видимыми для всех.

Если заморозка не успевает — из-за выключенного автовакуума, долгих транзакций или забытого слота репликации, — база в итоге перестаёт принимать запись. Следите за age(datfrozenxid).

Буферный кэш

Любая страница сначала загружается в shared_buffers. Когда места нет, алгоритм «часы» ищет жертву: стрелка идёт по кругу, уменьшая счётчик использования, и вытесняет первую страницу со счётчиком 0. Популярные страницы держатся в памяти.

Пока рабочий набор помещается в память, почти все чтения — попадания. Увеличьте его — и база начнёт читать с диска. Большое последовательное чтение использует маленький кольцевой буфер, чтобы не выбить из кэша всё полезное.

WAL: сначала журнал, потом данные

Изменение страницы сначала описывается в журнале предзаписи (WAL). При COMMIT на диск сбрасывается только журнал — последовательная запись, быстро. Сами страницы данных остаются «грязными» в памяти и пишутся позже.

После сбоя Postgres берёт последнюю контрольную точку и проигрывает журнал. Выключите synchronous_commit — коммит станет быстрее, но при сбое можно потерять последние подтверждённые транзакции. Целостность базы при этом не пострадает.

Контрольные точки

  • Checkpoint сбрасывает все грязные страницы на диск. После него журнал до этой точки для восстановления не нужен.
  • Редкие контрольные точки: меньше записи, но дольше восстановление после сбоя.
  • Частые контрольные точки: больше записи. После каждой первое изменение страницы пишет в WAL её целиком (full page writes), и объём журнала растёт в разы.
  • Если контрольные точки случаются из-за max_wal_size, а не по времени, — его пора увеличить. Это видно в log_checkpoints.

# postgresql.conf — разумная отправная точка
checkpoint_timeout = 15min
max_wal_size = 16GB
checkpoint_completion_target = 0.9  # размазать запись
wal_compression = lz4

shared_buffers = 8GB          # ~25% RAM
effective_cache_size = 24GB   # shared_buffers + кэш ОС
work_mem = 32MB
maintenance_work_mem = 1GB
random_page_cost = 1.1        # для SSD

B-дерево

Индекс — отдельный файл из тех же страниц по 8 КБ. В листьях лежат ключи по порядку и ссылки на строки в таблице (ctid). Внутренние страницы направляют поиск. Листья связаны друг с другом, поэтому диапазоны читаются подряд.

Здесь в узле до трёх ключей. В настоящей странице их сотни, поэтому дерево над миллиардом строк — всего 4–5 уровней. Вставьте ключи и посмотрите, как узлы делятся.

Составной индекс: порядок колонок важен


CREATE INDEX ON orders (user_id, created_at);

Записи отсортированы сначала по user_id, внутри — по created_at. Индекс помогает, когда запрос сужает поиск слева направо.

Выберите запрос — подсветится, какую часть индекса придётся прочитать.

Какие бывают индексы

B-treeРавенство, диапазоны, сортировка, LIKE 'abc%'. По умолчанию и в 90% случаев.
GINjsonb, массивы, полнотекстовый поиск, pg_trgm для LIKE '%abc%'. Быстро ищет, медленнее обновляется.
GiSTГеоданные (PostGIS), диапазоны, поиск ближайших, ограничения-исключения.
BRINОгромные таблицы, где значение связано с физическим порядком, например время вставки. Индекс в тысячи раз меньше B-tree.
ЧастичныйWHERE status = 'active' — индексирует только нужные строки.
По выражениюON users (lower(email)) — для запросов с тем же выражением.
ПокрывающийINCLUDE (amount) — запрос берёт всё из индекса (index-only scan), если таблица недавно чистилась VACUUM.

Каждый индекс замедляет запись. Если UPDATE не трогает индексированные колонки и на странице есть место, Postgres делает HOT-обновление и индексы не трогает вовсе. Лишний индекс на часто обновляемой колонке это ломает.

Планировщик выбирает по стоимости

Для каждого плана Postgres оценивает стоимость: страницы последовательного и случайного чтения плюс обработка строк. Оценки строк берутся из статистики, которую собирает ANALYZE.

Индекс выгоден, когда запрос выбирает малую долю строк. На SSD случайное чтение почти так же дёшево, как последовательное: снизьте random_page_cost — и индекс выигрывает дольше. Неверная статистика — главная причина внезапно плохих планов.

Очередь блокировок: как миграция кладёт прод

ALTER TABLE берёт эксклюзивную блокировку. Пока идёт долгий запрос, ALTER ждёт — а все новые запросы к таблице выстраиваются в очередь за ним, хотя друг другу не мешают.


SET lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN note text;
-- не дождался — ошибка, повторить позже

Запустите долгий отчёт, затем ALTER. Потом повторите с lock_timeout.

Зачем PgBouncer

Соединение с Postgres — это процесс с памятью, даже когда он ничего не делает. А у приложения 20 подов по 50 соединений в пуле — и большинство из них простаивает.

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

Режимы пулера

session

Серверное соединение закреплено за клиентом до отключения. Совместимо со всем, но почти не экономит соединения.

transaction

Соединение выдаётся на время транзакции. Основной режим. Между транзакциями клиент может попасть в другой процесс, поэтому состояние сессии терять нельзя:

  • SET → SET LOCAL внутри транзакции
  • сессионные advisory-блокировки, LISTEN, временные таблицы — не работают
  • prepared statements — только с PgBouncer 1.21+ (max_prepared_statements)

statement

Соединение на каждый запрос. Транзакции из нескольких запросов запрещены. Нужен редко.

Альтернативы PgBouncer: Odyssey, PgCat, Supavisor. Пул внутри приложения всё равно нужен — он держит соединения до пулера.

Репликация

  • Физическая потоковая репликация: primary отправляет поток WAL, реплики его проигрывают. Реплика — точная копия всего кластера, только для чтения.
  • Асинхронная (по умолчанию): коммит не ждёт реплику. Быстро, но при падении primary последние транзакции могут не успеть доехать.
  • Синхронная: коммит ждёт подтверждения реплики. Ноль потерь — ценой задержки и риска встать, если реплика пропала. Ставьте кворум: ANY 1 (r1, r2).
  • Логическая репликация передаёт изменения отдельных таблиц и работает между версиями — ею делают обновления почти без простоя.

Фейловер без раздвоения мозга

Postgres сам не переключает мастера. Это делает Patroni: узлы договариваются через etcd (или Consul, ZooKeeper, Kubernetes). Мастер — тот, кто держит ключ лидера и продлевает его, пока жив.

Ключ не продлён за TTL — реплики выбирают самую свежую и повышают её. Мастер, отрезанный от etcd, сам переходит в режим только чтения: двух мастеров не будет. Вернувшийся узел догоняет нового лидера через pg_rewind. Клиенты находят мастера через HAProxy или target_session_attrs=read-write.

Бэкап и восстановление на момент времени

Физический бэкап — копия файлов кластера — плюс непрерывный архив WAL. Восстановление: развернуть последний бэкап до нужного момента и проиграть журнал до нужной секунды.


restore_command = 'pgbackrest archive-get %f "%p"'
recovery_target_time = '2026-10-03 14:59:00'
recovery_target_action = 'promote'

В субботу в 15:00 кто-то выполнил DROP TABLE. Подвиньте ползунок и выберите момент восстановления.

Бэкапы: правила

Делайте

  • Физический бэкап и архив WAL через pgBackRest, WAL-G или Barman: восстановление на любую секунду.
  • Копии в другом месте: другой дата-центр или облако, с шифрованием.
  • Регулярно и автоматически восстанавливайте бэкап на тестовый сервер. Непроверенный бэкап — не бэкап.
  • Следите за архивацией: если archive_command падает, pg_wal растёт, пока не кончится диск.
  • Отложенная реплика (recovery_min_apply_delay = '4h') быстро спасает от ошибки человека.

Не делайте

  • Не считайте реплику бэкапом: DROP TABLE доедет до неё за миллисекунды.
  • Не копируйте каталог данных работающей базы через cp или rsync — получится несогласованная копия.
  • Не полагайтесь только на pg_dump для большой базы: восстановление занимает часы, а всё между дампами теряется.
  • Не храните бэкапы на том же сервере или диске.

Где узкие места

Postgres упирается в несколько типичных потолков: соединения, диск, журнал, мусор от MVCC, блокировки и плохие планы. Запись идёт только через один primary — её масштабируют вертикально или разбиением данных.

Выберите симптом — увидите причину, как её проверить и что делать.

Что мониторить

  • Самые тяжёлые запросы по суммарному времени — pg_stat_statements.
  • Долгие транзакции и idle in transaction: они держат блокировки и мешают VACUUM.
  • Мёртвые строки и время последнего автовакуума по таблицам.
  • Отставание реплик, размер pg_wal и неактивные слоты репликации.
  • Возраст самой старой транзакции: age(datfrozenxid).

Страховка от зависших сессий: idle_in_transaction_session_timeout и statement_timeout.


-- самые тяжёлые запросы
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 10;

-- долгие и зависшие транзакции
SELECT pid, state, now() - xact_start AS age, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL ORDER BY age DESC;

-- мёртвые строки
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 10;

-- отставание реплик
SELECT application_name, replay_lag FROM pg_stat_replication;

Проверьте себя

Из большой таблицы удалили 90% строк через DELETE и дождались autovacuum. Что стало с размером файла на диске?

VACUUM отмечает место свободным для новых строк, но файл не сжимает: обрезаются только пустые страницы в самом конце. Чтобы вернуть место ОС, таблицу перезаписывают: VACUUM FULL (с эксклюзивной блокировкой) или pg_repack. Удалять много строк удобнее, если таблица разбита на партиции: старую партицию просто удаляют.

Проверьте себя

Сессия уже 6 часов в состоянии idle in transaction: открыла транзакцию, сделала запрос и ничего не делает. Чем это плохо?

Пока транзакция открыта, её снимок может понадобиться, и VACUUM не удаляет версии строк новее него — во всех таблицах. Таблицы раздуваются. Блокировки, которые транзакция успела взять, тоже не отпускаются. Лечится idle_in_transaction_session_timeout и исправлением кода, который забывает COMMIT.

Проверьте себя

У primary одна синхронная реплика: synchronous_standby_names = 'r1'. Реплика упала. Что будет с записью?

Синхронный коммит ждёт подтверждения от реплики, и Postgres честно ждёт. Поэтому синхронных реплик ставят минимум две с кворумом: ANY 1 (r1, r2) — коммит ждёт любую одну из них, и падение одной реплики запись не останавливает.

Главное

  • Память: shared_buffers — общий кэш, остальное кэширует ОС. work_mem умножается на число операций и соединений.
  • Надёжность держится на WAL: коммит — это fsync журнала, а не файлов данных.
  • MVCC оставляет мусор. Следите, чтобы autovacuum успевал, и не держите долгих транзакций.
  • Индекс помогает, когда запрос выбирает мало строк и условия совпадают с порядком колонок. Смотрите EXPLAIN (ANALYZE, BUFFERS).
  • Соединения дорогие — ставьте пулер. Миграции — с lock_timeout.
  • Реплика — для отказоустойчивости и чтения, фейловер — через Patroni, бэкап — физический с архивом WAL и регулярной проверкой восстановления.

К списку презентаций