Persistence · 13 мин

Контент v0.12.0

Журналирование и согласованность при сбоях

Одна операция — несколько записей на диск

В главе про устройство ФС мы видели, что даже простая операция меняет несколько структур в разных местах диска. Возьмём дозапись одного блока в конец файла. Нужно обновить три вещи:

   битмап данных (B)    inode файла (I)          блок данных (D)
   пометить новый блок  размер + указатель на D  сами байты
   занятым

Диск выполняет эти три записи по очереди. Между ними может пропасть питание или упасть ядро ОС. Тогда на диске окажется часть изменений, и файловая система будет несогласованной (inconsistent): её структуры противоречат друг другу. Переупорядочить записи может и сам диск или планировщик ввода-вывода, так что «записали в правильном порядке» ещё не значит «легли в правильном порядке».

Что бывает, если легла только часть

Разберём все варианты, когда из трёх записей B, I, D на диск попали не все.

   легла только D      данные записаны, но inode на них не ссылается и битмап
                       считает блок свободным. Как будто записи не было.
                       Файловая система согласована, данные пропали.
 
   легла только I      inode ссылается на блок D, но в D старый мусор,
                       а битмап считает D свободным. Позже блок выдадут другому
                       файлу, и два файла будут делить один блок.
 
   легла только B      битмап считает блок занятым, но никто на него не
                       ссылается. Утечка места: блок не выдадут никогда.
 
   легли I и B         структуры согласованы, но в D мусор: файл «читается»,
                       а внутри старое содержимое чужого удалённого файла.
 
   легли I и D         inode ссылается на D, данные на месте, но битмап
                       считает D свободным → тот же риск двойного использования.
 
   легли B и D         блок занят и заполнен, но inode о нём не знает → утечка.

Самые опасные случаи — когда inode ссылается на блок, который битмап считает свободным (данные двух файлов будут затирать друг друга), и когда пользователь видит в своём файле чужие данные (утечка информации).

Старый способ: fsck после каждого сбоя

Исторически с этим жили так: после аварийной перезагрузки, до монтирования, запускали fsck (file system check). Утилита проходит по всей файловой системе и сверяет структуры:

   1. прочитать все inode, собрать, какие блоки реально используются
   2. сверить с битмапами, исправить расхождения
   3. проверить каталоги: записи ведут на живые inode, числа ссылок верны
   4. inode без единого имени → в /lost+found

У этого подхода два больших недостатка.

  1. Время. fsck читает всю файловую систему, и время растёт с её размером, а не с объёмом незавершённой работы. Для терабайтного раздела на HDD это десятки минут простоя после каждого сбоя.
  2. Он чинит структуры, а не данные. fsck вернёт согласованность метаданных, но в случае «легли I и B» решит, что всё в порядке, хотя в файле мусор.

Нужен способ, при котором восстановление занимает время, пропорциональное объёму прерванной работы. Это журналирование.

Журналирование: сначала записать намерение

Идея пришла из баз данных. Прежде чем трогать основные структуры, файловая система записывает в отдельную область диска — журнал (journal) — что собирается сделать. И только потом вносит изменения на их места. Если сбой случится посередине, журнал скажет, что нужно доделать. Отсюда название write-ahead logging (WAL): журнал пишется до изменений.

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

Одна запись в журнале — транзакция:

   журнал (отдельная область диска, пишется последовательно)
 
   ┌─────────┬─────┬─────┬─────┬──────────┐
   │ TxBegin │  B  │  I  │  D  │ TxCommit │
   │  TID=5  │     │     │     │  TID=5   │
   └─────────┴─────┴─────┴─────┴──────────┘
 
   1. записать TxBegin и новые версии блоков B, I, D в журнал
   2. дождаться, пока это реально легло на носитель (барьер)
   3. записать TxCommit
   4. дождаться (барьер)
   5. checkpoint: записать B, I, D на их настоящие места
   6. отметить, что транзакция применена, место в журнале можно переиспользовать

Почему commit пишется отдельно, после барьера? Если отправить всё одной пачкой, диск может записать commit раньше, чем блок D (он вправе переупорядочивать запросы). Тогда после сбоя журнал выглядел бы полным, а внутри был бы мусор. А сама запись commit маленькая — помещается в один сектор (512 байт), а запись одного сектора диски выполняют атомарно: она либо целиком легла, либо нет. Поэтому commit — надёжная «печать»: если она есть, всё перед ней на месте.

Этот барьер можно сэкономить, если положить в commit контрольную сумму всей транзакции и писать всё сразу: после сбоя транзакция с неверной суммой считается неполной. В ext4 так работает опция journal_async_commit (по умолчанию выключена).

Перенос изменений из журнала на настоящие места называется checkpoint. Журнал обычно небольшой (у ext4 — от десятков мегабайт до 1 ГБ) и используется по кругу: после checkpoint место транзакции освобождается.

Восстановление: проигрывание журнала

После сбоя файловая система не проверяет весь диск. Она читает только журнал и смотрит на каждую транзакцию:

   транзакция в журнале         что делаем при монтировании
   ─────────────────────────    ───────────────────────────────────────
   есть TxBegin и TxCommit  →   replay: записать B, I, D на их места
                                (даже если уже писали — это те же байты,
                                повтор безопасен)
 
   есть TxBegin, нет Commit →   пропустить: на настоящие места мы пишем
                                только после commit, значит, их не трогали

Посмотрим на два момента сбоя.

   сбой до commit:                   сбой после commit, до checkpoint:
   │Begin│ B │ I │ ...               │Begin│ B │ I │ D │Commit│
   транзакция неполная →             транзакция полная → replay,
   игнорируем; файловая              B, I, D доводятся до своих мест;
   система как до операции           операция выполнена целиком

Главный инвариант: на настоящие места пишется только то, что уже полностью закоммичено в журнал. Поэтому после восстановления операция либо выполнена целиком, либо не выполнена вовсе, промежуточного состояния нет. Это и есть атомарность, которой не хватало.

Сколько длится восстановление? Столько, сколько нужно на чтение журнала — секунды, независимо от того, 100 ГБ раздел или 100 ТБ. Проигрывание повторяет только операции с commit, и это ровно то, что вы реализуете в задаче «WAL: восстановление по журналу».

Что журналировать: три режима ext4

Внимательный читатель заметит, что всё пишется дважды: сначала в журнал, потом на место. Для метаданных это терпимо, они маленькие. Блоки данных бывают огромными, и удваивать запись гигабайтного файла дорого. Поэтому ext4 умеет три режима (опция монтирования data=):

Режим Что в журнале Порядок данных Что может случиться после сбоя
data=journal метаданные и данные данные тоже через журнал ничего неожиданного; всё пишется дважды
data=ordered (по умолчанию) только метаданные данные пишутся на место до коммита метаданных последние изменения данных могут не сохраниться, но мусора в файлах не будет
data=writeback только метаданные порядок не гарантирован структуры целы, но в недавно изменённых файлах может оказаться старое или чужое содержимое

Режим ordered закрывает самый неприятный случай из начала главы — «inode указывает на блок, а в блоке мусор»:

   1. записать блок данных D на его место
   2. только после этого записать B и I в журнал и commit
   3. checkpoint B и I
 
   если inode ссылается на блок, в блоке уже новые данные

XFS и большинство других журналируемых файловых систем журналируют только метаданные и тоже следят за порядком, чтобы не выставлять наружу чужие данные.

Частая ошибка: считать, что журналирование защищает данные приложения. Оно гарантирует, что после сбоя файловая система будет согласованной. Данные, которые лежали в page cache и не прошли fsync, теряются в любом режиме, включая data=journal (транзакции журнала тоже копятся в памяти и сбрасываются раз в несколько секунд — у ext4 по умолчанию раз в 5 секунд, опция commit=). Долговечность конкретной записи обеспечивает только fsync, см. fsync и durability. Журналирование делает fsync дешевле и надёжнее, но не заменяет его.

Это тот же WAL, что в базах данных

Журналирование файловой системы и WAL базы данных — одна идея: сначала последовательный журнал намерений, потом изменения на местах.

   ext4 (журнал jbd2)                 PostgreSQL, etcd, SQLite в режиме WAL
   ────────────────────────────       ──────────────────────────────────────
   TxBegin / блоки / TxCommit    ≈    записи WAL + commit-запись
   барьер после commit           ≈    fsync WAL перед ответом «COMMIT»
   checkpoint на места           ≈    checkpoint: грязные страницы → файлы таблиц
   replay при монтировании       ≈    crash recovery при старте
   журнал по кругу               ≈    старые сегменты WAL удаляются после checkpoint

Что из этого следует на практике:

  • Почему дополнительная запись в журнал ускоряет, а не замедляет? Журнал пишется последовательно, а изменения на местах — разбросаны по диску. Последовательная запись намного дешевле случайной (см. HDD и SSD). Подтвердить коммит можно, как только лёг журнал, а случайные записи выполняются потом, в фоне, пачками.
  • Группировка. Много мелких операций объединяются в одну транзакцию журнала и один барьер. В базах это group commit.
  • Всё держится на честном сбросе. Если диск подтверждает запись журнала, не записав её, журнал бесполезен.

Copy-on-write: ZFS и btrfs

Другой подход к той же проблеме — copy-on-write (COW, копирование при записи): никогда не перезаписывать данные на месте. Новая версия блока пишется в свободное место, потом новая версия блока, который на него ссылается, и так до корня дерева. Последним шагом атомарно переключается указатель на корень.

   было:          root → A → B
   пишем новое:   B' в свободное место
                  A' (ссылается на B') в свободное место
   переключаем:   root → A'
 
   до переключения корня на диске целиком старая версия,
   после — целиком новая

Согласованность получается без журнала: пока корень не переключён, старое дерево цело и нетронуто. Бонусом бесплатны снапшоты: достаточно сохранить старый корень, и старая версия всей файловой системы останется доступна. Минусы — фрагментация (файл, который часто меняют кусками, разбрасывается по диску) и нужда в сборке освободившихся блоков.

Одна оговорка: у COW-систем журнал всё-таки есть, но для другого. Переключать корень на каждый fsync дорого, поэтому ZFS ведёт ZIL (ZFS intent log), а btrfs — log tree: туда быстро записываются синхронные операции, чтобы fsync возвращался, не дожидаясь полного переключения дерева.

Идея «писать новое рядом, а не поверх» продолжается в лог-структурированных файловых системах — там она доведена до предела.

Сводка

fsck Журналирование Copy-on-write
Когда работает после сбоя, при загрузке постоянно + replay при монтировании постоянно
Время восстановления пропорционально размеру ФС пропорционально журналу (секунды) почти мгновенно
Двойная запись нет метаданные всегда; данные в data=journal нет, но пишутся новые версии узлов дерева
Снапшоты нет нет дешёвые
Примеры ext2, FAT ext3, ext4, XFS, NTFS ZFS, btrfs, APFS
Проверь себя· Журналирование ФС

Почему операция «создать файл» опасна при внезапном отключении питания?

В чём суть write-ahead logging (WAL)?

После краша найдены две транзакции: одна с begin и commit, другая с begin, но без commit. Что делает ФС?

Главное преимущество журналирования над fsck:

Что верно про ordered (metadata) journaling?

Сколько раз в full data journaling реально записывается блок данных D (журнал + место)?

Частые ошибки

  • Думать, что журналирование сохраняет данные приложения без fsync.
  • Путать режимы ext4: writeback быстрее, но после сбоя в файлах может оказаться старое или чужое содержимое.
  • Считать, что журнал удваивает стоимость записи и потому медленный: он последовательный, а подтверждать коммит можно сразу после записи журнала.
  • Полагать, что fsck восстанавливает данные. Он восстанавливает согласованность структур.

Проверьте себя

  1. Почему fsck работает тем дольше, чем больше диск, а replay журнала — нет?
  2. Почему commit-запись пишут после барьера, а не вместе с транзакцией?
  3. Чем data=ordered защищает от появления в файле чужих данных?
  4. После сбоя в журнале три транзакции: T1 (Begin, B, I, Commit), T2 (Begin, I, D, Commit), T3 (Begin, B). Checkpoint успел выполниться только для T1. Что сделает ФС с каждой и почему повторная запись блоков T1 ничего не сломает?
Показать ответы

Ответы. (1) fsck проверяет все структуры ФС, а replay читает только журнал, размер которого не зависит от диска. (2) Диск может переупорядочить записи и положить commit раньше содержимого транзакции; после сбоя такая транзакция выглядела бы полной. (3) Данные записываются на место раньше, чем коммитятся ссылающиеся на них метаданные, поэтому inode никогда не указывает на блок со старым содержимым. (4) T1 и T2 будут проиграны (у T1 место в журнале могло быть уже освобождено, тогда её просто нет), T3 пропущена: у неё нет commit. Повтор T1 записывает на места те же байты, что уже там лежат, результат не меняется.

Что спрашивают на собесе

  • Почему создание файла не атомарно? Это несколько записей в разные места диска (битмапы, inode, каталог, данные); сбой посередине оставляет ФС несогласованной.
  • Что такое write-ahead logging? Сначала намерение записывается в журнал и фиксируется commit-записью, потом изменения применяются на местах. После сбоя проигрываются только транзакции с commit.
  • Почему commit пишется отдельно? Чтобы диск не записал его раньше содержимого транзакции; сама commit-запись умещается в атомарно записываемый сектор.
  • Чем журнал лучше fsck? Время восстановления зависит от размера журнала, а не диска, и согласованность гарантируется построением, а не эвристикой.
  • Режимы ext4? journal — всё через журнал; ordered (по умолчанию) — только метаданные, данные пишутся раньше них; writeback — только метаданные, без порядка.
  • Защищает ли журнал данные приложения? Нет. Без fsync последние записи теряются в любом режиме.
  • Как журналирование ФС связано с WAL в базах? Это одна идея: последовательный журнал, барьер, checkpoint, replay.
  • Чем COW отличается? Не перезаписывает на месте, атомарно переключает корень, даёт дешёвые снапшоты; платит фрагментацией.

Продолжить