GraphLMS

ОС
Начать

ОС · Persistence · 15 мин

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

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

Представь простую вещь: ты создаёшь новый файл. Кажется, что это одно действие. Но для файловой системы (ФС) это несколько отдельных записей в разные места диска. Вспомним из главы Реализация файловой системы, что на диске лежат:

  • inode — карточка файла (размер, права, где лежат блоки данных);
  • битмапы (bitmap) — карты «занято/свободно» для inode и блоков данных;
  • блок каталога (directory) — таблица «имя файла → номер inode».

Чтобы добавить в файл один новый блок данных, ФС обязана обновить минимум три места:

   Операция «дописать блок в файл» = 3 записи в РАЗНЫЕ места диска
 
   ┌───────────────┐   ┌───────────────┐   ┌───────────────┐
   │  data bitmap  │   │     inode     │   │   блок данных │
   │  (B)          │   │     (I)       │   │   (D)         │
   │ пометить блок │   │ +размер,      │   │ записать сами │
   │ как занятый   │   │ +ссылка на D  │   │ байты         │
   └───────────────┘   └───────────────┘   └───────────────┘
        запись 1            запись 2            запись 3

Что нарисовано: одно логическое действие пользователя разваливается на три физические записи в разные сектора диска. Диск выполняет их по очереди, а не мгновенно и не атомарно.

И вот здесь начинается беда. Диск может записать одну из трёх, а потом — внезапное отключение питания или паника ядра. Часть записей легла, часть нет. ФС оказывается в несогласованном (inconsistent) состоянии.

Что такое «битая ФС»: разбираем по сценариям

Атомарность (atomicity) — это свойство «всё или ничего». У нас её нет: три записи независимы. Посмотрим, что бывает, если краш случился, когда легли не все.

   Что реально успело лечь на диск перед крашем:
 
   успело: D        → данные есть, но никто на них не ссылается.
                      inode о блоке не знает. Просто потеря места
                      (block leak). Не страшно, но обидно.
 
   успело: I        → inode говорит «у меня есть блок D»,
                      но bitmap считает D свободным.
                      ОПАСНО: ФС позже отдаст этот же блок
                      другому файлу → два файла пишут в один блок.
 
   успело: B        → bitmap говорит «занято», но никто не ссылается.
                      Блок навсегда потерян (его не выдадут, но и не
                      используют). Утечка места.
 
   успело: I и B    → согласовано по учёту, но D содержит мусор.
                      Файл «есть», читается, но внутри — старый хлам.

Что нарисовано: разные комбинации частично выполненных записей дают разные виды порчи — от безобидной утечки места до опасного двойного использования блока, когда данные одного файла затирают данные другого. Сам по себе диск не знает, что три записи связаны, — для него это три не зависящих друг от друга запроса.

Старый способ: fsck — чинить всё после загрузки

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

   fsck: полный обход диска при загрузке
 
   ┌────────────────────────────────────────────────┐
   │ 1. Прочитать ВСЕ inode                          │
   │ 2. Построить, какие блоки реально используются  │
   │ 3. Сверить с bitmap, починить несоответствия    │
   │ 4. Проверить ссылки в каталогах, счётчики ссылок│
   │ 5. Потерянные inode → в /lost+found             │
   └────────────────────────────────────────────────┘


   диск на 2 ТБ → проверка может идти ДЕСЯТКИ МИНУТ

Что нарисовано: fsck сканирует весь диск целиком, потому что не знает, где именно произошёл сбой. Два больших минуса:

  1. Медленно. Время проверки растёт вместе с размером диска, а не с объёмом незавершённой работы. Для серверной ФС на терабайты это неприемлемо — система после краша лежит десятки минут.
  2. Чинит учёт, а не данные. fsck восстановит согласованность метаданных (bitmap сойдётся с inode), но твои данные он не вернёт. В сценарии «успело I и B» он решит, что всё в порядке, хотя внутри блока — мусор.

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

Журналирование = write-ahead logging

Главная идея простая, и она пришла из баз данных. Перед тем как трогать «настоящие» структуры ФС, мы сначала записываем в отдельную область — журнал (journal, он же log) — что собираемся сделать. И только потом вносим изменения на места. Отсюда название write-ahead logging (WAL) — «запись с опережением».

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

Транзакция (transaction) в журнале выглядит так:

   Журнал (отдельная зона диска), пишется ПОСЛЕДОВАТЕЛЬНО:
 
   ┌─────────┬─────┬─────┬─────┬──────────┐
   │ TxBegin │  B  │  I  │  D  │ TxCommit │
   │ (TID=5) │     │     │     │  (TID=5) │
   └─────────┴─────┴─────┴─────┴──────────┘
       │       └──── что хотим записать ───┘    │
       начало                                 «всё записано целиком»
 
   Шаги:
   1) пишем begin + сами изменения (B, I, D) в журнал
   2) barrier/flush — ждём, пока это реально осело на диск
   3) пишем commit-запись (маленькую, атомарную)
   4) barrier
   5) ТЕПЕРЬ применяем (checkpoint): копируем B, I, D на их места в ФС

Что нарисовано: транзакция в журнале обрамлена маркерами TxBegin и TxCommit. Commit-запись — это «печать»: она означает, что вся транзакция целиком успела лечь в журнал. Только наличие корректного commit делает запись «настоящей».

Шаг «применить на места» называется checkpoint (контрольная точка) — перенос изменений из журнала в основную ФС.

Replay: как журнал спасает после краша

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

   Восстановление = проигрывание (replay) журнала
 
   Транзакция в журнале    Что делаем при загрузке
   ─────────────────────   ─────────────────────────────────────
   begin … commit есть  →  REPLAY: заново применить B,I,D на места
                           (даже если уже применяли — не страшно,
                            запись идемпотентна: пишем те же байты)
 
   begin … commit НЕТ   →  пропустить. Транзакция не зафиксирована,
                           на «настоящих» местах ФС её точно нет
                           (мы же применяем ТОЛЬКО после commit).

Что нарисовано: правило восстановления зависит ровно от одного факта — есть ли commit. Разберём два момента краша:

   Краш ДО commit:                 Краш ПОСЛЕ commit, но ДО checkpoint:
   ┌─────┬───┬───┬─────────┐       ┌─────┬───┬───┬───┬────────┐
   │begin│ B │ I │ (нет    │       │begin│ B │ I │ D │ commit │
   │     │   │   │  commit)│       └─────┴───┴───┴───┴────────┘
   └─────┴───┴───┴─────────┘                │
        │                                   ▼
        ▼                          транзакция полная → REPLAY,
   игнорируем. ФС нетронута,       доводим B,I,D до их мест.
   данные пользователя не          Данные сохранены целиком.
   подтверждены, но ФС цела.

Ключевой инвариант: на настоящие места ФС мы пишем только после полного commit в журнал. Поэтому либо в журнале есть вся транзакция (и мы её докатываем), либо её там нет целиком (и в ФС её тоже нет). Промежуточного «полу-состояния» в основной ФС быть не может. Это и даёт атомарность, которой не хватало вначале.

И главное про скорость: replay проходит только по журналу, а журнал маленький (обычно несколько десятков-сотен МБ, по кругу). Восстановление занимает секунды вне зависимости от того, диск у тебя на 100 ГБ или на 100 ТБ.

Ordered (metadata) vs full data journaling

Заметил неприятное? Мы пишем каждое изменение дважды: сперва в журнал, потом на место. Для данных файла (блоки D могут быть огромными) это удвоение трафика — дорого. Поэтому существует компромисс.

Режим Что попадает в журнал Скорость Надёжность данных
Full data journaling метаданные (B, I) и данные (D) медленно (всё пишется 2×) максимальная: и ФС цела, и данные целы
Ordered / metadata journaling только метаданные (B, I) быстро (D пишется 1×) ФС всегда цела; данные могут быть старыми

В режиме ordered (он же metadata journaling — по умолчанию в ext4) есть тонкость с порядком. Чтобы не получить «опасный» случай из начала главы (inode ссылается на блок D, а в D — мусор от прежнего файла), правило такое:

   Порядок записи в режиме ordered journaling:
 
   1) сначала пишем сами ДАННЫЕ D на их место в ФС
   2) ТОЛЬКО потом пишем метаданные (B,I) в журнал + commit
   3) потом checkpoint метаданных
 
   Гарантия: если inode ссылается на блок, то в блоке —
   уже актуальные данные, а не чужой мусор.

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

Мост в бэкенд: это ровно WAL в базах данных

Если ты собеседуешься на backend/Go-позицию, держи главный мостик: журналирование ФС и WAL в базах — это одна и та же идея. «Сначала лог, потом данные».

   ФС (ext4 journal)           База данных (Postgres WAL,
                               etcd/raft log, SQLite WAL)
   ─────────────────           ───────────────────────────
   begin / записи / commit  ≈  WAL-запись + LSN + commit record
   checkpoint на места ФС   ≈  checkpoint: грязные страницы → heap
   replay журнала при старте ≈ crash recovery: проигрывание WAL
   barrier/flush журнала    ≈  fsync на WAL перед подтверждением COMMIT

Что нарисовано: одни и те же приёмы под разными именами. Несколько практических выводов, которые любят спрашивать:

  • Почему запись в WAL быстрая, хотя это «лишняя» запись? Журнал пишется последовательно (sequential), а это самый быстрый режим и для HDD, и для SSD. А вот раскидывание B/I/D по их местам — это случайные (random) записи. Лог амортизирует их: много мелких случайных правок сначала копятся в последовательном логе.
  • Группировка транзакций (batching). Несколько подряд идущих операций можно закоммитить одной commit-записью и одним flush — меньше дорогих сбросов на диск.
  • Durability держится на flush. Commit «настоящий», только когда журнал физически осел на носитель. За это отвечает fsync/barrier — см. fsync и долговечность записи. Без честного flush журнал бесполезен: контроллер диска может держать запись в своём кэше.

Альтернатива: copy-on-write (ZFS, btrfs)

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

   COW: не трогаем старое, пишем новое рядом, потом переключаем корень
 
   было:  root ──► A ──► B(старый)
 
   пишем: root ──► A ──► B(старый)
                          B'(новый) ◄── записали в свободное место
                   A'(новый, ссылается на B') ◄── тоже новый
 
   атомарный шаг: root ─────────► A'
 
   До переключения корня видна только старая консистентная версия.
   После — только новая. Промежутка нет. Журнал не нужен.

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

Итоговое сравнение

Свойство fsck Journaling (WAL) Copy-on-write
Когда работает после краша, при загрузке постоянно (пишет лог) + replay при старте постоянно (пишет рядом)
Время восстановления ∝ размеру диска (минуты-десятки минут) ∝ размеру журнала (секунды) мгновенно (корень либо старый, либо новый)
Двойная запись нет да (или 1× в metadata-режиме) нет (но фрагментация и нужен GC)
Спасает данные нет (только учёт) да в full-режиме; метаданные всегда да
Снапшоты нет нет да, дёшево
Примеры ранний ext2, FAT ext3/ext4, NTFS, XFS ZFS, btrfs
Проверь себя· Журналирование ФС

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

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

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

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

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

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

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

  • Почему «создать файл» — не атомарная операция и чем это грозит? Это несколько независимых записей (inode, bitmap, каталог/данные) в разные места диска; краш посередине оставляет ФС в несогласованном состоянии — от утечки блока до опасного двойного использования блока.
  • Что такое write-ahead logging и почему «сначала лог»? Сначала пишем намерение в журнал и фиксируем commit, и только после этого применяем на места. После краша проигрываем (replay) только транзакции с commit; без commit — игнорируем. Это даёт атомарность «всё или ничего».
  • Чем journaling лучше fsck? Время восстановления зависит от размера журнала, а не диска (секунды вместо десятков минут), и оно опирается на корректность по построению, а не на эвристическую починку.
  • Ordered vs full data journaling — в чём компромисс? Full журналирует и данные (надёжно, но всё пишется дважды); ordered журналирует только метаданные, а данные пишет один раз на место перед коммитом метаданных — быстрее, но последняя запись данных может потеряться, хотя ФС останется целой.
  • Как journaling ФС связан с WAL в базах (Postgres, etcd/raft)? Это одна идея: последовательный лог намерений + checkpoint + crash recovery через replay. Durability commit-а держится на честном fsync/barrier журнала.
  • Чем COW (ZFS/btrfs) отличается от журналирования? COW не перезаписывает на месте: пишет новые версии рядом и атомарно переключает корень дерева — журнал не нужен, плюс дешёвые снапшоты, но появляется фрагментация и потребность в сборке мусора.
fsync и durability: почему теряются данныеLog-structured FS и мост к LSM-деревьям