open atlas
← Все проекты

infra · advanced · 7d

Краш-устойчивое key-value хранилище с WAL

Собери крошечное дисковое KV-хранилище, которое переживает kill -9 на середине записи, дописывая в write-ahead log до изменения основного файла.

Каждая долговечная система хранения — Postgres, RocksDB, SQLite — стоит на одном неудобном факте: вызов write(2) не означает, что данные на диске. Он означает, что ОС приняла байты в свой page cache, и они попадут на постоянный носитель, когда ядро соблаговолит. Write-ahead log — это механизм, превращающий эту ложь в контракт: ты дописываешь мутацию в долговечный лог и делаешь fsync до того, как тронуть основную структуру данных, поэтому kill -9 в любой точке оставляет либо целую запись для реплея, либо оборванный хвост, который можно обнаружить и отбросить. Построить это с нуля проводит через каждый урок, на изучение которого хранилищные инженеры тратят карьеры: самоописывающийся фрейминг записей, инвариант ack-после-fsync, мультипликатор пропускной способности group commit, проблему оборванного хвоста при восстановлении после краша, упорядочивание чекпойнтинга и то, почему врущая файловая система может нарушить долговечность независимо от корректности кода.

Результат

KV-хранилище, где каждая мутация долговечна после возврата вызова, проверенное стендом с инъекцией крашей.

Этапы

0/6 · 0%
  1. 01Append-only лог: фрейминг записей и формат

    До долговечности спроектируй дисковый формат. WAL — это последовательность самоописывающихся записей, дописываемых в один растущий файл, и каждый последующий этап — восстановление, контрольные суммы, усечение — зависит от того, можешь ли ты пройти этот байтовый поток запись за записью. Фрейми каждую запись как длина-префикс + тип + payload (и зарезервируй хвостовой слот под CRC, который добавишь на этапе 5), потому что без длины ты не найдёшь, где начинается следующая запись, а оборванный хвост станет неотличим от реальных данных. Реши единицу аппенда: мелкие записи (одна мутация на каждую) держат латентность низкой, но тратят байты заголовка; крупные батч-записи амортизируют оверхед фрейминга. Реальная запись WAL — это десятки байт заголовка над payload, и, скажем, при 64-байтных значениях оверхед фрейминга нужен ниже ~20%, чтобы лог не удваивал объём записи. Построй writer, который дописывает фреймы, и reader, который их обходит, пока без всяких гарантий долговечности — это работа следующего этапа.

    Критерии готовности
    • Запись несёт явную длину, так что reader находит смещение следующей записи без сканирования в поисках разделителя.
    • Writer дописывает фреймы, а reader проходит весь лог обратно в тот же упорядоченный список записей.
    • Ты можешь назвать оверхед фрейминга на запись в байтах и при каком размере значения он остаётся приемлемым.
    Самопроверка

    Senior-ревьюер проверяет, что фрейм самоописывающийся (с длиной-префиксом) и совместим вперёд — под CRC и поле версии/типа зарезервировано место, так что этап 5 — расширение формата, а не переписывание.

  2. 02Долговечность через fsync и group commit

    Теперь сделай так, чтобы «вызов вернулся» означало «данные переживут потерю питания». write(2) лишь копирует байты в page cache ОС; пока fsync(2) (или fdatasync) не вытолкнет их на стабильное хранилище, краш теряет их молча. Поэтому контракт долговечности такой: допиши фрейм, сделай fsync, затем подтверди — никогда не подтверждай до возврата fsync. Загвоздка в цене: один fsync на SSD ~0.5–3 мс, а на вращающийся диск 5–10+ мс, так что наивный дизайн один-fsync-на-запись упирается в несколько сотен — низкие тысячи записей/сек, как бы быстр ни был CPU. Group commit это лечит: буферизуй конкурентных писателей, делай один fsync на весь батч, затем подтверждай всех вместе. Один fsync, амортизированный по батчу из 100, превращает запас в ~1000 fsync/сек в ~100k подтверждений/сек — ценой добавленной латентности (писатель ждёт до окна батча). Настрой окно батча (например, 1–5 мс или N ожидающих) и измерь кривую пропускная-способность-против-латентности, которую ты только что купил.

    Критерии готовности
    • Каждая подтверждённая запись проходит fsync до возврата её ack, что доказано тестом, утверждающим, что ни один ack не опережает свой fsync.
    • Group commit батчит конкурентных писателей в один fsync, и ты сообщаешь записей/сек с батчингом и без.
    • Ты замерил латентность одиночного fsync на своём диске и можешь назвать выбранное окно батча и почему.
    Самопроверка

    Senior-ревьюер проверяет, что порядок ack-после-fsync держится при group commit (батчевый писатель не подтверждается, пока общий fsync не завершится) и что fdatasync против fsync был осознанным выбором, а не случайностью.

  3. 03Восстановление после краша: реплей и оборванный хвост

    WAL бесполезен, если ты не можешь восстановить из него состояние после краша. На старте реплей идёт по логу с начала, применяя каждую корректную запись для восстановления состояния в памяти, и чисто останавливается на первой записи, которую не может полностью разобрать, — оборванном хвосте записи, прерванной между аппендом и fsync. Ключевой инвариант: восстановление должно быть идемпотентным и детерминированным по префиксу: повторный реплей того же лога даёт идентичное состояние, а оборванная последняя запись отбрасывается, а не применяется наполовину, потому что ты подтверждал только те записи, чей fsync завершился, так что всё после последней целой записи никогда не обещалось вызывающему. Внедряй краши (усекай файл по случайным смещениям или убивай посреди записи под fault-стендом) и докажи, что восстановленное состояние точно совпадает с множеством подтверждённых записей — без потерянных ack, без фантомных записей из оборванного хвоста. Заметь: время восстановления растёт с длиной лога: лог в 1 ГБ, реплеенный со, скажем, нескольких сотен МБ/с, — это секунды старта, и именно это давление вынуждает следующий этап.

    Критерии готовности
    • После kill -9 посреди записи восстановленное состояние равно ровно множеству подтверждённых записей — оборванный хвост отброшен, ни один ack не потерян.
    • Повторный реплей того же лога даёт побайтово идентичное состояние (восстановление идемпотентно).
    • Ты замерил пропускную способность реплея и можешь назвать время восстановления на текущем и в 10 раз большем логе.
    Самопроверка

    Senior-ревьюер проверяет, что восстановление останавливается на первой неразбираемой записи (не сканирует за оборванной записью в поисках валидно выглядящих байтов дальше) и что восстановленное множество точно равно подтверждённому при прогоне с инъекцией крашей.

  4. 04Чекпойнтинг и усечение лога

    Неограниченный лог означает неограниченное время восстановления и неограниченный диск. Чекпойнтинг это ломает: периодически сбрасывай текущее материализованное состояние в долговечный snapshot-файл, записывай смещение лога, которое этот снапшот покрывает, затем усекай (или ротируй в новый сегмент и удаляй старые) всё до него. Восстановление тогда становится загрузкой-снапшота + реплеем-только-хвоста, превращая многосекундный холодный старт в почти константный. Компромиссы реальны: чекпойнть слишком часто — платишь I/O записи снапшота, который конкурирует с фоновыми записями; слишком редко — восстановление и диск раздуваются. Порядок — это мина долговечности: ты должен сделать fsync снапшота и нового маркера смещения чекпойнта до усечения старого лога, иначе краш посреди чекпойнта оставит тебя без полного снапшота и без только что удалённых записей лога. Сегментируй лог в файлы фиксированного размера, чтобы усечение было атомарным unlink целых сегментов, а не рискованным переписыванием файла на месте.

    Критерии готовности
    • После чекпойнта восстановление загружает снапшот и реплеит только записи после смещения чекпойнта, и ты показываешь падение времени восстановления.
    • Снапшот и маркер смещения проходят fsync до удаления любого старого сегмента лога, что доказано тестом краша-во-время-чекпойнта, который всё равно восстанавливается корректно.
    Самопроверка

    Senior-ревьюер проверяет порядок чекпойнта (снапшот долговечен → маркер смещения долговечен → только потом усечение) и что краш на каждом шаге чекпойнта всё равно восстанавливает полное подтверждённое множество.

  5. 05KV-хранилище поверх: memtable и flush

    Преврати долговечный лог в пригодное key-value хранилище. Паттерн — это парадная дверь LSM: запись дописывает запись (ключ, значение) в WAL для долговечности, затем обновляет in-memory memtable (отсортированную карту), которая обслуживает чтения — так что WAL — это хребет долговечности, а memtable — быстрое запрашиваемое представление. Их согласованность держится порядком: сначала WAL-аппенд + fsync, потом обновление memtable, так что краш может потерять лишь то, что никогда не подтверждалось. Когда memtable перерастает порог, сбрось его как отсортированную дисковую таблицу и, критически, только тогда отбрось префикс WAL, который он покрывал, — флаш это просто специализированный чекпойнт, и применяется то же правило fsync-до-усечения из этапа 4. Get(key) проверяет memtable, затем откатывается к сброшенным таблицам. Это момент, когда проект перестаёт быть логом и становится storage-движком, и он вынуждает тебя рассуждать о read-your-writes: подтверждённая запись обязана быть видна самому следующему чтению.

    Критерии готовности
    • Запись долговечна в WAL до обновления memtable, и подтверждённая запись читается самым следующим Get (read-your-writes).
    • Флаш memtable пишет отсортированную дисковую таблицу и отбрасывает только покрытый ею префикс WAL, и чтения корректно откатываются от memtable к сброшенным таблицам.
    • Краш до флаша восстанавливает все подтверждённые записи реплеем WAL в свежий memtable.
    Самопроверка

    Senior-ревьюер проверяет порядок WAL-потом-memtable (никогда не memtable-первым) и что флаш списывает правильный префикс WAL — не слишком много (теряя несброшенные записи) и не слишком мало (реплея сброшенные данные).

  6. 06Обнаружение повреждений (CRC), наблюдаемость и инцидент оборванной записи

    Тихое повреждение — враг, ради которого существует контрольная сумма. Добавь CRC32 (слот, который ты зарезервировал на этапе 1) по заголовку+payload каждой записи; на реплее пересчитывай и сравнивай, чтобы оборванная запись, переворот бита или наполовину записанный сектор обнаруживались, а не применялись как реальные данные. Это граница между «восстанавливается чисто» и «перестраивает повреждённое состояние и отдаёт его вечно». Затем сделай движок наблюдаемым: счётчики записей/сек, латентность fsync p50/p99, размер батча group-commit, размер memtable и число флашей, время восстановления — те же RED-образные сигналы, что эмитит реальный storage-движок. Наконец, проведи инцидент: симулируй оборванную запись (усеки активный сегмент посреди записи) И потерянный fsync (fault-слой, который отбрасывает эффект fsync, так что выглядящие подтверждёнными данные на самом деле не попали на диск). Покажи, что CRC ловит оборванную запись и восстановление на ней останавливается; затем покажи, что случай потерянного fsync — по-настоящему опасный — он может потерять подтверждённую запись — и порассуждай, почему врущие про fsync диски/ФС делают «долговечность» свойством всего твоего стека, а не только кода. Напиши короткий пост-мортем: какой отказ твой дизайн переживает, какой нет, и одну митигацию, которая не «fsync-ай сильнее».

    Критерии готовности
    • CRC на запись обнаруживает повреждённую/оборванную запись на реплее, и восстановление останавливается на ней, а не применяет мусор.
    • Движок эмитит записей/сек, fsync p50/p99, размер батча и время восстановления, и ты можешь прочитать по ним всплеск промахов или медленный fsync.
    • Твой пост-мортем отличает случай оборванной записи (CRC ловит, ack не потерян) от случая потерянного fsync (ack может быть потерян) и предлагает одну нетривиальную митигацию.
    Самопроверка

    Senior-ревьюер проверяет, что CRC покрывает заголовок+payload (не только payload, чтобы повреждённое поле длины тоже ловилось) и что пост-мортем верно определяет случай потерянного fsync как способный нарушить контракт долговечности, с митигацией сверх «вызвать fsync ещё раз».

Стартер

  • README.md
  • src/wal.ts
  • test/wal.test.ts
Скачать стартер (.zip)

Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test

Рубрика

Джуниор Миддл Сеньор
Порядок fsync и контракт долговечности Записи подтверждаются после write(2), но до fsync; kill -9 между ними молча теряет подтверждённые данные. Каждый ack следует за fsync: дописать фрейм, fsync, затем ack — и group commit батчит конкурентных писателей в один fsync, с измерением записей/сек с батчингом и без. Ты рассуждаешь о компромиссе латентности group commit (окно батча добавляет время ожидания; слишком маленькое — платишь fsync-стоимость на писателя), измерил латентность единичного fsync на своём диске (~0.5–3 мс SSD) и выбрал окно батча под p99 бюджет латентности записи, который готов принять.
Восстановление после краша и оборванный хвост Восстановление реплеит все найденные записи; оборванная запись вызывает мусор или панику при разборе вместо чистой остановки. Восстановление реплеит целые записи и останавливается на первой неразбираемой (оборванный хвост отбрасывается, а не применяется наполовину); повторный реплей того же лога даёт побайтово идентичное состояние. CRC на запись ловит повреждённый (а не только усечённый) хвост; пост-мортем отличает случай оборванной записи (CRC ловит, ack не потерян) от потерянного fsync (ack может быть потерян независимо от CRC) и предлагает нетривиальную митигацию — не «fsync-ай сильнее», а что-то вроде контрольного суммирования на другом уровне или ahead-верификации.
Порядок чекпойнтинга и усечение лога Лог растёт безгранично; время восстановления пропорционально размеру лога и увеличивается каждый день. Чекпойнт сбрасывает материализованное состояние в снапшот, записывает смещение лога и усекает старые сегменты; восстановление становится снапшот + реплей только хвоста, и сбой во время чекпойнта всё равно восстанавливается корректно. Инвариант порядка — fsync снапшота, fsync маркера смещения, только потом unlink старых сегментов — соблюдается, и ты доказываешь это тестом сбоя-посреди-чекпойнта. Ты измерил время восстановления на текущем и в 10 раз большем логе и можешь назвать частоту чекпойнтинга, при которой холодный старт укладывается в бюджет латентности.
Эталонный разбор (спойлер)

Почему write-ahead: изменение B-дерева или любой многостраничной структуры на месте не является атомарным. Сбой посреди обновления оставляет структуру в несогласованном состоянии без возможности восстановления. WAL обходит это, дурабильно записывая намерение до изменения структуры; восстановление реплеит лог, и структура восстанавливается из согласованного префикса.

Стоимость fsync и group commit: один fsync на SSD ~0.5–3 мс, на вращающийся диск 5–10+ мс. Наивный дизайн один-fsync-на-запись ограничивает пропускную способность несколькими сотнями — низкими тысячами записей/сек. Group commit амортизирует эту стоимость: один fsync на батч из N превращает тот же запас в N-кратную скорость подтверждений, ценой добавленной латентности окна батча на писателя.

Порядок чекпойнтинга как мина долговечности: если усечь старые сегменты лога до fsync снапшота, сбой между усечением и завершением снапшота оставляет без записей лога и без полного снапшота — хранилище невосстановимо. Правильная последовательность строго: fsync снапшота → fsync смещения чекпойнта → unlink старых сегментов.

Врущие файловые системы: некоторые ФС и контроллеры хранилищ переупорядочивают записи и подделывают подтверждения fsync (распространено на дешёвых NAS, некоторых облачных блочных хранилищах в дефолтной конфигурации и RAID-контроллерах с резервным питанием при отключённом write-back кеше). Корректная реализация WAL не может защититься от этого — долговечность fsync является свойством всего стека I/O, а не только пользовательского кода. Митигация в пост-мортеме — обычно O_DSYNC на уровне устройства, паттерны двойной синхронизации или проверка гарантий долговечности уровня хранилища в SLA облачного провайдера.

Сделай по-сеньорски

  • Внедряй краши между записью в WAL и fsync; докажи, что ни одна подтверждённая запись не теряется на тысячах рандомизированных точек краша.
  • Добавь WAL-репликацию: стримь лог на follower, применяй по порядку и рассуждай о границе долговечности (ack по локальному fsync против ack по подтверждению follower) и цене пропускной способности.
  • Добавь LSM-компакцию: сливай сброшенные отсортированные таблицы, чтобы ограничить read amplification и вернуть место, и измерь компромисс write-amplification против латентности чтения.
  • Сравни O_DIRECT (минуя page cache, со своей буферизацией) с буферизованными записями + fsync и измерь разницу латентности и пропускной способности под устойчивой нагрузкой.

Навыки

append-only logfsync semanticscrash recoverycheckpointing