WAL и аналоги в разных БД

Один принцип write-ahead, разные реализации: PostgreSQL WAL, MySQL redo log плюс binlog (две роли), MongoDB journal и oplog, Tarantool WAL и snapshot, SQLite WAL-mode, Redis AOF и WAL в LSM-движках — что общего и чем различаются

Write-ahead logging — не изобретение PostgreSQL, а общий приём, который повторяет практически любая СУБД, где важна durability. Но «повторяет принцип» не значит «повторяет реализацию один в один»: одни базы обходятся одним журналом на все задачи, другие держат два разных журнала для двух разных ролей, третьи вообще используют журнал операций, а не журнал байтовых изменений страниц. Разница не декоративная — от неё зависит, что именно можно прочитать из журнала (только для восстановления после падения или ещё и для репликации), можно ли отдать его наружу как поток изменений и что произойдёт при повторном применении одной и той же записи. Эта статья проходится по нескольким БД — MySQL, MongoDB, Tarantool, SQLite, Redis, LSM-движкам — и сравнивает их аналоги WAL с PostgreSQL, который уже разобран в первой статье серии.

Это вторая статья серии «WAL и его аналоги». Опирается на механизм из первой: что такое write-ahead в принципе, зачем нужен fsync и что показывает crash recovery руками. Здесь предполагается, что читатель это уже видел, и разговор идёт о том, что меняется от базы к базе.

Одна перфолента-журнал протянута через шеренгу разных машин — шкаф-СУБД, катушки, спираль, микросхему и куб-хранилище: один принцип write-ahead, разные реализации от базы к базе

В статье

Один принцип, разные реализации

Формулировка, общая для всех: прежде чем изменить долговременное состояние, запиши намерение в последовательный (append-only) журнал, и только после того, как журнал гарантированно на диске, подтверждай операцию клиенту. Дальше начинаются развилки, и их по факту три.

Первая развилка — физический журнал против логического. Физический журнал описывает изменения на уровне байтов страницы («страница P, смещение O, новое значение V») — так устроены и PostgreSQL WAL, и MySQL redo log. Логический журнал описывает изменения на уровне операций («вставить строку с такими значениями», «обновить документ с таким _id») — так устроены MySQL binlog в ROW-формате, MongoDB oplog. Физический журнал компактен и быстро применяется тем же движком, который его написал, но почти бесполезен для другой версии или другого движка — сериализация внутреннего формата страницы завязана на конкретную реализацию. Логический журнал переносим между версиями и даже системами (это и есть основа CDC — предмет статьи #3), но обычно тяжелее и не годится напрямую для физического redo движка хранения.

Вторая развилка — один журнал на всё или два журнала с разными ролями. PostgreSQL, MongoDB (в связке journal+oplog можно спорить, но по сути) и Tarantool склоняются к экономной модели: один поток записи, который решает и локальную durability, и (иногда с доработкой) служит источником для репликации. MySQL — крайний случай разделения: redo log и binlog — это два физически разных файла, пишущихся в разные моменты транзакции, с разным форматом и разным потребителем, и именно это разбирается во врезке ниже.

Третья развилка — сколько записей нужно на одну логическую операцию — идемпотентна ли повторная доигровка записи журнала. Физический redo обычно идемпотентен по построению (перезапись той же страницы тем же значением ничего не портит), а вот логическая операция «увеличить счётчик на 1» идемпотентной не является — если её применить дважды, результат будет другим. MongoDB oplog решает это явно: каждая запись oplog хранит уже разрешённое, идемпотентное представление операции (не «increment», а «установить конкретное значение»), чтобы повторное применение при пересинхронизации реплики не задваивало эффект. Это станет важным в статье #3, где обсуждается идемпотентность потребителя CDC.

PostgreSQL WAL: краткая сводка

Коротко, без повтора того, что подробно разобрано в статье #1: PostgreSQL использует один физический WAL и для crash recovery, и — при wal_level=replica/logical — как основу для потоковой и логической репликации. Тот же поток байтовых изменений страниц, который проигрывается при restart после падения, стримится на реплику при физической репликации и декодируется в логические изменения (pgoutput и подобные плагины) при логической. Экономия очевидна: не нужно поддерживать второй журнал специально для репликации, платится цена лишь тем, что физический формат жёстко завязан на версию и внутреннее устройство страниц PostgreSQL. Как этот же WAL используется для физической и логической репликации, CDC и point-in-time recovery — тема статьи #3. А что именно гарантирует COMMIT поверх этого журнала и как durability сочетается с уровнями изоляции — на том же стенде PostgreSQL разбирает статья «Транзакции в реляционных БД на практике».

Врезка: MySQL — redo log и binlog, две роли

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

Redo log — журнал уровня storage engine (InnoDB), чисто физический: описывает изменения строк на страницах InnoDB, нужен только для crash recovery самого движка и никогда не покидает пределы InnoDB — ни репликация, ни внешние консьюмеры его не читают. На стенде (mysql:8.4, фактически mysqld 8.4.10, MySQL Community Server GPL, LTS) redo log виден по пути /var/lib/mysql/#innodb_redo/:

#ib_redo10_tmp … #ib_redo40_tmp   (31 предаллоцированный «спящий» файл, по 3 276 800 байт)
#ib_redo9                          (1 активный файл без суффикса _tmp, 3 276 800 байт)

Важный нюанс формата: начиная с 8.0.30 (и в 8.4 — тоже) InnoDB перешёл на пул предаллоцированных файлов #ib_redoNN_tmp плюс один активно пишущийся файл без суффикса _tmp, вместо классических ib_logfile0/ib_logfile1 версий до 8.0.30. Управляет общим объёмом переменная innodb_redo_log_capacity (на стенде — дефолтные 100 МБ, 104857600 байт).

Binlog — журнал уровня сервера MySQL, логический (в ROW-формате — построчный, а не SQL-выражение), и предназначен ровно для того, для чего redo log не годится: репликации и point-in-time recovery, а также как источник для CDC-инструментов вроде Debezium, которые читают именно binlog, а не заглядывают внутрь InnoDB. На стенде (--log-bin=binlog --binlog-format=ROW --server-id=1, log_bin=ON) SHOW BINARY LOGS после 500 INSERT показал:

Log_name        File_size  Encrypted
binlog.000001   181        No
binlog.000002   2998153    No
binlog.000003   20260      No

Файл непуст и растёт: после вставки 500 строк появился второй, ротированный файл размером около 3 МБ. Стоит отметить и попутный факт со стенда: параметр binlog_format в MySQL 8.4 помечен как deprecated ('binlog_format' is deprecated and will be removed in a future release) — само использование ROW-репликации остаётся, меняется только способ её настройки в будущих версиях, это не блокер для текущей работы.

Тезис врезки: одно логическое изменение (INSERT 500 строк) породило записи сразу в двух независимых журналах с разными ролями — redo log обеспечивает, что InnoDB переживёт падение процесса, а binlog обеспечивает, что это изменение можно передать на реплику или прочитать внешним CDC-консьюмером. Контраст с PostgreSQL резкий: там один WAL закрывает обе роли за счёт wal_level=logical, а в MySQL это принципиально два разных журнала на двух разных архитектурных уровнях (движок хранения vs сервер), и синхронизация между ними (чтобы commit был согласован и там, и там) обеспечивается отдельным механизмом — двухфазным commit между redo log и binlog внутри одной транзакции.

MongoDB: journal и oplog

MongoDB разводит те же две задачи — durability на узле и репликация — по двум разным механизмам, но иначе, чем MySQL. Journal — это write-ahead журнал WiredTiger, обеспечивающий, что подтверждённая запись переживёт падение процесса до следующего checkpoint движка; по своей роли он ближе к PostgreSQL WAL или MySQL redo log — только для локальной durability. Oplog (local.oplog.rs) — отдельная сущность специально для репликации: логический, идемпотентный журнал операций, который каждый узел replica set проигрывает у себя, чтобы оставаться синхронизированным с primary.

Ключевая структурная деталь — oplog устроен как capped-коллекция: коллекция фиксированного максимального размера, которая при заполнении начинает перезаписывать самые старые записи новыми, в порядке их поступления. На стенде (mongo:8.2, фактически MongoDB 8.2.11, replSet rs0) это проверяется напрямую — collStats(oplog.rs).capped === true.

После цикла из 500 insertOne и одного updateMany (итого 500 op:i + 500 op:u) счётчик по типу операции для waldemo.t показал ровно ожидаемое распределение:

[ { _id: 'u', count: 500 }, { _id: 'i', count: 500 } ]

Пример записи op:'i' (вставка):

{
  op: 'i',
  ns: 'waldemo.t',
  o: { _id: ObjectId('6a4bdef07c75d1f4919af9be'), i: 0, v: '0.7j7uhk7zw3r' },
  o2: { _id: ObjectId('6a4bdef07c75d1f4919af9be') },
  ts: Timestamp({ t: 1783357168, i: 2 }),
}

Пример записи op:'u' (обновление, в diff-представлении):

{
  op: 'u',
  ns: 'waldemo.t',
  o: { '$v': 2, diff: { i: { touched: true } } },
  o2: { _id: ObjectId('6a4bdef07c75d1f4919af9be') },
  ts: Timestamp({ t: 1783357172, i: 107 }),
}

Здесь важна одна оговорка, которую легко упустить при демонстрации живого oplog. Если запросить «последние 5 записей» по $natural:-1 (естественный порядок хранения, то есть по времени вставки в capped-коллекцию), результат покажет только op:'u' — потому что все 500 операций вставки (insertOne в цикле) были записаны раньше по времени, а 500 обновлений от updateMany легли плотным хвостом уже после них. Это не значит, что oplog не пишет вставки — это чистое следствие порядка операций в самом демо-скрипте (сначала все insertOne, затем один updateMany). Чтобы честно показать оба типа записи, нужно либо выбрать op:'i' отдельным запросом с фильтром, либо явно проговорить эту особенность natural order, не оставляя у читателя впечатления, что insert в oplog не попадает.

Отдельно стоит подчеркнуть, почему oplog логически идемпотентен: запись op:'u' выше хранит не «увеличить i на 1», а уже разрешённый diff конкретного документа по его _id — повторное применение той же записи oplog приводит ровно к тому же состоянию документа, а не удваивает эффект. Это свойство необходимо для повторной синхронизации отстающей реплики и для восстановления после сбоя без риска задвоить изменения.

Tarantool: WAL и snapshot

Tarantool использует классическую для in-memory движков схему: WAL операций плюс периодический snapshot всего датасета. Каждая изменяющая операция сначала попадает в WAL (запись обязана быть на диске до подтверждения клиенту, тот же fsync-контракт, что и везде), а snapshot — это полный слепок содержимого спейсов на определённый момент, снимаемый значительно реже. Восстановление после падения — это загрузка последнего snapshot и доигрывание WAL, накопившегося после него, ровно та же механика, что и checkpoint+redo в PostgreSQL, только термины и пропорции другие: snapshot — тяжёлая, редкая операция, WAL — лёгкий постоянный поток. Подробный разбор Tarantool как in-memory вычислительной платформы (включая то, как WAL сочетается с вычислениями рядом с данными) — в статье «Tarantool и Picodata: вычисления рядом с данными»готовится, с 23 сентября серии про in-memory computing.

SQLite: WAL-mode и файл -wal

SQLite по умолчанию использует rollback journal (откатный журнал старых версий страниц), но начиная с версии 3.7 доступен альтернативный journal_mode=WAL, при котором вместо отката старых страниц новые версии дописываются в отдельный файл -wal рядом с основной базой, а чтение и запись становятся менее блокирующими друг друга (читатели видят согласованный снимок, не дожидаясь записи).

На стенде (keinos/sqlite3, фактически sqlite3 3.53.2, 2026-06-03) PRAGMA journal_mode=WAL включается штатно, PRAGMA journal_mode возвращает wal. После вставки 10 000 строк, пока соединение ещё открыто, на диске реально видны три файла:

-rw-r--r--    1 sqlite   sqlite        4096 Jul  6 19:21 /w/test.db
-rw-r--r--    1 sqlite   sqlite       32768 Jul  6 19:21 /w/test.db-shm
-rw-r--r--    1 sqlite   sqlite      424392 Jul  6 19:21 /w/test.db-wal

test.db-wal весит 424 392 байта, test.db-shm (shared-memory index для координации читателей) — 32 768 байт. Оба файла реальны, и WAL не абстракция.

Здесь есть нюанс, из-за которого демонстрацию легко испортить: auto-checkpoint-on-close. Если закрыть единственное соединение к базе (например, выйти из sqlite3 CLI), SQLite сам выполняет checkpoint и удаляет -wal/-shm файлы — снаружи, после закрытия сессии, остаётся только основной файл базы:

-rw-r--r-- 1 ak 197609 413696 Jul  6 22:21 test.db

-wal/-shm отсутствуют. Явный PRAGMA wal_checkpoint(TRUNCATE) новым соединением на уже слитый WAL возвращает 0|0|0 — переносить действительно уже нечего. Практический вывод: WAL-файл в SQLite живёт ровно пока есть хотя бы одно открытое соединение к базе; чтобы увидеть его на диске, нужно смотреть файлы изнутри ещё открытой сессии, а не после её закрытия — это нормальное поведение SQLite (защита от разрастания -wal на однопользовательских сценариях), а не баг демонстрационного стенда. Больше о SQLite как встраиваемой базе — в статье «SQLite: встраиваемые базы данных»готовится, с 8 октября.

Redis: AOF как аналог WAL

Redis по умолчанию — не журналируемая по write-ahead принципу база (RDB — периодический полный снапшот в памяти на диск, не журнал операций), но опция AOF (Append Only File) превращает Redis в честный аналог WAL: каждая изменяющая команда (или её эквивалент) дописывается в постоянно растущий журнал перед тем, как считается подтверждённой, в зависимости от политики appendfsync.

На стенде (redis:8, фактически 8.8.0) включено appendonly yes с appendfsync everysec:

CONFIG GET appendonly    → yes
CONFIG GET appendfsync   → everysec

everysec — компромиссная политика: fsync AOF на диск выполняется не на каждую команду (это было бы дорого, аналог synchronous_commit=on в PostgreSQL), а раз в секунду фоновым потоком — при падении сервера теряется максимум секунда последних записей, но не больше. Это прямой аналог выбора между synchronous_commit=on/off в PostgreSQL: always в Redis ≈ on в PostgreSQL (fsync на каждую операцию, максимальная durability, минимальная скорость), everysec — практичный средний вариант, no полагается целиком на политику ОС и ближе всего по духу к off.

Важная деталь про формат: начиная с Redis 7 AOF — это не единый плоский файл appendonly.aof, как в старых версиях, а multi-part структура — директория appendonlydir с базовым RDB-снапшотом, инкрементальным AOF-файлом и манифестом. На стенде после 1000 команд SET:

total 56
-rw------- 1 redis redis    88 Jul  6 19:22 appendonly.aof.1.base.rdb
-rw------- 1 redis redis 33809 Jul  6 19:31 appendonly.aof.1.incr.aof
-rw------- 1 redis redis   102 Jul  6 19:22 appendonly.aof.manifest

appendonly.aof.1.base.rdb (88 байт) — базовый снапшот на момент старта AOF, appendonly.aof.1.incr.aof (33 809 байт) — собственно журнал команд после снапшота, appendonly.aof.manifest — манифест, связывающий части структуры воедино. INFO persistence подтверждает активную запись: aof_enabled:1, aof_current_size:33809. Разбор Redis как структуры данных и паттернов использования — в статье «Redis: кеш и структуры данных»готовится, с 22 сентября серии про in-memory computing.

LSM-движки: WAL и memtable

LSM-tree (log-structured merge-tree) движки — ScyllaDB/Cassandra, RocksDB и родственные — используют WAL не вместо, а рядом с основной структурой хранения. Запись сначала попадает в WAL (в терминологии Cassandra/ScyllaDB — commitlog), затем применяется к memtable — отсортированной in-memory структуре, которая со временем сбрасывается на диск как immutable SSTable. WAL здесь нужен только на случай падения процесса до того, как memtable успела сброситься: если сервер упал, при рестарте WAL/commitlog проигрывается заново, чтобы восстановить содержимое ещё не сброшенной memtable. После того как memtable благополучно сброшена в SSTable, соответствующий участок WAL становится не нужен и может быть усечён — та же логика чекпоинта, что и в PostgreSQL, просто единицей «уже сохранено» служит не страница, а весь memtable целиком. Подробный разбор устройства LSM-tree, компакции и сравнения с B-tree — в статье «Storage engines: LSM-tree против B-tree»Скоро.

Таблица-сравнение

БД Аналог WAL Физический / логический Одна запись или две (recovery / репликация) Заметка
PostgreSQL WAL физический (страницы); логическое декодирование поверх того же потока при wal_level=logical один и тот же журнал закрывает обе роли экономия ценой жёсткой привязки формата к версии PostgreSQL
MySQL/InnoDB redo log + binlog redo — физический (InnoDB); binlog — логический (ROW-формат) две отдельные записи в двух журналах на двух архитектурных уровнях redo только для crash recovery движка, никогда не покидает InnoDB; binlog — для репликации и CDC; согласованы двухфазным commit
MongoDB journal + oplog journal — физический (WiredTiger); oplog — логический, идемпотентный journal для локальной durability, oplog отдельно для репликации oplog — capped-коллекция; хранит уже разрешённый diff, не «сырую» операцию, ради идемпотентности повторного применения
Tarantool WAL + snapshot WAL — физический поток операций; snapshot — полный слепок один WAL, snapshot — периодическая точка восстановления (аналог checkpoint) recovery = snapshot + доигровка WAL после него
SQLite (WAL-mode) файл -wal физический (страницы) один журнал, локальная durability; репликации как таковой нет живёт, пока открыто хоть одно соединение; auto-checkpoint-on-close подчищает -wal/-shm при закрытии последнего соединения
Redis (AOF) AOF (multi-part: base RDB + incr AOF + manifest) логический (поток команд) один журнал, локальная durability; репликация Redis устроена отдельно (репликация команд/RDB, не через AOF) appendfsync (always/everysec/no) — прямой аналог выбора synchronous_commit в PostgreSQL по цене durability
LSM-движки WAL / commitlog физический (пока не сброшено в SSTable) один журнал на memtable; после сброса memtable в SSTable соответствующий WAL усекается единица «уже сохранено» — весь memtable, а не отдельная страница

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

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии