Как база данных переживает внезапное отключение питания посреди транзакции и не превращается в кашу? Ответ почти всегда один: write-ahead logging. Прежде чем менять сами данные, СУБД записывает намерение в журнал — и только журнал обязан оказаться на диске в момент коммита. Данные можно применить позже; если случился сбой, БД проиграет журнал и восстановит согласованное состояние. Эта статья разбирает механизм на живом стенде PostgreSQL 18.4: сколько реально весит журнал при insert и update, что происходит на диске в момент commit, как выглядит настоящее восстановление после SIGKILL, зачем нужен checkpoint и во что обходится выбор synchronous_commit.
Это первая, вводная статья серии «WAL и его аналоги». Дальше — аналоги в разных БД (статья #2) и применения: репликация, CDC, PITR (статья #3).
В статье
- Принцип write-ahead
- Durability и коммит
- Crash recovery: redo из журнала
- Checkpoints: усечение и переиспользование WAL
- fsync и group commit: цена и амортизация латентности
- Цена и выгода WAL
Принцип write-ahead
Идея write-ahead logging формулируется в одно предложение: прежде чем изменить страницу данных на диске, СУБД обязана записать в журнал (WAL — write-ahead log) описание этого изменения, и запись в журнал должна физически попасть на диск раньше, чем изменённая страница. Отсюда и название — журнал пишется впереди данных, не после них.
Зачем такая последовательность, а не наоборот? Страница данных в PostgreSQL — это блок 8 КБ, который может содержать десятки строк одной таблицы в буферном кеше. Если разрешить сбрасывать такие страницы на диск в произвольный момент, не дожидаясь гарантии в журнале, — сбой посреди самой записи страницы способен испортить её частично: половина строки обновлена, половина осталась старой, а физическая целостность самой страницы (контрольная сумма, служебные заголовки) может быть нарушена. Восстановить корректное состояние из такой полу-записанной страницы невозможно в принципе — в ней нет истории, только текущий (испорченный) снимок. WAL же — последовательный, append-only поток записей: «транзакция X вставила строку Y в таблицу Z» или «страница P изменилась вот так». По нему всегда можно либо доиграть изменение (redo), либо, зная, что транзакция не закоммитилась, просто не учитывать его. Атомарность и durability транзакции строятся именно на этом: пока в журнале есть полная, подтверждённая fsync-ом запись коммита — транзакция переживёт любой сбой процесса или ОС между этим моментом и следующим удачным стартом.
Реальные цифры показывают, что это не бесплатно — работает буквально «двойная запись». На стенде (PostgreSQL 18.4, wal_level=logical) вставка 1 000 000 строк (id bigserial + текстовое поле ~32 байта) породила 158 МБ WAL. Обновление всех этих же 1 000 000 строк (v = v || 'x') породило 242 МБ — почти в 1.53 раза больше, хотя логически изменение куда меньше по объёму данных, чем первоначальная вставка. Причина в одной особенности WAL: первое изменение каждой страницы после checkpoint дописывает в журнал не дельту, а полный образ страницы целиком (full-page image) — защита от того самого частичного повреждения страницы при сбое посреди записи. Обновление всех строк задевает впервые после checkpoint каждую уже существующую страницу таблицы — и каждая добавляет в WAL полный образ; вдобавок update по правилам MVCC пишет и старую, и новую версию строки и правит индексы. Insert же дописывает строки в свежие страницы подряд. На одинаковом числе строк эти два фактора и утяжеляют WAL апдейта относительно вставки. Ниже эта же механика — checkpoint и что происходит с WAL после него — разобрана подробно.
Durability и коммит
Момент, когда транзакция считается необратимо подтверждённой, — это не момент, когда изменились строки таблицы в памяти, а момент, когда запись о коммите этой транзакции физически долетела до диска в составе WAL и подтверждена fsync. Сами изменённые страницы данных (dirty pages) в этот момент почти наверняка ещё только в буферном кеше PostgreSQL — на диск их спишет позже фоновый процесс bgwriter (постепенно, малыми порциями, чтобы не создавать всплесков I/O) или ближайший checkpoint. Это разделение принципиально: приложение получает подтверждение COMMIT, как только гарантирован WAL, а не как только физически обновлена каждая страница таблицы и всех её индексов — иначе коммит был бы на порядки дороже.
Отсюда следует контринтуитивный, но важный факт: сразу после COMMIT файлы самой таблицы на диске вполне могут ещё не содержать новых данных вообще — они лежат только в WAL и в кеше памяти. Но это не проблема, а ровно то, ради чего WAL существует: если сервер упадёт прямо сейчас, при следующем старте PostgreSQL прочитает WAL и восстановит те самые страницы из журнала — durability обеспечена журналом, а не тем, успел ли bgwriter дописать страницы. Durability как буква ACID в PostgreSQL при настройке по умолчанию (synchronous_commit=on) — это, по сути, «WAL этой транзакции на диске». Гарантия ровно настолько сильна, насколько честен этот fsync: ниже мы увидим, как synchronous_commit=off сознательно ослабляет её ради скорости — коммит перестаёт ждать диск, и несколько последних транзакций могут потеряться при сбое.
Crash recovery: redo из журнала
Проверить это можно не на словах, а руками: на стенде запись 1000 строк была подтверждена (synchronous_commit=on), после чего процесс PostgreSQL в контейнере получил SIGKILL — жёсткое убийство без единого шанса корректно завершиться и сбросить буферы. count(*) до падения — 1000. После рестарта, без вмешательства, PostgreSQL сам обнаружил незавершённое состояние и запустил восстановление:
2026-07-06 16:42:23.782 UTC [32] LOG: database system was not properly shut down; automatic recovery in progress
2026-07-06 16:42:23.787 UTC [32] LOG: redo starts at 0/20000A0
2026-07-06 16:42:23.789 UTC [32] LOG: redo done at 0/2028908 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
После восстановления count(*) — снова 1000. Ни одна из 1000 подтверждённых строк не потеряна, несмотря на самый грубый из возможных сбоев. Это и есть redo в действии: PostgreSQL находит в WAL точку, с которой не гарантирована консистентность страниц на диске (последний checkpoint), и последовательно проигрывает все записи журнала после неё, заново применяя к страницам те изменения, которые в момент падения могли не долететь до файлов таблицы. Раз запись о коммите транзакции есть в WAL — значит, транзакция была подтверждена, и redo обязан её восстановить; если записи коммита нет (транзакция оборвалась на середине) — её изменения просто не будут доиграны, как будто их и не было. В данном прогоне сам redo занял меньше 0.01 секунды — потому что несброшенных изменений накопилось мало (1000 строк, тестовый объём). На проде интервал восстановления масштабируется пропорционально объёму изменений, не сброшенных на диск с последнего checkpoint, — отсюда прямая связь с тем, как часто выполняется checkpoint, о которой пойдёт речь дальше.
Архитектуры с undo-логом (например, у InnoDB) добавляют к этой картине ещё один шаг: помимо redo подтверждённых транзакций, движок откатывает (undo) изменения незавершённых транзакций, используя отдельный undo-журнал. PostgreSQL устроен иначе — благодаря MVCC незакоммиченные версии строк просто никогда не становятся видимыми и со временем убираются автовакуумом, отдельный undo-проход при восстановлении не требуется.
Checkpoints: усечение и переиспользование WAL
WAL не может расти бесконечно — иначе диск рано или поздно закончится, а redo при восстановлении пришлось бы проигрывать от начала времён. Решает это checkpoint: периодическая операция, которая сбрасывает на диск все текущие грязные страницы (dirty pages) буферного кеша и фиксирует, что всё, записанное в WAL до этой точки, уже гарантированно отражено в файлах данных. После успешного checkpoint журнал до этой точки больше не нужен для recovery — соответствующие сегменты WAL можно освободить.
«Освободить» на практике не всегда значит «немедленно удалить». На стенде число сегментов WAL после CHECKPOINT не сокращалось — оставалось в тех же пределах, что и до него (в характерном прогоне — несколько десятков сегментов по 16 МБ; точное число от прогона к прогону плавает на единицу-другую в зависимости от недавней записи). Это не баг демонстрации и не признак того, что checkpoint ничего не сделал: при checkpoint_timeout=30min и дефолтных min_wal_size/max_wal_size PostgreSQL держит пул уже выделенных сегментов и переиспользует (recycle) их под новые записи вместо того, чтобы физически удалять файл и создавать новый — создание файла на диске тоже стоит I/O, и recycling его экономит. Честная формулировка: checkpoint следит за тем, чтобы число WAL-сегментов не росло неограниченно, но само физическое усечение (unlink) — отдельно наблюдаемое поведение, зависящее от min_wal_size/max_wal_size и объёма недавней записи; в характерном прогоне видно именно стабильное число файлов в пределах пула, а не сокращение их количества.
Отсюда компромисс, который настраивается явно: частые checkpoints сокращают объём WAL, который придётся проигрывать при восстановлении (короче recovery), но создают больше всплесков I/O от массовой записи грязных страниц — реже — тяжелее один checkpoint, чаще — больше суммарной нагрузки на диск от самих checkpoint’ов. checkpoint_timeout и max_wal_size в PostgreSQL — это ровно ручки этого компромисса, а не произвольные тюнинговые параметры без физического смысла.
fsync и group commit: цена и амортизация латентности
fsync — системный вызов, которым PostgreSQL требует от ОС и диска гарантию: данные физически на энергонезависимом носителе, не просто в буфере ОС или кеше контроллера диска. Именно fsync записи коммита в WAL и есть тот самый момент, после которого транзакция необратимо подтверждена. Проблема в том, что fsync — одна из самых медленных операций, которые делает СУБД: даже на быстром NVMe это доли миллисекунды на операцию, но при высокой частоте коммитов это складывается в заметную долю общей латентности транзакции.
Group commit — стандартное решение: вместо fsync на каждый отдельный коммит, PostgreSQL умеет объединить несколько параллельно завершающихся транзакций в один fsync-вызов, если они подтверждаются достаточно близко по времени. Один fsync подтверждает сразу несколько транзакций — стоимость системного вызова амортизируется на всех участников группы. Это работает прозрачно под нагрузкой с параллельными подключениями и не требует ничего от кода приложения.
Явный рычаг, которым можно управлять этой ценой напрямую, — параметр synchronous_commit. on (по умолчанию) — коммит ждёт fsync локального WAL, и если настроена синхронная реплика — ещё и подтверждения от неё. off — коммит возвращает управление приложению сразу после записи в WAL-буфер, не дожидаясь fsync: транзакция может быть потеряна при падении сервера в узком окне до фактического сброса на диск (обычно доли секунды — управляется wal_writer_delay), но не может привести данные в противоречивое состояние — просто как будто коммита не было. local — промежуточный уровень: ждать локальный fsync, но не ждать подтверждения от синхронной реплики.
На стенде (pgbench -c 4 -j 2 -T 20 -r, PostgreSQL 18.4) разница между режимами измерена напрямую:
| synchronous_commit | tps | latency average |
|---|---|---|
| on | 1226.21 | 3.256 ms |
| local | 1215.59 | 3.284 ms |
| off | 4451.46 | 0.894 ms |
Разрыв между off и on — около 3.6× и по throughput, и по latency в обе стороны: off даёт почти в 3.6 раза больше транзакций в секунду и почти в 3.6 раза ниже среднюю задержку. Это дословно цена ожидания fsync на каждый коммит.
Важный честный нюанс: local здесь оказался практически неотличим от on (1215.59 tps / 3.284 ms против 1226.21 tps / 3.256 ms — разница в пределах шума прогона), а не «где-то посередине» между on и off, как можно было бы ожидать по названию. Причина — топология стенда: synchronous_standby_names пуст, синхронных реплик не подключено. on и local расходятся только тогда, когда есть подключённая синхронная реплика — on ждёт от неё подтверждения, local не ждёт. На single-node стенде без sync-реплик обоим режимам нечего ждать, кроме локального fsync, поэтому они и ведут себя одинаково. Абсолютные цифры прогона — характерный прогон на конкретном хосте (Docker Desktop, Windows, WSL2-бэкенд) и не претендуют на «у вас будет так же»; важны относительный разрыв on/off и сам факт «on ≈ local» на такой топологии.
Практический вывод: synchronous_commit=off — не «включить и забыть», а осознанный обмен возможной потерей последних нескольких коммитов при падении сервера на throughput и latency. Уместен там, где допустимо потерять последние доли секунды данных (метрики, логи, кеш-подобные записи), но не там, где каждая подтверждённая транзакция обязана пережить любой сбой (платежи, бухгалтерия).
Цена и выгода WAL
Итоговая бухгалтерия честная и двусторонняя. Цена — двойная запись: каждое изменение сначала попадает в WAL, потом — в страницы данных, а первое изменение страницы после checkpoint добавляет ещё и полный образ страницы. На стенде это конкретно 158 МБ WAL на 1M insert и 242 МБ на 1M update — write-amplification не абстракция, а измеримый расход диска и полосы I/O. Плюс латентность fsync на каждый коммит (или на каждую группу коммитов при group commit) — цена, которую можно частично снять synchronous_commit=off, но тогда назад приходит риск потери последних транзакций.
Выгода — то самое единственное свойство: пережить SIGKILL, отключение питания, панику ядра ОС без порчи и без потери подтверждённых данных, что и было продемонстрировано выше буквально — count 1000 до падения, count 1000 после. Но WAL — это не только про durability отдельного узла. Тот же журнал, будучи последовательным и полным описанием всех изменений, становится фундаментом для того, что раньше требовало бы отдельного механизма: физическая репликация — это буквально стриминг того же WAL на другой узел; point-in-time recovery — это доигрывание архивного WAL до нужного момента; логическая репликация и CDC — это декодирование того же потока в события уровня строк. Все три темы разбираются в третьей статье серии, «WAL на службе: репликация, CDC и PITR». А то, как разные СУБД (MySQL, MongoDB, SQLite, Redis) реализуют тот же принцип write-ahead по-своему — иногда одним журналом, иногда двумя с разными ролями — разбирает вторая статья, «WAL и аналоги в разных БД».
WAL — не единственный ответ на вопрос durability. В LSM-движках (RocksDB, Cassandra, ScyllaDB) журнал играет ту же роль подстраховки перед сбросом memtable на диск — механика разобрана в статье «Storage engines: LSM-tree против B-tree»Скоро. А то, как разные уровни изоляции транзакций сочетаются с этой же durability-гарантией commit’а — в статье «Транзакции в реляционных БД на практике» серии про транзакции, где SERIALIZABLE и SELECT FOR UPDATE разбираются на том же стенде PostgreSQL.
Источники
- PostgreSQL Documentation — Chapter 30. Reliability and the Write-Ahead Log, 30.5. WAL Configuration
- PostgreSQL Documentation — 20.5.4. Asynchronous Commit (
synchronous_commit) - Стенд:
digital-cookbook/databases/wal/postgres(PostgreSQL 18.4)
Комментарии