Learning Platform
Урок 14.01 · 24 мин
Продвинутый
WALLSNpg_walDurabilityRecovery

В предыдущих модулях мы научились читать EXPLAIN, побороли bloat и поняли MVCC. Но всё, что мы обсуждали, относилось к одному узлу и к данным, которые уже на диске. Главный вопрос, который мы ещё не задали: что произойдёт, если в момент UPDATE orders SET status = 'paid' упадёт питание? Где гарантия, что транзакция, которая успешно вернула COMMIT;, останется в БД после рестарта? И почему UPDATE на 1 миллион строк завершается за секунду, хотя для записи 8 KiB страницы на disk нужно много миллисекунд?

Ответ один и тот же: WAL — Write-Ahead Log. С него начинается весь модуль про репликацию, бэкапы и PITR, потому что они — производные.

Зачем вообще нужен журнал

Базовая проблема — fsync() на основные heap-файлы дорого. Если бы каждый COMMIT приводил к синхронной записи всех изменённых страниц на диск, пропускная способность БД была бы измеряемой в сотнях транзакций в секунду на современном SSD. На рандомных страницах — ещё хуже, потому что random writes по 8 KiB разбросаны по всему файлу.

WAL обходит это так:

  1. Любая модификация heap/index-страницы сначала пишется как короткая WAL-запись в последовательный лог (pg_wal/).
  2. WAL — это append-only поток. Запись идёт строго последовательно в конец текущего WAL-сегмента: HDD и SSD оба обожают sequential writes.
  3. При COMMIT мы fsync’аем только WAL — не основные heap-файлы. Это одна короткая запись и fsync() на конец одного файла.
  4. Основные heap/index страницы могут оставаться
    dirty
    в shared_buffers сколько угодно — мы знаем, что в случае краха восстановим их из WAL.

Это и есть Write-Ahead Logging: правило «сначала пиши в журнал, потом меняй данные». Точнее — LSN записи WAL должен попасть на диск до того, как страница с тем же изменением будет fsync’нута. Это правило формализовано в Postgres как WAL invariant и проверяется в XLogFlush() перед BufferSync().

Профит:

  • Durability соблюдается. После COMMIT; транзакция переживёт kill -9, OOM, отключение питания. Recovery после рестарта применит WAL-записи с момента последнего checkpoint и догонит до момента краха.
  • Throughput на запись радикально выше. Тысячи COMMIT в секунду — нормально, даже на одном диске.
  • Replication становится тривиальной. WAL — это уже сериализованный поток изменений. Отправь его на другой сервер, прикажи ему «replay» — получишь точную копию.

LSN: логический адрес в журнале

Каждой WAL-записи присвоен

LSN
. Это 64-битное смещение в байтах от начала всего WAL-потока этой инсталляции БД. Записывается в виде XXXXXXXX/YYYYYYYY — две 32-битных hex-части через слэш, например 0/1A2B3C4D. Старшие 32 бита — для значений > 4 GiB.

Главные свойства LSN:

  • Монотонно растёт. Каждая новая WAL-запись имеет LSN больше всех предыдущих. Никогда не повторяется и не сбрасывается.
  • Глобален в кластере. Один и тот же LSN — одна и та же позиция в журнале на primary и на всех replica (после того, как replica догонит).
  • Адресует байты. Разница LSN_b - LSN_a = сколько байт WAL было записано между этими событиями.

LSN — это «универсальная координата». PageHeader каждой 8 KiB страницы heap содержит поле pd_lsn — LSN последней WAL-записи, изменявшей эту страницу. Это WAL invariant в действии: при записи страницы на disk Postgres проверяет, что WAL до pd_lsn уже flushed на disk.

Доступ к LSN из SQL:

Текущая позиция WAL primary — куда сейчас пишутся новые записи. В реальной БД это число будет расти на каждый INSERT/UPDATE/DELETE; в pglite WAL имитируется, но функция возвращает осмысленное значение.

PostgreSQL

Сделаем фиктивную транзакцию и посмотрим, как LSN продвигается. В реальной БД разница покажет, сколько WAL произвёл этот INSERT (header + tuple data + опционально full-page image).

PostgreSQL

На реальной БД вы увидите, что lsn_after - lsn_before ≈ десятки KiB. В pglite WAL-имитация неточная, но сам синтаксис и поведение функций совпадают с настоящим Postgres.

Сегменты WAL: pg_wal и 16 MiB

WAL — это поток, но физически он разрезан на сегменты. Сегмент = один файл фиксированного размера в $PGDATA/pg_wal/. По умолчанию размер сегмента — 16 MiB (можно поменять только при initdb через --wal-segsize, в runtime — нельзя).

pg_wal: сегменты как файлы фиксированного размера

Имя сегмента из 24 hex-символов: timeline ID (8) + старшие 32 бита LSN (8) + младшие 32 бита, делённые на размер сегмента (8). Имена расположены в лексикографическом порядке — Postgres сортирует и применяет по очереди.

$PGDATA/pg_wal/директория со всеми WAL-сегментами
00000001000000000000000116 MiB, LSN 0/01000000..0/02000000
00000001000000000000000216 MiB, LSN 0/02000000..0/03000000
000000010000000000000003active, заполняется сейчас
......
archive_status/метки для archive_command (когда сегмент готов к архивации)

Когда активный сегмент заполняется до конца, Postgres переключается на следующий. Старый файл по умолчанию переиспользуется (rename’ится с новым именем и заполняется заново), а не удаляется — это экономит CPU и снижает фрагментацию ФС.

Имя сегмента: 24 hex-символа, разбитых на три части по 8 символов: TTTTTTTTLLLLLLLLSSSSSSSS. Здесь T — timeline ID (про timelines — в уроке про PITR), L — старшие 32 бита LSN, S — младшие. Поэтому простой ls pg_wal/ сразу показывает временной порядок: имена сортируются как обычные строки.

Соответствие LSN и имени файла можно получить функцией:

В какой WAL-сегмент попадает текущий LSN? Эта функция полезна, когда мониторишь archive_command или хочешь узнать, какой именно файл нужен replica для catch-up.

PostgreSQL

Структура WAL-записи

Каждая WAL-запись — это последовательность байт со строгой структурой:

Анатомия одной XLogRecord

Header фиксированного размера + переменное число block references + опциональные full-page images + main data. Минимум 24 байта; типичная INSERT-запись — 100-200 байт без FPI.

XLogRecord header24 байта: размер, xid, prev LSN, CRC, resource manager ID
Block references0..32: какие страницы трогаются
Full-page images (опц.)8 KiB на каждую первую модификацию после checkpoint
Main dataспецифичная для rmgr (heap_insert, btree_insert, ...)

Поле rmgr (resource manager) — кто умеет применить эту запись при recovery. Главные:

  • Heap / Heap2 — INSERT/UPDATE/DELETE кортежей, freeze, prune, vacuum cleanup.
  • Btree, Gin, Gist, SP-Gist, Brin, Hash — операции в соответствующих индексах.
  • Transaction — COMMIT, ABORT, SUBTRANS, prepared transactions.
  • XLog — служебные (XLOG_CHECKPOINT_ONLINE, XLOG_SWITCH).
  • Storage — CREATE/DROP relation, truncate.

Каждая запись содержит previous LSN — указатель на предыдущую WAL-запись той же транзакции. Это позволяет «прокрутить» транзакцию назад при abort и используется в recovery.

Запись WAL: wal_buffers и flush

WAL не сразу пишется на disk. Сначала запись попадает в wal_buffers — shared-memory область (по умолчанию авто-расчёт ~shared_buffers / 32, обычно 16-64 MiB). Из buffers он попадает в pg_wal/ через системные вызовы write() + fsync(). Это делает один из:

  • backend сам — при synchronous_commit = on, на момент COMMIT транзакция flush’ает свой WAL и ждёт OK от fsync.
  • walwriter — фоновый процесс, который раз в wal_writer_delay (200ms) flush’ает накопленный WAL. Это важно при synchronous_commit = off — там backend не ждёт flush на COMMIT, а полагается на walwriter.

synchronous_commit параметр на сессию:

  • on (default) — flush before COMMIT returns. Полный durability на этой ноде.
  • off — COMMIT возвращает OK сразу, walwriter flush’нет через ≤ 200ms. Можно потерять последние 200ms транзакций при crash.
  • local / remote_write / remote_apply — про репликацию, разберём в уроке 3.

Текущие параметры WAL и fsync. На production проверь, что synchronous_commit, fsync, wal_buffers — то, что ты хочешь.

PostgreSQL

fsync = off — нелегальный режим для production. Это снимает гарантию durability полностью: при crash база может прийти в inconsistent state. Допустимо только для бенчмарков и стейджа, где данные не жалко.

Сколько WAL производит реальная нагрузка

Это вопрос, который нужно задавать перед каждым capacity planning. Несколько ориентиров:

  • INSERT одной строки: ~100-200 байт WAL (header + tuple). На массовой загрузке без COPY — пишет в несколько раз больше, чем сами данные, из-за оверхедов.
  • UPDATE одной строки: ~200-400 байт. Если HOT update — без записи нового индекса. Если не HOT — плюс WAL для индекса.
  • Full-page image (FPI): при первой модификации страницы после checkpoint в WAL дописывается вся страница 8 KiB. Это защищает от torn-page (когда питание упало посреди записи 8 KiB и в файл попало 4 KiB старых + 4 KiB новых). FPI — главный источник WAL-amplification: один UPDATE может породить 8 KiB WAL вместо 200 байт.

Главный тюнинг здесь — wal_compression, который сжимает FPI’и (LZ4/zstd в 14+). И full_page_writes = off — но это допустимо только на FS с гарантированной атомарностью записи 8 KiB (то есть почти нигде).

WAL-amplification: почему запись в WAL может быть в 40x больше данных

Это один из самых неочевидных эффектов в продакшене. Простой UPDATE одной 100-байтной строки может породить:

  • При HOT update, страница не FPI: ~150 байт WAL. Норма.
  • При не-HOT update (новый ctid в индексе): ~150 байт heap WAL + ~100 байт index WAL = 250 байт.
  • Если страница ещё не имела FPI после checkpoint: добавляются 8192 байта FPI. Итого 8.4 KiB на 100 байт данных = ×84.

Реальные числа для типичного OLTP с checkpoint раз в 5 минут: WAL volume в 5-20 раз больше, чем чистый объём изменённых данных. Это закладывают в capacity planning для PITR-storage.

Главное смягчение — wal_compression. С PG14+ доступны LZ4 и zstd, которые сжимают FPI в 3-5 раз. CPU-overhead — несколько процентов на современных архитектурах. На большинстве workloads wal_compression = lz4 — почти бесплатное снижение WAL volume.

Куда WAL отдаёт силу

После того, как WAL-запись попала на диск, она используется в трёх местах:

  1. Crash recovery (следующий урок про checkpoints). При рестарте Postgres находит последний checkpoint, восстанавливает shared_buffers, и применяет WAL-записи по порядку до конца.
  2. Replication (физическая — уроки 3, 4). WAL-поток передаётся на standby, replica применяет его в режиме «recovery never ends».
  3. PITR (урок 5). Архив WAL + базовый backup позволяет воспроизвести состояние БД на любой LSN/timestamp.

Всё это работает за счёт одного свойства WAL: если у нас есть последовательность WAL-записей с момента T_0 + snapshot БД на T_0 — мы можем восстановить состояние БД на любой момент T_1 > T_0. Это и есть полная база теории.

Проверка знанийKnowledge check
У вас Postgres 15, default WAL segment size 16 MiB. За час OLTP-нагрузки в pg_wal/ появилось 32 сегмента (с учётом archive_status). Сколько байт WAL сгенерировано за этот час и какая средняя скорость записи WAL?
ОтветAnswer
32 сегмента x 16 MiB = 512 MiB суммарно. За час — это 512 MiB / 3600s ≈ 145 KiB/s в среднем. На SSD это очень скромно, но имей в виду: реальная скорость WAL имеет всплески (например, при первой модификации страниц после checkpoint появляются full-page images по 8 KiB каждая). Пиковая может быть в 10-20 раз выше средней. Чтобы посмотреть точно — снимай pg_current_wal_lsn() с интервалом 1s и считай diff через pg_wal_lsn_diff. На реальном сервере под нагрузкой типичные значения — 10-100 MiB/s.

Практическая интуиция: WAL throughput

Несколько мнемонических соотношений, которые полезно держать в голове:

  • 1 GiB WAL за час = очень лёгкая БД (десятки маленьких транзакций в секунду).
  • 10 GiB WAL за час = средний OLTP с тысячами TPS.
  • 100 GiB+ WAL за час = серьёзная нагрузка, имеет смысл tunable: wal_compression, увеличенный checkpoint_timeout, отдельный SSD под pg_wal/.
  • 1 TiB WAL за день = около 12 MiB/s sustained. На NVMe это 1% throughput, но если archive в S3 — затыкается network, и pg_wal/ копится.

Замер собственного WAL throughput — одна строка в крон:

SELECT
  pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0') AS total_bytes,
  now() AS ts;

Снимай раз в минуту в таблицу, считай diff — получишь точную скорость WAL. На основе этого выбирай размер архивного хранилища и retention.

Чек-лист

  • WAL (Write-Ahead Log) — append-only журнал всех изменений. Любая модификация heap/index сначала пишется в WAL, потом — в основной файл. Это даёт durability при дешёвых COMMIT.
  • LSN — 64-битный беззнаковый адрес в байтах от начала WAL-потока. Формат 0/1A2B3C4D. Монотонно растёт, глобален в кластере. Поле pd_lsn в PageHeader каждой страницы — LSN последней записи, изменявшей её.
  • WAL invariant: страница не fsync’ается на disk, пока WAL до её pd_lsn не flushed.
  • Сегменты — файлы в $PGDATA/pg_wal/, по умолчанию 16 MiB. Имя из 24 hex-символов: timeline + hi(LSN) + lo(LSN)/segsize. Старые сегменты переиспользуются.
  • Full-page image (FPI) — при первой записи страницы после checkpoint в WAL пишется вся 8 KiB страница. Главный источник WAL-amplification.
  • WAL — фундамент для crash recovery, replication и PITR. Этим займёмся в следующих уроках.
fsync и durability — когда write на самом деле записан на диск Архитектура Transaction Log в Delta Lake

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что такое LSN (Log Sequence Number) в PostgreSQL?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5