Featured image of post Почему данные не пропадают при отключении питания во время сохранения: инженерия согласованности при сбоях (Crash Consistency)

Почему данные не пропадают при отключении питания во время сохранения: инженерия согласованности при сбоях (Crash Consistency)

Глубокий технический разбор согласованности систем хранения при сбоях (Crash Consistency): почему добавление одного блока 4 КБ требует нескольких неатомарных записей на диск, переупорядочивание в страничном кэше ОС и FTL SSD, пределы fsck и Soft Updates, журналирование Write-Ahead Logging (WAL) и алгоритм ARIES, режимы ext4, деревья Меркла Copy-on-Write (ZFS/Btrfs) и ловушки fsync().

1. Введение: минное поле внутри добавления одного блока данных в 4 КБ

Когда вы нажимаете Ctrl+S в текстовом редакторе или подтверждаете банковский перевод, ваше приложение вызывает системный вызов write(). С точки зрения прикладного программиста сохранение файла выглядит как одно мгновенное, неделимое (атомарное) действие.

Однако внутри ядра операционной системы, файловой системы, движка базы данных и контроллера SSD скрывается одна из сложнейших проблем системного программирования — проблема согласованности при сбоях (Crash Consistency Problem).

Рассмотрим простейшую операцию в классической файловой системе UNIX (например, UFS или ext2): добавление одного нового блока данных размером \(4\,\text{КБ}\) (\(D_{\text{new}}\)) в конец существующего файла. Даже эта тривиальная операция требует обновления как минимум трёх физически разнесённых структур на носителе:

  1. Битовая карта данных (Data Bitmap \(B\)): изменить бит для физического блока \(k\) с \(0\) (свободен) на \(1\) (занят).
  2. Индексный дескриптор (Inode \(I\)): обновить метаданные файла — увеличить размер size, обновить время модификации mtime и записать физический адрес блока \(k\) в массив указателей inode.
  3. Сам блок данных (\(D_{\text{new}}\)): записать реальные \(4\,\text{КБ}\) пользовательских данных в физический сектор блока \(k\).

Здесь мы сталкиваемся с фундаментальной физической асимметрией оборудования: оперативная память (DRAM) быстра, но энергозависима (volatile), тогда как диски и NAND-флеш SSD энергонезависимы (non-volatile), но физически не способны обновить три разнесённых блока \((B, I, D_{\text{new}})\) одновременно в рамках одной атомарной операции. Если питание пропадёт посередине этих трёх записей (\(2^3 - 2 = 6\) состояний частичной записи), после перезагрузки файловая система окажется в состоянии критического разрушения:

Записано на дискПотеряно в RAMВозникающая несогласованность и степень опасности
Только \(D_{\text{new}}\)\(B,\; I\)Безопасная потеря данных: данные лежат на диске, но ни inode, ни битовая карта на них не указывают. Целостность файловой системы не нарушена.
Только \(B\)\(I,\; D_{\text{new}}\)Утечка пространства (Space Leak / блок-сирота): блок \(k\) помечен занятым (\(1\)) в \(B\), но ни один файл на него не ссылается; ёмкость потеряна навсегда.
Только \(I\) (или \(I + D_{\text{new}}\))\(B\)Двойное выделение (Double Allocation — катастрофическое разрушение): inode \(I\) указывает на блок \(k\), но битовая карта \(B\) считает его свободным (\(0\)). Позже другой файл займёт блок \(k\), и оба файла уничтожат данные друг друга!
\(I\) и \(B\)\(D_{\text{new}}\)Утечка чужих старых данных (Stale Data Exposure — критическая уязвимость): inode и битовая карта обновлены, но \(D_{\text{new}}\) не записан. Чтение файла вернёт чужие удалённые данные (пароли, приватные ключи), ранее лежавшие в секторе \(k\)!

2. Почему записи попадают на диск не в том порядке, в котором их отправила программа

В современной компьютерной архитектуре хронологический порядок вызовов write() почти никогда не совпадает с порядком физической записи битов на магнитные пластины или ячейки NAND из-за четырёх уровней энергозависимой буферизации и переупорядочивания (Reordering):

  flowchart TD
    APP["1. Пространство пользователя<br/>Буферы времени выполнения (stdio fwrite) / Буферный пул СУБД"]
    PC["2. Страничный кэш ядра ОС (Page Cache / Dirty Pages)<br/>Отложенная запись (Write-Back): объединяет и задерживает запись на секунды"]
    SCHED["3. Блочный уровень ОС и планировщик ввода-вывода (mq-deadline / BFQ)<br/>Сортирует запросы по LBA-адресам для минимизации времени поиска"]
    DEV["4. Бортовой энергозависимый DRAM-кэш диска и очереди NCQ/NVMe<br/>Контроллер FTL в SSD переупорядочивает запись между каналами NAND"]
    MEDIA[("5. Энергонезависимый физический носитель<br/>Ячейки NAND Flash / Магнитные пластины (Только это переживает сбой питания!)")]

    APP -->|"Системный вызов write()"| PC
    PC -->|"Фоновый сброс / fsync()"| SCHED
    SCHED -->|"Команды NVMe / SATA"| DEV
    DEV -->|"Аппаратный барьер FLUSH CACHE / FUA"| MEDIA

Чтобы гарантировать порядок и долговечность, ОС должна явно отправлять аппаратные барьеры сброса кэша (NVMe Flush, SATA FLUSH CACHE EXT или бит FUA: Force Unit Access), заставляющие накопитель сбросить свой бортовой DRAM-буфер в энергонезависимые ячейки NAND.


3. Поколение 1: Оптимистичное обновление на месте, fsck и Soft Updates

  1. fsck (File System Consistency Check) и предел масштабируемости: Ранние файловые системы UNIX (FFS, ext2) устраняли несогласованность после сбоя путём полного сканирования всех inode, битовых карт и каталогов перед монтированием (fsck). Поскольку время работы fsck пропорционально общей ёмкости диска \(O(N_{\text{disk}})\), проверка современного многотерабайтного массива занимает часы или дни простоя — даже если в момент отключения питания изменялся один-единственный файл размером \(4\,\text{КБ}\)!
  2. Soft Updates (Ganger & McKusick, 1994): Применявшийся во FreeBSD UFS механизм обходился без журнала за счёт строгого отслеживания графа зависимостей указателей в памяти и разрыва циклических зависимостей внутри одного блока с помощью временного отката (roll-back) и наката (roll-forward) отдельных полей в ОЗУ перед записью на диск.

4. Основа современных хранилищ: упреждающее протоколирование (WAL) и журналирование

Чтобы сократить время восстановления с \(O(N_{\text{disk}})\) до объёма активных транзакций в момент сбоя \(O(N_{\text{active}})\) (считанные миллисекунды!), файловые системы (ext3/ext4, XFS, NTFS) и СУБД используют упреждающую запись в журнал (Write-Ahead Logging, WAL / Journaling):

  1. Journal Write: перед изменением основных областей диска (Home Locations) система последовательно добавляет в кольцевой Журнал (Journal) дескриптор начала транзакции TxB (с номером TID) и обновлённые копии блоков \((I, B, D_{\text{new}})\).
  2. Journal Commit (Точка атомарной фиксации): после аппаратного барьера сброса кэша записывается единственный сектор — блок фиксации TxE (Transaction End). Запись этого единственного сектора TxE является атомарным переключателем, фиксирующим всю транзакцию!
  3. Checkpoint: после записи TxE блоки \((I, B, D_{\text{new}})\) асинхронно копируются в свои основные места на диске.
  flowchart LR
    subgraph Journal ["Кольцевой журнал на диске (Последовательная запись)"]
        TXB["1. TxB (TID=42)<br/>Дескриптор"] --> JB["2. Битмап B в журнале"]
        JB --> JI["3. Inode I в журнале"]
        JI --> JD["4. Данные D_new в журнале"]
        JD ==>|"★Аппаратный барьер FLUSH"| TXE["5. TxE (TID=42)<br/>Атомарный блок Commit"]
    end

    subgraph Home ["Основные места на диске (Home Locations)"]
        HB["Основная битовая карта"]
        HI["Основная таблица Inode"]
        HD["Основной блок данных"]
    end

    TXE ==>|"6. Checkpoint (Асинхронная запись)"| HB
    TXE ==>|"6. Checkpoint"| HI
    TXE ==>|"6. Checkpoint"| HD
  • Сбой ДО записи TxE: основные области диска не затронуты; при перезагрузке незавершённая запись в журнале просто отбрасывается.
  • Сбой ПОСЛЕ записи TxE: при перезагрузке система идемпотентно повторно копирует (Redo replay) блоки из журнала в основные места за несколько миллисекунд. Современная ext4 устраняет даже промежуточный барьер за счёт контрольных сумм журнала (CRC32C) внутри TxE.

Чтобы избежать двукратной записи всех данных (\(2\times\) Write Amplification), режим по умолчанию data=ordered в ext4 журналирует только метаданные \((I, B)\), но требует, чтобы блок данных \(D_{\text{new}}\) был полностью записан на своё основное место ДО того, как блок фиксации метаданных TxE попадёт в журнал. В реляционных СУБД протокол ARIES (Mohan et al., 1992) реализует политику Steal / No-Force с метками pageLSN и тремя фазами: Analysis, Redo (Repeating History) и Undo (с компенсационными записями CLR).


5. Поколение 3: Копирование при записи (Copy-on-Write, CoW) и деревья Меркла (ZFS / Btrfs)

Файловые системы нового поколения (ZFS, Btrfs, APFS) полностью запрещают перезапись блоков на месте (In-Place Overwrite) и используют Copy-on-Write (CoW / Теневая страничная организация):

  1. Любой изменённый блок данных \(D_{2,\text{new}}\) и его родительский inode \(I_{v2}\) всегда записываются в новые свободные блоки, оставляя старые блоки \(D_{2,\text{old}}\) нетронутыми.
  2. Когда все новые дочерние блоки записаны на диск, одна атомарная запись переключает корневой блок (Uberblock в ZFS) с \(U_1\) на \(U_2\).
  3. Преимущества: мгновенное восстановление за \(0\) секунд без проигрывания журнала, мгновенные снимки (Snapshots) за \(O(1)\) без копирования и автоматическое самовосстановление (Self-Healing) от тихого повреждения битов (Bit Rot) благодаря деревьям Меркла с 256-битными криптографическими контрольными суммами в каждом родительском указателе.

6. Ловушки fsync() для разработчиков и тестирование методом внесения неисправностей

Даже на журналируемой или CoW-файловой системе замена файла через rename("config.json.tmp", "config.json") без предварительного вызова fsync() приводит к появлению пустых файлов размером \(0\) байт после сбоя питания при отложенном выделении (Delayed Allocation в ext4), так как переименование метаданных фиксируется раньше сброса страниц данных из кэша!

Полный 6-шаговый протокол безопасного атомарного сохранения файла: (1) write() во временный файл \(\to\) (2) fsync(fd) временного файла \(\to\) (3) close(fd) \(\to\) (4) атомарный rename() \(\to\) (5) открытие родительского каталога и fsync(dir_fd) для сохранения записи каталога \(\to\) (6) close(dir_fd).

Как показал скандал fsyncgate в PostgreSQL в 2018 году (когда ядро Linux помечало грязную страницу в ОЗУ как Clean после ошибки ввода-вывода EIO во время fsync(), что было исправлено механизмом errseq_t), современная разработка систем хранения невозможна без фреймворков систематического внесения сбоев (Fault Injection), таких как ALICE и CrashMonkey, проверяющих все допустимые перестановки команд при внезапном отключении питания.

comments powered by Disqus