Перейти к содержанию

Внутреннее устройство файловых систем

Файловая система превращает байты на блочном устройстве в иерархию каталогов и файлов с метаданными. От её внутренней структуры зависит почти всё: насколько быстро открывается файл в каталоге с миллионом записей, как поведёт себя система при внезапном отключении питания, сколько накладных расходов на маленькие файлы, поддерживается ли snapshot. Эта статья разбирает устройство ext4 в подробностях и кратко сравнивает его с btrfs, XFS, ZFS и tmpfs.

ext4: layout на диске

ext4 делит раздел на блочные группы (block groups). Каждая группа содержит копию суперблока (для устойчивости), дескрипторы групп, битмапы, таблицу inode и сами блоки данных. Идея — локализация: метаданные файла и его данные лежат в одной группе, чтобы головка диска (или контроллер SSD) меньше двигалась.

  Раздел / partition
  ┌──────────┬───────────────────────────────────────────────────────┐
  │   boot   │              ext4 filesystem                          │
  │  sector  │                                                       │
  └──────────┴───────────────────────────────────────────────────────┘
  ┌─────────────┬─────────────┬─────────────┬───────┬─────────────┐
  │  block      │  block      │  block      │  ...  │  journal    │
  │  group 0    │  group 1    │  group 2    │       │  (особый    │
  │             │             │             │       │   inode #8) │
  └──────┬──────┴─────────────┴─────────────┴───────┴─────────────┘
         ▼  одна block group (обычно 32768 блоков по 4 KB = 128 MB)
  ┌─────────────┬──────────────┬───────────────┬────────────┬─────────────┐
  │  superblock │ group desc.  │ block bitmap  │ inode      │ data blocks │
  │  (копия)    │ table        │ + inode bitmap│ table      │             │
  │  опционально│ (копия)      │ (по 1 блоку)  │ (N блоков) │ (остальное) │
  └─────────────┴──────────────┴───────────────┴────────────┴─────────────┘

Что лежит где:

Структура Что хранит
Superblock глобальные параметры ФС: размер блока, число inode, версия, флаги, UUID, mount count
Group descriptor для каждой группы: адреса bitmap'ов и inode table, число свободных блоков/inode
Block bitmap один бит на блок данных в группе: занят или свободен
Inode bitmap один бит на inode в группе
Inode table массив inode-структур (по 256 байт каждая в ext4 по умолчанию)
Data blocks сами блоки данных файлов и каталогов
Journal отдельный inode (по умолчанию #8), содержащий журнал изменений

Sparse superblock (default) — копии superblock и group descriptor table лежат не во всех группах, а только в номерах, кратных степеням 3, 5, 7 (плюс 0 и 1). Это экономит место на больших разделах.

Resize позволяет расширять ФС онлайн: ext4 хранит в superblock резерв блочных групп (s_reserved_gdt_blocks), куда можно добавить новые group descriptors без переноса данных.

Inode: метаданные файла

Inode (index node) — структура размером 256 байт (в ext4; 128 в ext2/3), содержащая всё о файле, кроме имени:

  ┌──────────────────────────────────────────┐
  │ st_mode         (тип + права)            │  2 байта
  │ st_uid, st_gid                           │  4 байта
  │ st_size         (размер в байтах)        │  8 байт (нижние и верхние 32 бита)
  │ st_atime, st_mtime, st_ctime, st_crtime  │  каждый 8 байт (наносекунды в ext4)
  │ st_nlink                                 │  2 байта
  │ st_blocks                                │  4 байта (в единицах 512 B)
  │ i_block[15]                              │  60 байт: extent header + extents
  │                                          │            или (ext2/3) указатели на блоки
  │ flags, generation, ACL, ...              │
  │ extended inode space (xattrs)            │  до конца 256 байт
  └──────────────────────────────────────────┘

Имя файла в inode не хранится. Оно живёт в directory entry в каталоге, где этот файл лежит. Несколько имён, указывающих на один inode — это hard links (см. hard_and_symbolic_links.md).

Адресация данных: extents против block mapping

В ext2/ext3 inode хранил прямые указатели на блоки данных:

  ext3 inode (15 указателей по 4 байта):
  ┌───────────────┐
  │ block[0..11]  │ ─▶ 12 прямых блоков (до 48 KB при 4 KB блоке)
  │ block[12]     │ ─▶ indirect block ─▶ 1024 блока (до 4 MB)
  │ block[13]     │ ─▶ double-indirect ─▶ 1024² блоков (до 4 GB)
  │ block[14]     │ ─▶ triple-indirect ─▶ 1024³ блоков (до 4 TB)
  └───────────────┘

Большой файл на ext3 требовал трёх обращений к indirect-блокам прежде, чем получить адрес данных. И каждый блок данных описывался индивидуально — для 1-гигабайтного файла нужно 256K записей по 4 байта в indirect-цепочке.

ext4 заменил это на extents — записи вида (логический блок → физический блок, длина):

  Extent описывает непрерывный диапазон:
  логический блок 0..1023  →  физический блок 50000..51023  (1024 блока подряд)
  логический блок 1024..1535 → физический блок 60000..60511  (512 блоков подряд)
  ...

Для непрерывного файла в 4 GB достаточно одного extent (max длина — 32768 блоков, ~128 MB) вместо миллиона указателей. В inode помещается extent tree — небольшое B+ tree, корень которого живёт в 60 байтах i_block[]:

  i_block[60 bytes]
  ┌─────────────────────────────────────┐
  │ ext4_extent_header                  │  12 байт
  │   eh_magic = 0xF30A                 │
  │   eh_entries  (число записей ниже)  │
  │   eh_depth    (0 = лист, >0 = узел) │
  ├─────────────────────────────────────┤
  │ ext4_extent / ext4_extent_idx [0]   │  12 байт
  │ ext4_extent / ext4_extent_idx [1]   │  12 байт
  │ ext4_extent / ext4_extent_idx [2]   │  12 байт
  │ ext4_extent / ext4_extent_idx [3]   │  12 байт
  └─────────────────────────────────────┘
                  ▼ если eh_depth > 0
       ┌─────────────────────┐
       │  внешний extent     │  (отдельный блок ФС, не в inode)
       │  block: header +    │
       │  больше записей     │
       └─────────────────────┘

ext4_extent_header — 12 байт, остаются 48 байт под четыре 12-байтовые записи. Если файл фрагментирован сильнее — выделяется отдельный блок под extent tree, и в inode остаётся ext4_extent_idx (индексные записи).

Глубина дерева редко превышает 5 — этого хватает на терабайтные файлы. Каждый узел B+ tree содержит до сотен extent'ов.

htree: быстрый lookup в каталогах

Классическая ext2 хранила каталог как линейный список directory entries. Поиск файла в каталоге с миллионом записей требовал линейного сканирования в среднем 500K записей — десятки секунд на HDD.

ext3 ввёл, а ext4 унаследовал, htree (hash tree) — хешированный индекс поверх линейного хранилища:

  Каталог с htree:
  ┌─────────────────────────────────────────────────┐
  │ block 0:  "." | ".." | htree root (hash index)  │
  ├─────────────────────────────────────────────────┤
  │ block 1:  записи с hash 0x00000000..0x3FFFFFFF  │
  │ block 2:  записи с hash 0x40000000..0x7FFFFFFF  │
  │ block 3:  записи с hash 0x80000000..0xBFFFFFFF  │
  │ block 4:  записи с hash 0xC0000000..0xFFFFFFFF  │
  └─────────────────────────────────────────────────┘

  Lookup "file.txt":
    1. hash("file.txt") = 0x5A12E7D0
    2. По htree корню в block 0 находим: hash попадает в block 2
    3. Внутри block 2 линейный поиск (но это уже 1 блок, а не весь каталог)

Хеш-функция (Half MD4 или Tea) выбирается при создании ФС. htree активируется автоматически, когда каталог не помещается в один блок. С точки зрения userspace ничего не меняется — readdir(3) возвращает все записи как раньше.

Включение htree контролируется флагом dir_index суперблока. Проверить:

tune2fs -l /dev/sda1 | grep features
# должен быть dir_index

Delayed allocation

ext4 не выделяет блоки на диске сразу при write(2). Вместо этого данные кладутся в page cache как dirty, а блоки на диске резервируются только при writeback (асинхронно, через pdflush/writeback поток, или принудительно через fsync):

  Без delayed allocation (ext3):
     write(fd, buf, 4096)
     выделить блок на диске СЕЙЧАС  ──▶  записать в page cache как dirty
     writeback позже сольёт грязные страницы на диск

  С delayed allocation (ext4):
     write(fd, buf, 4096)
     только пометить страницу как dirty (без выделения блока)
     writeback СЕЙЧАС: видит все dirty страницы файла сразу,
        выделяет ОДИН непрерывный extent на всю запись

Что это даёт:

  • Меньше фрагментации. Файлу, который растёт на 1 KB за раз, без delayed allocation выделяли бы по 1 KB-блоку в разных местах. С delayed allocation writeback видит уже 100 MB dirty данных и выделяет одну непрерывную область.
  • Меньше работы. Если файл создан и удалён до writeback'а, блоки на диске вообще не выделялись.
  • Лучше extents. Один большой extent вместо десятков мелких.

Цена — известная проблема: после write без fsync данных нет на диске. Если процесс или система упадёт между write и writeback'ом, файл будет нулевой длины или с нулями внутри. Это поведение ext4 (как и любой ФС с writeback cache) и оно по стандарту POSIX корректно — но иногда удивляет пользователей.

Журналирование

Если система упала во время записи метаданных, ФС может остаться в несогласованном состоянии: inode уже указывает на блок, который ещё не выделен в bitmap, или наоборот. Без журнала восстановление требует полного fsck, который на терабайтном разделе занимает часы.

ext4 решает это журналом: перед изменением метаданных запись о намерении пишется в специальный циркулярный буфер (journal). При сбое ядро при mount'е проигрывает журнал заново — все незавершённые транзакции либо доводятся до конца, либо откатываются.

Журнал — это отдельный inode (по умолчанию #8) размером обычно 128 MB. Управляется подсистемой JBD2 (Journaling Block Device 2).

Три режима журналирования:

Режим Что в журнал Скорость Безопасность
data=writeback только метаданные максимум минимум
data=ordered только метаданные, но порядок строгий компромисс хорошая
data=journal метаданные и данные минимум максимум

data=writeback

В журнал идут только метаданные (inode, bitmap, group descriptor). Данные пишутся в любой момент — до или после commit метаданных.

  Транзакция:
     ┌──────────────────────┐
     │  data на диск (1)    │   ↓ может произойти позже метаданных
     │     ИЛИ              │
     │  metadata в journal  │   ↓
     │  commit              │   ↓
     │  metadata на диск    │
     └──────────────────────┘

Опасность: после сбоя метаданные говорят «файл длиной 4 KB, блок 50000», но данные ещё не успели туда записаться. В блоке может оказаться мусор от прежнего файла.

data=ordered (default)

Данные пишутся до commit'а метаданных. В журнал идут только метаданные, но JBD2 гарантирует порядок: блок данных физически лёг на диск раньше, чем закоммитятся метаданные, ссылающиеся на этот блок.

  Транзакция:
     ┌──────────────────────┐
     │  data на диск        │  ── ждём, пока не запишется
     │  ↓                   │
     │  metadata в journal  │
     │  commit              │
     │  ↓                   │
     │  metadata на диск    │
     └──────────────────────┘

При сбое: либо транзакция вся применилась, либо вся откатилась. Мусора в новых блоках не будет. По умолчанию ext4 работает именно так.

data=journal

В журнал идёт всё: и метаданные, и данные. Каждый блок пишется дважды — сначала в журнал, потом в финальное место.

  Транзакция:
     ┌──────────────────────┐
     │ data в journal       │
     │ metadata в journal   │
     │ commit               │
     │ ─ ─ ─ ─ ─ ─          │
     │ data на диск         │ ← из journal в финальное место
     │ metadata на диск     │
     └──────────────────────┘

Самый безопасный, самый медленный. Полезен для критичных приложений, где двойная запись окупается стабильностью. Парадокс: некоторые синхронные workload'ы с data=journal оказываются быстрее, чем с ordered, потому что обе записи журнала идут последовательно (быстрая запись на HDD), а потом writeback метаданных и данных может слиться оптимально.

Сменить режим — опция монтирования:

mount -o remount,data=writeback /dev/sda1

Другие файловые системы

btrfs

Copy-on-Write (CoW) на уровне ФС: блок никогда не перезаписывается — изменённая копия пишется в новое место, и ссылки на неё обновляются вверх по дереву. Это даёт практически бесплатные snapshots (моментальные снимки), clone файла (без копирования), checksum'ы на всё (включая данные), встроенный RAID 0/1/10/5/6, сжатие, дедупликацию.

  CoW при изменении блока B файла:
        старая версия            новая версия
        ┌──────┐                 ┌──────┐
        │ root │                 │ root'│  ← новый корень
        └───┬──┘                 └───┬──┘
            │                        │
        ┌───┴────┐               ┌───┴────┐
        │ A │ B  │               │ A │ B' │   ← новая копия B
        └────────┘               └────────┘
            │   │                 │    │
            ▼   ▼                 ▼    ▼
          data data              данные новый блок

Цена — фрагментация (CoW «размазывает» файл по диску с каждой правкой) и проблемы с большими long-lived базами данных (сильно деградируют без chattr +C — отключение CoW для конкретных файлов).

XFS

Изначально разработана SGI для серверов и больших файлов. Использует allocation groups (аналог блочных групп ext4, но сильно крупнее — по умолчанию 1 GB и больше) и B+ trees для всех структур: extents, inodes, free space.

XFS отлично масштабируется на терабайтные и петабайтные тома, гонит на параллельных нагрузках (каждая allocation group может обрабатываться независимо). Не поддерживает уменьшение раздела онлайн — только увеличение. Журналирует только метаданные (аналог data=writeback).

ZFS

Файловая система от Sun, портирована в Linux как OpenZFS. Не входит в mainline ядро по лицензионным причинам (CDDL несовместима с GPL). CoW + integrated volume management: ZFS управляет дисками сама, без LVM.

Особенности:

  • Pools объединяют несколько устройств; на пул заводятся datasets (аналог разделов).
  • Snapshots через CoW, бесплатно по записи.
  • Checksums на каждый блок (SHA-256 или fletcher4), автоматическое восстановление при scrub.
  • L2ARC, ZIL — кеш чтения на SSD, отдельный device для журнала.
  • RAID-Z — улучшенный RAID 5/6 без write hole.

Цена — память. ZFS любит RAM, рекомендуют 1 GB на TB пула.

tmpfs

Файловая система в RAM. Не имеет блочного устройства — данные хранятся в page cache, выгружаются в swap при нехватке памяти. Размер задаётся при монтировании:

mount -t tmpfs -o size=2G tmpfs /mnt/ram

Используется для /dev/shm (shared memory), /run, /tmp в многих дистрибутивах. Скорость — на пределе скорости RAM, никаких syscall'ов до диска. Содержимое исчезает при перезагрузке.

Сравнительная таблица

ФС Журнал CoW Snapshots Extents Checksums data В mainline
ext2 нет нет нет нет (blocks) нет да
ext3 да нет нет нет (blocks) нет да
ext4 да нет нет да metadata only да
XFS да нет нет да metadata only да
btrfs нет (CoW) да да да да да
ZFS нет (CoW) да да да да нет
tmpfs да

Диагностика ext4

Состояние и параметры:

tune2fs -l /dev/sda1            # superblock: размер, кол-во inode, флаги, mount count
dumpe2fs /dev/sda1 | less       # полный дамп: group descriptors, bitmaps
debugfs /dev/sda1               # интерактивный debug (требует root)

Внутри debugfs:

debugfs:  stat <inode>          # показать структуру inode
debugfs:  ls /etc               # содержимое каталога
debugfs:  dump <inode> /tmp/out # извлечь содержимое файла
debugfs:  icheck <block>        # какой inode владеет этим блоком

Фрагментация:

e4defrag -c /home/user          # отчёт по фрагментации
e4defrag /home/user             # дефрагментация онлайн
filefrag -v file.bin            # показать extents конкретного файла

Проверка целостности:

fsck.ext4 -f /dev/sda1          # форсированная проверка (раздел должен быть размонтирован)

Связанные темы

Источники