ОС · Persistence · 14 мин
Log-structured FS и мост к LSM-деревьям
Радикальная идея: писать только в конец
Обычная файловая система пишет данные на место (in-place): у файла есть свои блоки на диске, и когда ты меняешь байты в середине, ФС перезаписывает именно те блоки, где они лежат. Заодно надо обновить и метаданные — например, отметку «файл изменён» в его inode (это структура с информацией о файле: размер, права, указатели на блоки данных; разбирали в главе про реализацию ФС). Получается, что одно маленькое изменение порождает несколько записей в разные места диска: сюда блок данных, туда inode, ещё в третье место — битовую карту занятости.
Log-structured File System (LFS, «лог-структурированная ФС») предлагает поступить наоборот. Никаких перезаписей на месте вообще. Любое изменение — это дозапись в конец одного растущего лога. Лог тут — журнал в буквальном смысле: бесконечная лента, куда новые записи добавляются только в хвост, как строчки в дневник.
Бытовая аналогия. Представь бухгалтерскую книгу. Можно вести её так: нашёл нужную строку, стёр ластиком старую сумму, вписал новую (in-place). А можно так: никогда ничего не стираешь, а просто дописываешь снизу новую строку «по счёту №5 теперь такая-то сумма». Актуальное значение — это всегда последняя запись про данный счёт. Вторая модель и есть лог.
ЗАПИСЬ НА МЕСТО (in-place): ЗАПИСЬ В ЛОГ (append-only):
меняем блок B17 файла foo меняем блок B17 файла foo
│ │
┌────▼─────────────────────┐ ┌─────▼──────────────────────────┐
│ ...│B17(старое→новое)│... │ │ старое │...│ [B17 новое] ◄─ хвост│
└──────────────────────────┘ └────────────────────────────────┘
позиционируем головку точно всегда пишем в КОНЕЦ, подряд,
на B17 → случайная запись большими пачками → последовательноЧто нарисовано: слева головка диска (или контроллер) каждый раз прыгает на точный адрес старого блока — это случайная запись (random write). Справа все записи сыплются подряд в один растущий конец — последовательная запись (sequential write).
Почему «в конец» — это быстро
Главный аргумент LFS — про производительность записи, и он живёт до сих пор.
На HDD (жёсткий диск с вращающимися пластинами; см. главу про HDD) самое дорогое — это seek, перемещение головки на нужную дорожку, и ожидание поворота пластины. Случайные записи в разные места = постоянные seek'и, диск тратит время на механику, а не на данные. Последовательная же запись идёт одним потоком без seek'ов и выжимает из диска максимум.
На SSD (твердотельный накопитель; см. главу про SSD) пластин нет, но есть своя беда: ячейки нельзя перезаписать «поверх». Чтобы записать в уже занятую страницу, надо сначала стереть весь блок (а блок — это много страниц). Мелкие случайные записи провоцируют write amplification (усиление записи) — контроллер внутри перетасовывает данные, изнашивая ячейки. Последовательная запись большими кусками ложится ровно и щадит флеш.
почему последовательная запись выигрывает:
HDD: random → seek seek seek seek (головка скачет, механика тормозит)
seq → ───────────────────► (один проход, без скачков) БЫСТРО
SSD: random → стереть блок → записать → износ ячеек (write amplification)
seq → заполняем блоки целиком, ровно ЩАДЯЩЕЧто нарисовано: и на магнитном диске, и на флеше последовательная запись принципиально дешевле случайной. LFS превращает все записи в последовательные — в этом её суть.
LFS ещё и копит изменения в памяти, собирая их в большой сегмент (segment) — непрерывный кусок лога (например, несколько мегабайт), — и пишет его на диск целиком одной операцией. Это та же идея, что батчинг записей в журнале (см. главу про журналирование): много мелких изменений → одна крупная последовательная запись.
А как теперь найти данные? imap
Тут всплывает проблема. Если inode каждый раз переписывается в новое место хвоста лога, то как его найти? В обычной ФС inode номер N лежит по фиксированному адресу (массив inode). В LFS inode «гуляет» по логу.
Решение — inode map (imap), карта inode: таблица, которая отображает «номер inode → текущий адрес inode в логе». Когда inode перезаписывается в новое место, обновляется и его запись в imap. Сама imap тоже пишется в лог (по кусочкам), а чтобы её саму найти, есть одно фиксированное место на диске — checkpoint region (контрольная область), которое периодически указывает на актуальные куски imap.
ЦЕПОЧКА ПОИСКА БЛОКА ФАЙЛА в LFS:
[фикс. место] [в логе] [в логе] [в логе]
checkpoint ───► imap ───► inode #N ───► блок данных D
region (№N→адрес) (адреса блоков) (то, что читаем)
читаем файл #N:
1) checkpoint → находим актуальные куски imap
2) imap[N] → адрес inode #N в логе
3) inode #N → адреса блоков данных
4) блок D → собственно данныеЧто нарисовано: чтение в LFS — это короткая цепочка указателей. Только одно звено (checkpoint region) живёт по фиксированному адресу; всё остальное «плавает» в логе, а imap связывает стабильные номера inode с их плавающими адресами. На практике imap держат в памяти, поэтому лишних чтений с диска нет.
Старые версии и segment cleaning
Раз мы никогда не перезаписываем на месте, а только дописываем — старые версии блоков остаются лежать в логе мёртвым грузом. Изменил блок десять раз — в логе девять устаревших копий и одна живая. Рано или поздно диск забьётся мусором.
Значит, нужна уборка. В LFS она называется segment cleaning (чистка сегментов) — это, по сути, сборка мусора (garbage collection). Работает так: берём несколько старых сегментов, смотрим, какие блоки в них живые (на них всё ещё указывает актуальный inode), а какие мёртвые (перекрыты более новой версией). Живые блоки переписываем в новый сегмент в хвосте лога, а освободившиеся старые сегменты помечаем как свободные — туда можно писать заново.
SEGMENT CLEANING (уплотнение):
ДО — три полузанятых сегмента (Ж=живой блок, ✗=мёртвый):
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Ж ✗ ✗ Ж ✗ Ж │ │ ✗ Ж ✗ ✗ Ж ✗ │ │ Ж ✗ Ж ✗ ✗ ✗ │
└───────────────┘ └───────────────┘ └───────────────┘
сегмент 1 сегмент 2 сегмент 3
читаем живые (Ж), складываем плотно в новый сегмент в хвосте:
ПОСЛЕ:
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ свободен │ │ свободен │ │ Ж Ж Ж Ж Ж Ж │ ◄─ новый, плотный
└───────────────┘ └───────────────┘ └───────────────┘
сегменты 1 и 2 теперь свободны → снова доступны под записьЧто нарисовано: разрозненные живые блоки из трёх дырявых сегментов собрали в один плотный, а освобождённые сегменты вернули в оборот. Это ровно то, что делает дефрагментация, но как фоновый непрерывный процесс. Как определить, жив ли блок? В заголовке сегмента LFS хранит, какому inode и какому смещению принадлежит каждый блок; сверяемся с актуальным inode через imap — совпал адрес, значит блок живой.
Цена уборки — дополнительная запись (живые блоки копируются повторно). Это плата за то, что основная запись всегда последовательная. Запомни компромисс: дешёвая запись сейчас → отложенная работа по уборке потом.
Где здесь надёжность: связь с journaling и fsync
LFS и журналируемые ФС — близкие родственники, но не одно и то же.
В журналируемой ФС (см. журналирование) лог — это временный буфер: сначала пишем намерение в журнал, потом применяем изменения на их постоянные места (checkpoint), журнал переиспользуется по кругу. То есть данные в итоге живут на месте, журнал — лишь страховка от падения посреди записи.
В LFS лог — это и есть сама файловая система. Постоянного «места» у блоков нет, весь диск — один большой лог.
JOURNALING FS LOG-STRUCTURED FS
┌──────────┐ ┌──────────────┐ ┌────────────────────────────┐
│ ЖУРНАЛ │──►│ ОСНОВНОЕ МЕСТО│ │ ЛОГ = ВСЯ ФС (хвост ►) │
│(временно)│ │ (постоянно) │ │ данные живут прямо в логе │
└──────────┘ └──────────────┘ └────────────────────────────┘
лог — страховка, потом сброс лог — конечное хранилищеЧто нарисовано: в journaling лог отдельно от данных и недолговечен; в LFS лог и есть хранилище. Восстановление после сбоя в LFS опирается на checkpoint region (последняя согласованная точка) плюс «проигрывание» (roll-forward) сегментов, записанных после неё.
При этом обе системы упираются в одну и ту же физику долговечности. Запись «в конец лога» всё равно сначала оседает в кэше диска, и пока не выполнен fsync (см. fsync и долговечность), данные могут потеряться при потере питания. Последовательность лога упрощает порядок, но не отменяет необходимости форсировать сброс на устройство.
Мост к бэкенду: это и есть LSM-tree
Здесь начинается самое важное для собеса. Идея «пиши всё в лог, потом уплотняй» никуда не делась — она переехала в базы данных и называется LSM-tree (Log-Structured Merge tree, лог-структурированное дерево слияния). На ней построены RocksDB, Pebble (движок CockroachDB), LevelDB, Cassandra, ScyllaDB, движки ClickHouse. Если ты работаешь с любым из них — ты работаешь с идеями LFS.
Как устроена запись в LSM:
- Запись сначала идёт в WAL (Write-Ahead Log, журнал упреждающей записи) — тот самый append-only лог на диске, чисто для надёжности (как journaling).
- Одновременно запись попадает в memtable — сортированную структуру в оперативной памяти (обычно дерево). Это быстро, пишем в RAM.
- Когда memtable наполнилась, её сбрасывают (flush) на диск одним последовательным куском — получается SSTable (Sorted String Table), неизменяемый отсортированный файл-сегмент.
- SSTable'ов со временем накапливается много, в них копятся устаревшие версии ключей. Фоновая компакция (compaction) сливает несколько SSTable в новые, выкидывая мёртвые версии. Это тот же самый segment cleaning из LFS.
LSM-TREE — путь записи:
write(key,val)
│
┌────▼─────┐ append ┌──────────────────┐
│ memtable │◄────────────│ WAL (на диске) │ ← надёжность
│ (в RAM, │ └──────────────────┘ при падении
│сортиров.)│
└────┬─────┘
│ заполнилась → flush одним послед. куском
▼
уровень L0: [SSTable] [SSTable] [SSTable] (отсортир., неизменяемые)
│ compaction (слияние + выброс мёртвых версий)
▼
уровень L1: [ SSTable крупнее, без дублей ]
│ compaction
▼
уровень L2: [ ещё крупнее ... ]Что нарисовано: запись летит в RAM (memtable) и в WAL для страховки; наполнившись, memtable сбрасывается в отсортированный сегмент SSTable; фоновая компакция уплотняет сегменты по уровням, удаляя устаревшие версии ключей. Сравни с предыдущей схемой segment cleaning — это буквально та же механика, только над парами ключ-значение.
Соответствие один в один:
LFS ⇄ LSM-tree
─────────────────────────────────────────────────────
лог сегментов ⇄ WAL + поток SSTable
imap (номер→адрес) ⇄ индексы/манифест SSTable
segment cleaning ⇄ compaction
мёртвые старые версии блоков ⇄ устаревшие версии ключей
последовательная запись ⇄ flush memtable одним кускомWrite-optimized vs read-optimized: LSM vs B-tree
Классическая альтернатива LSM — B-tree (сбалансированное дерево, на нём построены PostgreSQL, MySQL/InnoDB, многие индексы). B-tree обновляет данные на месте: нашёл нужную страницу-узел, переписал. Это та самая in-place модель, с которой мы начали главу.
Отсюда фундаментальный компромисс, который обожают на собесах:
- LSM = write-optimized. Запись дешёвая (последовательная, в память + лог), но чтение дороже: ключ может лежать в memtable или в любом из множества SSTable, приходится проверять несколько мест (спасают bloom-фильтры и индексы). Плюс read amplification и фоновая нагрузка от компакции.
- B-tree = read-optimized. Чтение дешёвое (спустился по дереву за O(log n) к одной странице), но запись дороже: случайные in-place обновления страниц, write amplification на уровне страниц.
LSM (write-opt) B-TREE (read-opt)
─────────────── ─────────────────
write: → RAM + лог (послед.) ДЁШЕВО write: in-place в страницу СЛУЧАЙНО
read: ищем в memtable + N ДОРОЖЕ read: спуск по дереву к 1 ДЁШЕВО
SSTable (+bloom-фильтры) страницеЧто нарисовано: две зеркальные стратегии. LSM жертвует скоростью чтения ради дешёвой записи; B-tree — наоборот. Выбор движка под нагрузку — это выбор, чего у тебя больше: записей или чтений.
| Критерий | LSM-tree | B-tree |
|---|---|---|
| Модель записи | append-only в лог, потом компакция | in-place в страницу |
| Тип записи на диск | последовательная | случайная |
| Оптимизирован под | запись (write-heavy) | чтение (read-heavy) |
| Стоимость чтения | выше: несколько SSTable + bloom | ниже: спуск к одной странице |
| Write amplification | от компакции (фоном) | от обновления страниц |
| Space amplification | устаревшие версии до компакции | фрагментация страниц |
| Фоновая работа | компакция (segment cleaning) | минимум |
| Примеры | RocksDB, Pebble, Cassandra, LevelDB | PostgreSQL, InnoDB, BoltDB |
| Корни идеи | LFS (этот раздел) | классические on-disk деревья |
В чём ключевая идея log-structured файловой системы?
Зачем в LFS нужна imap (inode map)?
Что делает segment cleaning в LFS?
Какие соответствия между LFS и LSM-tree верны?
Что верно про сравнение LSM-tree и B-tree?
Сколько фиксированных (не плавающих по логу) мест нужно LFS, чтобы запустить поиск любого файла с диска после перезагрузки?
Что спрашивают на собесе
- В чём радикальная идея LFS? Никаких перезаписей на месте — всё пишется только дозаписью в конец одного растущего лога, что превращает любые записи в последовательные (быстрые и для HDD, и щадящие для SSD).
- Если inode постоянно переезжает в новое место лога, как его найти? Через imap (inode map): таблицу «номер inode → текущий адрес в логе». Её корень закреплён в фиксированной checkpoint region, саму imap обычно держат в памяти.
- Что такое segment cleaning и зачем он нужен? Сборка мусора: старые версии блоков накапливаются мёртвым грузом, поэтому живые блоки из дырявых сегментов переписывают плотно в новый сегмент, а освободившиеся возвращают под запись.
- Чем LFS отличается от журналируемой ФС? В journaling лог — временный буфер, данные потом ложатся на постоянные места (checkpoint). В LFS лог и есть само хранилище, постоянного «места» у блоков нет.
- Как LFS связана с LSM-tree? Это одна идея: WAL + memtable → flush в SSTable ↔ запись в лог; compaction ↔ segment cleaning; устаревшие версии ключей ↔ мёртвые блоки. RocksDB/Cassandra/LevelDB — прямые наследники LFS.
- LSM vs B-tree — когда что? LSM write-optimized (дешёвая последовательная запись, дороже чтение из-за множества SSTable) — под write-heavy нагрузки. B-tree read-optimized (in-place, быстрый спуск к одной странице) — под read-heavy.