ОС · 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 сканирует весь диск целиком, потому что не знает, где именно произошёл сбой. Два больших минуса:
- Медленно. Время проверки растёт вместе с размером диска, а не с объёмом незавершённой работы. Для серверной ФС на терабайты это неприемлемо — система после краша лежит десятки минут.
- Чинит учёт, а не данные. 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 не перезаписывает на месте: пишет новые версии рядом и атомарно переключает корень дерева — журнал не нужен, плюс дешёвые снапшоты, но появляется фрагментация и потребность в сборке мусора.