MAATRIX / Блог / Что такое WAL в базе данных и зачем писать всё дважды

Что такое WAL в базе данных и зачем писать всё дважды

MAATRIX

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

Почему нельзя просто менять страницу данных сразу

Таблица в PostgreSQL физически хранится как набор файлов, разбитых на страницы фиксированного размера (по умолчанию 8 КБ). Когда вы делаете UPDATE одной строки, эта строка лежит где-то внутри одной такой страницы, а сама страница — в произвольном месте файла на диске. Казалось бы, самый прямой путь: найти нужную страницу, поменять байты, записать её обратно. Именно так когда-то и работали простые СУБД, и именно с этим подходом связаны две неприятности.

Первая — надёжность. Если сервер выключился (или упал) ровно в момент записи страницы на диск, вы можете получить страницу, записанную наполовину: часть блока — новые данные, часть — старые, вперемешку. Файловая система не гарантирует атомарность записи блока произвольного размера в произвольное место файла, особенно если размер записи не совпадает с размером сектора диска. Восстановить целостность такой «порванной» страницы без дополнительной информации невозможно — вы просто не знаете, что должно было там оказаться.

Вторая — скорость. Изменения в реальной нагрузке разбросаны по всей таблице почти случайно: одна транзакция трогает страницу №4, следующая — страницу №95000, третья — снова страницу №4. Для вращающегося диска это означает постоянные перемещения головки, для SSD — менее катастрофично, но всё равно менее эффективно, чем последовательная запись. Если бы каждая транзакция сразу же гарантированно записывала на диск страницу данных, база захлёбывалась бы на случайных операциях ввода-вывода ещё до того, как вы упёрлись бы в процессор или память. Подробнее о том, почему это так критично именно для дисковой подсистемы, разобрано в статье про важность скорости диска для баз данных.

Как это устроено в PostgreSQL: сначала журнал, потом данные

WAL — Write-Ahead Log, журнал предзаписи. Идея в одном предложении: прежде чем менять страницу данных, PostgreSQL записывает в отдельный последовательный журнал запись о том, что именно должно измениться, — и только после этого (не обязательно сразу) применяет изменение к самой странице в памяти и, позже, на диске.

Механика по шагам при обычном UPDATE:

  1. Транзакция находит нужную страницу таблицы в буферном кэше (shared_buffers) — если её там нет, читает с диска.
  2. Формируется WAL-запись: она описывает изменение компактно (что-то вроде «в такой-то странице по такому-то смещению заменить старое значение на новое»), а не хранит копию всей страницы целиком (кроме особого случая full-page write, о нём ниже).
  3. Эта WAL-запись дописывается в конец текущего файла журнала — строго последовательно, всегда в конец. Именно последовательность и делает эту запись дешёвой: диску не нужно никуда «прыгать».
  4. Транзакция коммитится только после того, как соответствующая WAL-запись физически подтверждена на диске (fsync) — это гарантия durability, буквы D в ACID.
  5. Страница в буферном кэше помечается «грязной» (dirty) и обновляется в памяти сразу, но на диск в файл самой таблицы она пока не пишется. Это может произойти через секунды, а может — через часы, в зависимости от нагрузки.

Файлы журнала лежат в подкаталоге pg_wal внутри PGDATA и по умолчанию режутся на сегменты по 16 МБ каждый — это управляемо через параметр --wal-segsize при инициализации кластера, но на уже работающей базе не меняется без переинициализации. Посмотреть текущую позицию записи в журнале можно так:

SELECT pg_current_wal_lsn();
SELECT pg_current_wal_insert_lsn();

LSN (Log Sequence Number) — это, по сути, «адрес» в журнале: монотонно растущее число, которое однозначно определяет позицию записи. Именно LSN потом используется, чтобы понять, насколько реплика отстала от мастера, или докуда «доехало» восстановление после сбоя.

Отдельно стоит full-page write: при первом изменении страницы после checkpoint PostgreSQL на всякий случай пишет в WAL не только описание изменения, но и копию всей страницы целиком. Это защита от того же «порванного» блока — если страница будет повреждена при записи, у вас есть её полный снимок в журнале, из которого можно восстановиться. Это увеличивает объём WAL сразу после каждого checkpoint, и это нормальное поведение, а не утечка.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Checkpoint: когда изменения долетают до файлов таблиц

Раз страницы данных обновляются не сразу, значит, рано или поздно они всё равно должны попасть на диск — иначе накопленные в памяти «грязные» страницы будут расти бесконечно, а WAL, который их описывает, нельзя будет удалить (мало ли, вдруг он ещё понадобится для восстановления). Этот процесс называется checkpoint.

Checkpoint — это точка, в которой PostgreSQL гарантирует: все страницы, изменённые до этого момента, реально записаны в файлы данных на диске. После успешного checkpoint журнальные сегменты, относящиеся к более старым изменениям, можно переиспользовать или удалить — они больше не нужны для восстановления после сбоя (но могут ещё понадобиться для архивации или репликации, о чём ниже).

Checkpoint запускается по двум условиям — по времени и по объёму накопленного WAL:

checkpoint_timeout = 5min     # не реже, чем раз в 5 минут (по умолчанию)
max_wal_size = 1GB            # не позже, чем накопится этот объём WAL

Частые checkpoint'ы — это ровный, предсказуемый ввод-вывод небольшими порциями, но накладные расходы на full-page write случаются чаще. Редкие checkpoint'ы — меньше накладных расходов на запись, но каждый checkpoint превращается в заметный всплеск дисковой нагрузки, который может ощущаться как временное «подвисание» базы. Баланс между этими двумя крайностями — одна из типичных задач тюнинга конкретного сервера под конкретную нагрузку.

Восстановление после сбоя: WAL как страховка

Вот ради чего всё это затевалось. Представьте: сервер внезапно перезагрузился (пропало питание, убило процесс OOM killer'ом, что угодно) в тот момент, когда часть «грязных» страниц уже была изменена в памяти, но ещё не долетела до диска. При следующем запуске PostgreSQL файлы данных на диске находятся в состоянии «на момент последнего checkpoint», то есть отстают от реальности.

Дальше запускается crash recovery: PostgreSQL читает WAL начиная с позиции последнего успешного checkpoint и последовательно проигрывает («replay») все записанные в нём изменения поверх файлов данных — ровно то же самое действие, которое должно было произойти в обычном режиме работы, просто выполненное сейчас, при старте. Поскольку каждая WAL-запись коммитилась на диск синхронно до подтверждения транзакции клиенту, ни одна подтверждённая транзакция при этом не теряется: если клиент получил COMMIT, значит, соответствующая запись уже физически на диске в журнале, и восстановление её проиграет.

Увидеть, что база находится в процессе восстановления, можно в логе — там будут строки вида redo starts at ... и redo done at .... Для проверки самой логики (без реального сбоя) можно посмотреть содержимое сегментов журнала утилитой pg_waldump:

pg_waldump /var/lib/postgresql/16/main/pg_wal/000000010000000000000005

Она покажет человекочитаемый список записей: какая транзакция, какая операция, какая страница затронута. Это полезно и для отладки, и просто для понимания, что WAL — не чёрный ящик, а вполне читаемая последовательность событий.

Важный нюанс: WAL защищает от потери подтверждённых транзакций, но не превращает диск в бессмертный. Если физически поврежден сам сегмент журнала (например, отказал диск, на котором лежит pg_wal, ещё до того, как содержимое было где-то продублировано), восстанавливать будет нечего. Поэтому WAL — это инструмент durability на уровне «упал процесс / выключилось питание», а не замена резервному копированию и репликации на отдельный физический носитель. Про то, как выстроить резервное копирование поверх этого, — в статье про восстановление базы данных из бэкапа на практике.

Тот же журнал кормит репликацию

Второе применение WAL, которое логично вытекает из первого: если журнал полностью описывает все изменения базы в строгом порядке, его можно передать на другой сервер и там проиграть — и получить точную копию базы с небольшим отставанием. Это и есть физическая репликация в PostgreSQL (streaming replication).

Мастер отправляет реплике поток WAL-записей почти в реальном времени, реплика применяет их к своей копии данных — по сути, выполняет тот же самый redo, что и при восстановлении после сбоя, только непрерывно, а не разово при старте. Отсюда и требование wal_level = replica (или выше) — на уровне minimal PostgreSQL пишет в WAL меньше информации, ровно столько, сколько нужно для восстановления после сбоя на этом же сервере, и этого недостаточно для того, чтобы реплика могла воссоздать по журналу консистентное состояние.

SHOW wal_level;
SELECT client_addr, state, sent_lsn, replay_lsn,
       replay_lsn - sent_lsn AS lag_bytes
FROM pg_stat_replication;

Разница между sent_lsn (что мастер уже отправил) и replay_lsn (что реплика уже применила) — это и есть репликационное отставание, выраженное в байтах WAL, а не в секундах напрямую (секунды можно оценить через pg_stat_replication.replay_lag, если реплика присылает временные метки). Развёртывание такой связки по шагам — в статье про настройку репликации PostgreSQL на VPS.

Здесь же встречается механизм replication slot — способ мастера «помнить», что вот эта конкретная реплика ещё не забрала WAL до такой-то позиции, и поэтому нельзя удалять журнальные сегменты старше этой позиции, даже если локальный checkpoint уже случился и с точки зрения самого мастера они не нужны.

Практика: почему растёт WAL и где смотреть его размер

Именно из связки «checkpoint освобождает WAL, но кое-кто ещё может его ждать» и рождается самая частая практическая проблема: каталог pg_wal растёт и не думает уменьшаться. Причины на практике сводятся к нескольким:

  • Отстающая или отключённая реплика с активным replication slot. Мастер обязан хранить WAL для этого слота, пока слот не удалён или реплика не догонит его. Если реплика упала и не поднимается, а слот остался — WAL будет копиться до заполнения диска.
  • Архивация не успевает или падает с ошибкой. Если настроен archive_command (для WAL-архивов, например, под pgBackRest или под ручной PITR), а команда архивации регулярно завершается с ошибкой, PostgreSQL не имеет права удалить неотправленный сегмент — он должен сохранить его до успешной архивации.
  • Долгая транзакция или отложенный checkpoint. Продолжительная транзакция может удерживать необходимость хранить старые сегменты; кроме того, слишком редкие checkpoint (большой max_wal_size, большой checkpoint_timeout) сами по себе означают, что WAL естественно накапливается между checkpoint'ами, прежде чем часть его становится ненужной.

Проверить размер каталога и то, что там лежит:

du -sh /var/lib/postgresql/16/main/pg_wal
ls -la /var/lib/postgresql/16/main/pg_wal | head -20

Проверить активные слоты репликации и их отставание — это первое, что стоит смотреть, если WAL растёт неограниченно:

SELECT slot_name, active, restart_lsn,
       pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS retained_bytes
FROM pg_replication_slots;

Если active = false, а retained_bytes большой и растёт — это неактивный слот, который держит журнал впустую, и его стоит либо реанимировать реплику, либо удалить слот (SELECT pg_drop_replication_slot('имя_слота');), если реплика больше не нужна. Не выдумываю конкретных пороговых цифр, при которых «пора бить тревогу», — это сильно зависит от объёма диска и профиля нагрузки конкретного сервера; ориентируйтесь на тренд роста, а не на абсолютное число мегабайт. Разбор конкретно этой ситуации по шагам, с командами диагностики и исправления, — в отдельной статье про рост pg_wal, его причины и решение.

Ещё один параметр, о котором стоит знать: wal_keep_size (в старых версиях — wal_keep_segments) задаёт, сколько WAL хранить про запас для реплик без слотов, даже без учёта checkpoint. Он полезен как дополнительная защита, но не отменяет проблему: если реплика отстаёт дольше, чем позволяет этот запас, она просто больше не сможет догнать мастер обычным streaming-путём и потребует пересоздания с нуля (или подтягивания недостающих сегментов из архива, если он настроен).

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

WAL — это то же самое, что binary log в MySQL?

Идея очень похожая (последовательный журнал изменений, который используется для восстановления и репликации), но детали формата и поведения различаются между СУБД — не переносите команды и параметры один в один.

Можно ли просто удалить старые файлы из pg_wal вручную, если не хватает места?

Нет, не трогайте этот каталог руками. PostgreSQL сам решает, какие сегменты уже не нужны, и удаляет их при checkpoint или при продвижении replication slot; ручное удаление активного сегмента может сломать базу так, что она не запустится.

WAL замедляет запись, раз пишется дважды?

Нет — в сумме дисковых операций действительно происходит две записи (в журнал и позже в файл данных), но запись в WAL последовательная и дешёвая, а запись в данные откладывается и группируется checkpoint'ом, так что суммарно это быстрее, чем синхронно писать каждую случайную страницу сразу.

Что произойдёт, если полностью отключить WAL?

PostgreSQL не даёт полностью отключить WAL для обычных таблиц — это основа durability движка. Существуют временные (UNLOGGED) таблицы, для которых WAL не пишется вовсе, но ценой этого является то, что после сбоя они гарантированно очищаются, а не восстанавливаются.

Нужно ли включать wal_level = replica, если репликация не используется?

Если она не планируется даже в будущем и не нужны инструменты вроде pg_basebackup с WAL-архивом, можно оставить minimal — WAL будет чуть компактнее. Но большинство реальных инсталляций держат replica, потому что репликацию или PITR-бэкап часто добавляют позже, а понижать уровень несложно, а вот включать «на лету» без перезапуска — нет.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →