Вот рецепт, который копируют все. Он есть в любом языке, в любой кодовой базе, и выглядит безупречно:

const fd = fs.openSync(tmp, 'w');
fs.writeSync(fd, data);
fs.fsyncSync(fd);      // данные на диске
fs.closeSync(fd);
fs.renameSync(tmp, target);   // подмена атомарна

Временный файл, fsync, атомарное переименование. Классический «атомарный рекорд». Он действительно защищает от половинчатой записи: читатель увидит либо старый файл целиком, либо новый целиком, но не склейку.

Чего в нём не хватает — одной строки.

Чего не хватает

fsync(fd) доводит до носителя содержимое файла. Но rename() меняет не файл, а каталог: он правит запись в директории. А запись в каталоге — это метаданные другого объекта, и до возврата rename() она живёт в page cache ровно так же, как любая несохранённая страница.

Чтобы переименование пережило отключение питания, нужно синхронизировать сам каталог:

const dfd = fs.openSync(path.dirname(target), 'r');
fs.fsyncSync(dfd);
fs.closeSync(dfd);

Здесь обычно следует абзац «это все знают».

«Это все знают»

write-file-atomic — 102 479 180 загрузок в неделю. Его использует сам npm CLI, когда пишет ваш package-lock.json. Вот его последовательность, версия 8.0.0, прочитана в распакованном пакете, а не пересказана:

open(tmp) → write → fsync(fd) → close → chmod → rename(tmp, target)

Каталог не синхронизируется ни разу.

Это не донос на конкретный пакет. То же самое делают atomically, на котором стоит conf (хранилище настроек сотен CLI-утилит), и steno, на котором стоит lowdb. Так пишут везде.

Всего я проверил двенадцать способов сказать «сохранено» — от голого writeFileSync до SQLite и git commit. Десять теряют подтверждённую запись. Таблица будет ниже; сначала о том, как это меряется, иначе ей незачем верить.

Во все три проекта заведены issue с измерением и предложенной правкой — write-file-atomic#243, atomically#22, steno#37. Сначала мейнтейнерам, потом в статью: узнавать о находке в своём коде из поста — так себе способ.

Дальше можно было бы спорить. Кто-то скажет «ext4 это и так переживёт», кто-то — «да у всех так», третий — «за двадцать лет ни разу не видел». Спор ничего не решает, потому что спорящие говорят о разном: один про файловую систему, другой про диск, третий про свой опыт на своём железе.

Поэтому я не стал спорить, а измерил.

Как измерить отключение питания

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

Правильный инструмент лежит в ядре Linux и называется dm-log-writes. Это цель device-mapper, которая пропускает записи на диск и попутно складывает их копию во второе устройство — в том порядке, в котором блочный слой их отправил, с отметками сброса кэша и FUA.

Это не модель POSIX и не рассуждение о том, что «должно» произойти. Это то, что блочный слой действительно отправил вниз.

Дальше схема такая:

data.img  ──loop──┐
                  ├── dm-log-writes ── mkfs ── mount   ← здесь работает приложение
log.img   ──loop──┘        │
                           └── журнал всех записей с барьерами

replay.img ──loop── чистое устройство, на нём собирается состояние после отказа
     │
     └── dm-snapshot ── монтируется, запуская восстановление журнала ФС
  1. Приложение работает на настоящей файловой системе на настоящем блочном устройстве.

  2. Журнал воспроизводится на чистое устройство до каждой точки отказа — получается диск ровно таким, каким его оставило бы отключение питания.

  3. Устройство монтируется, и отрабатывает настоящее восстановление журнала ext4, xfs или btrfs. Не наша догадка о восстановлении — оно само.

  4. На результате запускается проверка: то, что приложение объявило сохранённым, на месте?

Критично, что проверяется каждая точка отказа, а не одна удобная. В типичном прогоне журнал содержит от полутора сотен до полутора тысяч записей, границ сброса среди них — несколько десятков, и теряют данные три из них. Ткнуть наугад и попасть — не тот способ, которым доказывают.

Первое измерение

Два прогона одного и того же рекорда: без fsync каталога и с ним. Отказ ровно в тот момент, когда приложение вернуло успех.

содержимое после восстановления

что в каталоге

без fsync каталога

OLD

config.json, config.json.tmp, lost+found

с fsync каталога

NEW

config.json, lost+found

Приложение вернуло успех. После восстановления в файле старое содержимое, а рядом лежит осиротевший .tmp.

Обратите внимание на деталь, которая делает этот опыт самопроверяющимся: временный файл, который fsync-нули, уцелел. Переименование, которое не fsync-нули, — нет. То есть измеряется именно заявленное, а не общая потеря данных при отказе.

И ещё: файл не становится нулевой длины и не бьётся. Срабатывает auto_da_alloc, добавленный в ext4 в 2009 году после волны жалоб на обнулённые конфиги. Но он закрывает другую проблему — выталкивает данные перед переименованием. Долговечным само переименование он не делает. Классический костыль, создающий ощущение закрытого вопроса.

Сколько у вас времени

Окно не бесконечно. Файловая система рано или поздно фиксирует журнал сама, и с этого момента обещание становится правдой. Вопрос — когда.

Распространённое ожидание: «ядро сбросит грязные страницы через dirty_expire_centisecs, это тридцать секунд». Неверно. Границу держит интервал фиксации журнала:

-- data=ordered,commit=1 --         -- data=ordered,commit=60 --
  пережил 0 с  ->  ВСЁ ЕЩЁ ТЕРЯЕТ     пережил 0  с  ->  ВСЁ ЕЩЁ ТЕРЯЕТ
  пережил 2 с  ->  уже безопасно       пережил 30 с  ->  ВСЁ ЕЩЁ ТЕРЯЕТ
  пережил 4 с  ->  уже безопасно       пережил 70 с  ->  уже безопасно

Окно следует интервалу фиксации точно. Отсюда неприятный вывод: совет «поставьте commit=60, станет быстрее», который легко найти в руководствах по тюнингу, расширяет окно потери подтверждённых данных в двенадцать раз. Вы покупаете пропускную способность за время, в течение которого ваше «сохранено» — неправда.

Инструмент

Всё это собрано в gray — назван в честь Джима Грея, который и описал, что значит «сохранено».

$ sudo gray run --workload ./save.sh --check ./verify.sh

Journal: 133 entries, 512-byte sectors
Crash points checked: 24, of them with a promise already made: 18

FAIL — acknowledged data is lost in 3 of 24 checked states.

  crash points #101–#111 after mark "gray:ack:v2"
    acknowledged "v2", found "v1" after recovery

Likely cause
  renameat("…/config.json.tmp" → "…/config.json") changes the contents of
  directory "…", and that directory is never fsynced afterwards.

What the search did not cover
  Cases at flush boundaries were enumerated. Reorderings inside the window
  between two flushes were not.
  Deepest window: 13 writes in flight.
  Subsets of them left unenumerated: 9.0 thousand.

Контракт простой: --workload — что делает приложение, --check — что обязано быть верно после восстановления. Внутри нагрузки вызывается gray ack <ключ> там, где приложение сказало бы пользователю «сохранено».

Последний блок печатается всегда, в том числе при успехе. Инструмент, который молчит о границах собственного перебора, предлагает принять зелёный прогон за доказательство — ровно то, против чего он написан.

Разбор журнала и воспроизведение сверены с эталонным replay-log из xfstests: один и тот же журнал, побайтово равные образы на каждой точке отказа. Своя реализация нужна, чтобы инструмент ставился одной командой, а не требовал клона тестового набора ядра; но раз она своя — она обязана не расходиться с той, что читает эти журналы годами.

Аудит: двенадцать целей

Вопрос ко всем один: приложение сообщило об успехе — переживёт ли записанное отключение питания в этот момент?

цель

ext4

xfs

btrfs

виновный вызов

write-file-atomic@8 — 102 млн загрузок/нед, внутри npm

да

да

да

rename, каталог без fsync

atomically@2 — движок под conf

да

да

да

rename, каталог без fsync

conf@16 — настройки сотен CLI (пишет через atomically)

да

да

да

rename, каталог без fsync

steno@4 — писатель под lowdb

да

да

да

rename, каталог без fsync¹

fs.writeFileSync без синхронизации

да

да

да

запись без fsync

npm pkg set

да

да

да

запись без fsync

git commit, настройки по умолчанию

да

да

да

rename, каталог без fsync

git commit при core.fsync=all

да

да

да

rename, каталог без fsync

SQLite, synchronous=FULL (по умолчанию)

да

да

да

unlink, каталог без fsync

write + fsync + rename + fsync каталога

нет

нет

нет

—

SQLite, synchronous=EXTRA

нет

нет

нет

—

Десять из двенадцати теряют подтверждённую запись. Не теряют ровно две — и это ровно те две, которые я поставил контролями.

¹ У steno диагностика назвала переименование, но там всё проще и хуже: fsync в исходнике нет вообще — ни файла, ни каталога, только writeFile и rename. Для дебаунс-писателя это, похоже, осознанный размен на скорость; вопрос скорее к README, чем к коду, — так и написано в issue.

Замерено на ядре 6.8 aarch64, ext4 с data=ordered,commit=5, xfs и btrfs — на настройках по умолчанию. Точные версии всех целей — в audit/RESULTS.md.

Три файловые системы здесь не для полноты. Результат, который виден только на ext4, был бы свойством ext4, а не проверяемого кода.

Одна оговорка прямо к таблице, чтобы её не пропустил тот, кто дальше не читает. Клетка — это один прогон. На моей машине всё воспроизводится десять раз из десяти, но на шумном общем раннере колонка xfs однажды вернулась чистой, а в следующий раз нет: окно там узкое, и тайминги успевают его закрыть. Подробно — ниже, в разделе про собственные ошибки. Вывод для читателя простой: прежде чем верить клетке со словом «нет», прогоните с --repeat.

SQLite: три часа я думал, что сломал инструмент

Самое неприятное измерение в этой работе.

SQLite ставился эталоном добросовестности. Двадцать лет паранойи вокруг отказов питания, собственный набор тестов, отдельный документ про атомарность записи. Инструмент обязан был смолчать.

Он нашёл потерю подтверждённой строки. На настройках по умолчанию.

Я три часа искал ошибку у себя. Нашёл у SQLite — и не ошибку, а задокументированный размен.

Трассировка INSERT при journal_mode=delete, synchronous=FULL:

openat(app.db-journal) → fd 4
fdatasync(4)                      журнал на диск
openat(каталог) → fd 5
fdatasync(5)                      ПОЯВЛЕНИЕ журнала сделано долговечным
close(5)
fdatasync(4)                      заголовок журнала
fdatasync(3)                      страницы базы
close(4)
unlinkat(app.db-journal)          ← ЭТО И ЕСТЬ МОМЕНТ ФИКСАЦИИ
close(3)                          и всё: каталог после удаления не синхронизируется

Каталог синхронизируется при создании журнала отката и не синхронизируется при его удалении. А удаление в режиме delete и есть момент фиксации — так написано в документации SQLite, §3.11: «the rollback journal file is deleted. This is the instant where the transaction commits».

Отказ после unlinkat, но до того, как удаление доехало до носителя, возвращает журнал. SQLite при открытии видит «горячий» журнал и откатывает уже зафиксированную транзакцию.

И вот что делает эту историю хорошей, а не скандальной. Документация SQLite говорит это дословно, в описании прагмы synchronous:

EXTRA synchronous is like FULL with the addition that the directory containing a rollback journal is synced after that journal is unlinked to commit a transaction in DELETE mode. […] Without EXTRA, depending on the underlying filesystem, it is possible that a single transaction that commits right before a power loss might get rolled back upon reboot. The database will not go corrupt. But the last transaction might go missing, thus violating durability, if EXTRA is not set.

По умолчанию стоит FULL, а не EXTRA.

Заявлять надо ровно это, и ни словом больше: не «SQLite теряет данные», а «значение по умолчанию оставляет окно, и SQLite говорит об этом прямо — в описании прагмы, которое почти никто не читает». Разница между этими двумя фразами — это разница между статьёй и склокой.

Зачем это нужнее всего

Пара FULL/EXTRA оказалась сильнейшей калибровкой, какую вообще можно придумать:

настройка

вердикт

synchronous=FULL (по умолчанию)

потеря в 3 состояниях из 30

synchronous=EXTRA

тишина, 0 из 31

Вердикт переключается ровно той ручкой, которую документация называет управляющей этим поведением. Инструмент, который шумит всегда, бесполезен так же, как инструмент, который молчит всегда. Здесь показано, что он чувствителен именно к тому, к чему должен. Саму потерю он нашёл по блочным записям, ничего не зная ни про SQLite, ни про прагмы; виновный unlinkat назвала уже вторая трасса, системных вызовов. Про существование EXTRA ему не рассказывал никто — на неё я вышел, разбираясь, почему эталон упал.

Заодно это снимает вопрос доверия к находке про write-file-atomic.

git: ноль вызовов fsync

git commit с настройками по умолчанию не делает ни одного fsync. Ни объекта, ни индекса, ни ссылки. Всё пишется и переименовывается на место.

Это тоже документировано:

The default on most platforms is equivalent to core.fsync=committed,-loose-object, which has good performance, but risks losing recent work in the event of an unclean system shutdown.

Интереснее другое. Я включил core.fsync=all — строжайшую настройку, какая есть, — и gray всё равно нашёл потерю. Трассировка объясняет:

по умолчанию:        0 вызовов fsync
core.fsync=all:      6 вызовов fsync — и все на ФАЙЛЫ
                     .git/index.lock             ×2
                     .git/refs/heads/master.lock
                     .git/objects/xx/tmp_obj_*  ×3

переименования в обоих случаях:
                     .git/index.lock             → .git/index
                     .git/refs/heads/master.lock → .git/refs/heads/master

Шесть fsync на файлы и ни одного на каталог, хотя все эти файлы потом переименовываются на место. Содержимое становится долговечным, переименование — нет. Поэтому ссылка и откатывается: объекты на диске лежат, а коммита «не было».

Что я сломал в собственном инструменте

Раздел, без которого статья была бы рекламой.

1. Монтирование — это запись. Перебор устроен инкрементально: точки отказа идут по возрастанию, и каждая следующая досылает только новые записи поверх предыдущего состояния. Рассуждение верное — и всё равно неверное, потому что в нём не было третьего участника. Восстановление журнала пишет на устройство: правит блоки, обновляет суперблок. Инкремент досылал записи поверх того, что уже изменило ядро.

Поймалось расхождением между быстрым путём и «параноидальным», который собирает каждое состояние с нуля. Быстрый поднимал тревогу на заведомо правильном коде, параноидальный молчал. Лечение — монтировать одноразовый dm-snapshot, чтобы записи восстановления уходили в копию и выбрасывались. Теперь это регрессия: оба пути обязаны давать одинаковый ответ.

2. Пустая строка ложна. Диагностика разбирает вывод strace регулярным выражением. В коде стояло:

if (!time || !name || !args || result === undefined) return null;

Вызов sync() не имеет аргументов, args для него — пустая строка, а пустая строка ложна. Глобальный sync молча выпадал из разбора, и диагностика обвиняла запись, которая на самом деле была уже закрыта.

3. Я трижды заморозил измерение в инвариант. Это уже не эпизод, а форма ошибки, и я повторил её три раза подряд в одном файле тестов.

Сначала было требование «виновный вызов обязан называться renameat». Прошло у меня, упало на первом же прогоне CI: на aarch64 системного вызова rename в ядре нет вовсе, glibc вынужден звать renameat, а на x86_64 есть оба и выбирает он. Тест записал архитектуру.

Потом — «на xfs потеря обязана воспроизводиться». Прошло у меня, упало на CI.

Потом — «вердикт не должен меняться между повторами». Прошло у меня, упало на CI.

Каждый раз инструмент был прав, а тест требовал от мира того, что свойством мира не является. Разделение, к которому пришлось прийти, простое и стоило трёх падений: контроль обязан молчать — это инвариант, на любом ядре и любой машине; теряет ли конкретная цель на конкретной ФС, и устойчив ли ответ — это измерение, и докладывать о нём должен аудит, а требовать его набор тестов не вправе.

4. И главное. Увидев на CI один зелёный прогон xfs, я сделал вывод: «на этом ядре xfs держит переименование». Следующий прогон на той же машине, том же коммите и том же ядре нашёл потерю.

Автор инструмента, существующего ради тезиса «один зелёный прогон ничего не доказывает», сделал вывод из одного зелёного прогона.

На моей машине xfs теряет десять раз из десяти. На шумном общем раннере окно иногда успевает закрыться — и там же иногда не успевает. Явление зависит от таймингов, и ровно поэтому в инструменте есть --repeat, а в README — оговорка. Не потому, что приятно её писать, а потому, что без неё таблица выше была бы на шаг хуже обоснована.

Инструмент отказывается выдавать бессмысленный зелёный

Два способа, которыми такой инструмент мог бы тихо соврать. Оба проверяются до всего остального.

Нагрузка ничего не обещала. Ни одного gray ack — значит, список обещаний пуст, проверка честно выходит нулём на каждом состоянии, и наивный инструмент печатает PASS. Теперь:

INCONCLUSIVE — the workload never called `gray ack`.
  Nothing was promised, so nothing could be violated. This run proves nothing.

Проверка не способна упасть. Перед перебором проверка ставится перед диском, каким он был до того, как нагрузка записала хоть байт, и ей выдаётся полный список обещаний. Она обязана там упасть. Если не падает — она не отличает сохранённый файл от пустого диска:

INCONCLUSIVE — the check cannot fail.
  It returns success even on the disk as it was before the workload wrote
  anything, with every promised key handed to it.

Оба случая — код возврата 2, не 0. В CI такое не пройдёт зелёным. Зелёный прогон, который не мог быть красным, стоит меньше, чем ошибка.

Чего метод не умеет

  • Перестановки внутри окна между сбросами. Перебираются состояния на границах сброса. Накопитель вправе записать любое подмножество ожидающих записей в любом порядке; таких подмножеств 2^k, и инструмент печатает их число в каждом отчёте, а не умалчивает.

  • Накопители, которые лгут о выполнении сброса. Такой сделает хуже, чем показано, никогда не лучше.

  • mmap — он обходит тот путь записи, за которым инструмент наблюдает.

  • Оракул ваш. Доказывается, что переживает то, что утверждает ваша проверка. Не больше.

  • Один прогон на клетку таблицы. Там, где окно узкое, ответ зависит от таймингов машины — см. историю с xfs выше.

Что с этим делать

Если вы пишете файл. Полный рецепт — четыре шага, а не три:

const fd = fs.openSync(tmp, 'w');
fs.writeSync(fd, data);
fs.fsyncSync(fd);
fs.closeSync(fd);
fs.renameSync(tmp, target);
const dfd = fs.openSync(path.dirname(target), 'r');  // вот это
fs.fsyncSync(dfd);                                   // и это
fs.closeSync(dfd);

Если вы используете SQLite и вам важна последняя транзакция — PRAGMA synchronous=EXTRA. Платите задержкой на коммит, получаете то, что думали, что у вас уже есть.

Если вы тюните ext4 — посмотрите, что у вас стоит в commit. Вы меняете пропускную способность на ширину окна потери.

И главное. Не верьте ничему из перечисленного, включая эту статью, применительно к вашему стеку. Ядро, файловая система, опции монтирования, виртуальный диск и контроллер у вас свои. Модель расскажет про идеальную POSIX-машину; измерение — про вашу.

sudo npx @botiroff/gray run --workload ./ваша-нагрузка.sh --check ./ваша-проверка.sh

Ссылки