1. Введение: минное поле внутри добавления одного блока данных в 4 КБ
Когда вы нажимаете Ctrl+S в текстовом редакторе или подтверждаете банковский перевод, ваше приложение вызывает системный вызов write(). С точки зрения прикладного программиста сохранение файла выглядит как одно мгновенное, неделимое (атомарное) действие.
Однако внутри ядра операционной системы, файловой системы, движка базы данных и контроллера SSD скрывается одна из сложнейших проблем системного программирования — проблема согласованности при сбоях (Crash Consistency Problem).
Рассмотрим простейшую операцию в классической файловой системе UNIX (например, UFS или ext2): добавление одного нового блока данных размером $4\,\text{КБ}$ ($D_{\text{new}}$) в конец существующего файла. Даже эта тривиальная операция требует обновления как минимум трёх физически разнесённых структур на носителе:
- Битовая карта данных (Data Bitmap $B$): изменить бит для физического блока $k$ с $0$ (свободен) на $1$ (занят).
- Индексный дескриптор (Inode $I$): обновить метаданные файла — увеличить размер
size, обновить время модификацииmtimeи записать физический адрес блока $k$ в массив указателей inode. - Сам блок данных ($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
fsck(File System Consistency Check) и предел масштабируемости: Ранние файловые системы UNIX (FFS, ext2) устраняли несогласованность после сбоя путём полного сканирования всех inode, битовых карт и каталогов перед монтированием (fsck). Поскольку время работыfsckпропорционально общей ёмкости диска $O(N_{\text{disk}})$, проверка современного многотерабайтного массива занимает часы или дни простоя — даже если в момент отключения питания изменялся один-единственный файл размером $4\,\text{КБ}$!- 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):
- Journal Write: перед изменением основных областей диска (Home Locations) система последовательно добавляет в кольцевой Журнал (Journal) дескриптор начала транзакции
TxB(с номеромTID) и обновлённые копии блоков $(I, B, D_{\text{new}})$. - Journal Commit (Точка атомарной фиксации): после аппаратного барьера сброса кэша записывается единственный сектор — блок фиксации
TxE(Transaction End). Запись этого единственного сектораTxEявляется атомарным переключателем, фиксирующим всю транзакцию! - 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 / Теневая страничная организация):
- Любой изменённый блок данных $D_{2,\text{new}}$ и его родительский inode $I_{v2}$ всегда записываются в новые свободные блоки, оставляя старые блоки $D_{2,\text{old}}$ нетронутыми.
- Когда все новые дочерние блоки записаны на диск, одна атомарная запись переключает корневой блок (Uberblock в ZFS) с $U_1$ на $U_2$.
- Преимущества: мгновенное восстановление за $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, проверяющих все допустимые перестановки команд при внезапном отключении питания.
