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