Write-ahead logging — не изобретение PostgreSQL, а общий приём, который повторяет практически любая СУБД, где важна durability. Но «повторяет принцип» не значит «повторяет реализацию один в один»: одни базы обходятся одним журналом на все задачи, другие держат два разных журнала для двух разных ролей, третьи вообще используют журнал операций, а не журнал байтовых изменений страниц. Разница не декоративная — от неё зависит, что именно можно прочитать из журнала (только для восстановления после падения или ещё и для репликации), можно ли отдать его наружу как поток изменений и что произойдёт при повторном применении одной и той же записи. Эта статья проходится по нескольким БД — MySQL, MongoDB, Tarantool, SQLite, Redis, LSM-движкам — и сравнивает их аналоги WAL с PostgreSQL, который уже разобран в первой статье серии.
Это вторая статья серии «WAL и его аналоги». Опирается на механизм из первой: что такое write-ahead в принципе, зачем нужен fsync и что показывает crash recovery руками. Здесь предполагается, что читатель это уже видел, и разговор идёт о том, что меняется от базы к базе.
В статье
- Один принцип, разные реализации
- PostgreSQL WAL: краткая сводка
- Врезка: MySQL — redo log и binlog, две роли
- MongoDB: journal и oplog
- Tarantool: WAL и snapshot
- SQLite: WAL-mode и файл
-wal - Redis: AOF как аналог WAL
- LSM-движки: WAL и memtable
- Таблица-сравнение
Один принцип, разные реализации
Формулировка, общая для всех: прежде чем изменить долговременное состояние, запиши намерение в последовательный (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.
Источники
- MySQL Documentation — The Binary Log, InnoDB Redo Log
- MongoDB Documentation — Replica Set Oplog, Journaling
- SQLite Documentation — Write-Ahead Logging
- Redis Documentation — Persistence (AOF)
- Стенды:
digital-cookbook/databases/wal(mysql,mongo,sqlite,redis)
Комментарии