Файловые системы: VFS, ext4, XFS, btrfs, ZFS, журналирование
Диск не знает ни о файлах, ни о каталогах, ни об именах. Он умеет ровно две вещи: «прочитай сектор номер N» и «запиши сектор номер N». Всё остальное — иерархия, права, размеры, время изменения, атомарное переименование — это структуры данных, которые ядро аккуратно раскладывает поверх плоского массива нумерованных блоков. Файловая система — это, по сути, персистентная база данных, которая должна пережить внезапное отключение питания в произвольный момент и при этом остаться консистентной.
Для прикладного программиста это не абстрактное знание. Именно здесь живут ответы на вопросы, которые всплывают в проде в три часа ночи: почему df показывает 40 % свободного места, а запись падает с ENOSPC; почему после write() данные исчезли, хотя вызов вернул успех; почему одна и та же СУБД на ext4 и на btrfs отличается по latency в разы; почему rename() атомарен, а write() — нет; почему fsync() на macOS не делает того, что вы думаете.
Разберём слой за слоем: сначала общий интерфейс, которым ядро прикрывает все ФС сразу, потом конкретные реализации и их компромиссы, потом кэши и семантику долговечности.
Зачем нужен слой абстракции: VFS
В Linux одновременно смонтированы ext4 на диске, tmpfs в памяти, procfs с выдуманными файлами, overlayfs внутри контейнера и, возможно, NFS по сети. Программа делает open("/data/x") и не должна знать, что там снизу. Это обеспечивает VFS (Virtual File System) — придуманный в Sun в 1985 году для SunOS слой, который сегодня есть в каждой Unix-подобной ОС.
Идея VFS: определить набор объектов и таблиц функций (по сути — интерфейсы), которые обязана реализовать любая файловая система. Ядро вызывает sb->s_op->write_inode(), не зная, ext4 там или btrfs.
Четыре центральных объекта VFS в Linux:
Что важно понять из этой схемы:
- inode — это сам объект: права, размер, времена, указатели на данные. У него нет имени.
- dentry — это имя, элемент каталога, связывающий строку с inode. Один inode может иметь много dentry — это и есть жёсткие ссылки.
i_nlinkсчитает их. - file — это открытый дескриптор: позиция чтения, флаги. Два
open()одного файла дают два разныхstruct fileс независимыми позициями;dup()даёт два fd на одинstruct fileс общей позицией. Отсюда классический баг с общим смещением послеfork(). - address_space — мост между inode и page cache: где лежат закэшированные страницы этого файла.
Проверить связь имени и объекта можно прямо в терминале:
$ echo hello > a.txt
$ ln a.txt b.txt # жёсткая ссылка — второе имя того же inode
$ stat -c '%i %h %n' a.txt b.txt
1443628 2 a.txt
1443628 2 b.txt # один inode, два имени
$ rm a.txt # уменьшает i_nlink, файл жив
$ stat -c '%i %h %n' b.txt
1443628 1 b.txt
Файл физически удаляется, когда i_nlink == 0 и нет открытых дескрипторов. Отсюда знаменитая ситуация «удалил лог, а место не вернулось»: процесс держит fd.
$ lsof +L1 | head -3
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
nginx 1204 www 3w REG 8,1 9812304 0 1443701 /var/log/nginx/access.log (deleted)
NLINK = 0 — файл ждёт закрытия дескриптора. Лечится truncate -s 0 /proc/1204/fd/3 или перезапуском/SIGUSR1.
Кэш имён и почему stat бывает бесплатным
Разбор пути /usr/local/lib/libfoo.so — это последовательность поисков в каталогах. Чтобы не ходить на диск каждый раз, ядро держит dcache — хеш-таблицу dentry. Туда попадают и «отрицательные» записи (negative dentry): факт, что имени нет. Именно поэтому повторный stat() несуществующего файла почти бесплатен — а это очень частый паттерн (поиск библиотек, PATH, импорты интерпретаторов).
$ grep -E 'dentry|inode_cache' /proc/slabinfo | awk '{print $1, $2, $3}'
dentry 412088 415023
inode_cache 98211 99120
$ sysctl vm.vfs_cache_pressure # 100 = по умолчанию; меньше — агрессивнее держим кэш
vm.vfs_cache_pressure = 100
Различия семейств. Linux VFS с dcache — самый «кэш-ориентированный» вариант. В BSD (FreeBSD, OpenBSD, NetBSD) базовый слой называется vnode и восходит прямо к оригинальной статье Клеймана 1986 года; кэш имён там отдельная и более простая подсистема (vfs.cache_* в sysctl FreeBSD). В XNU (macOS) — свой VFS, тоже vnode-based, унаследованный от 4.4BSD. В Windows NT аналога VFS в привычном виде нет: там I/O-менеджер и стек filter drivers, через который проходят IRP-пакеты; фильтры (антивирусы, шифрование, аудит) навешиваются на стек, и это одна из причин, почему файловые операции на Windows традиционно дороже.
Путь одного read() сверху вниз
Соберём слои вместе. Что происходит между read(fd, buf, 4096) и импульсом на шине:
libc → syscall"] --> B["VFS: fd → struct file
проверка прав и флагов"] B --> C["f_op->read_iter()
обычно generic_file_read_iter"] C --> D{"O_DIRECT?"} D -- да --> J["обход page cache:
DMA прямо в буфер пользователя"] D -- нет --> E{"страница есть
в page cache?"} E -- да --> F["copy_to_user()
сотни наносекунд"] E -- нет --> G["readahead: запрос
соседних страниц"] G --> H["a_ops->read_folio → ФС
лог. блок → физ. блок
(экстенты / B+ дерево)"] H --> I["block layer: struct bio
слияние, планировщик mq"] I --> K["драйвер: NVMe queue / SCSI"] K --> L["устройство → DMA в страницу
прерывание о завершении"] L --> F J --> M["возврат числа байт"] F --> M
Три вывода для практики:
- Попадание в page cache — это memcpy. Разница между кэш-хитом и NVMe-чтением — примерно 200 нс против 80–100 мкс, то есть в 400 раз. Основная работа по ускорению I/O — не «быстрее диск», а «больше попаданий».
- Трансляция «логический блок файла → физический блок устройства» — это единственное, чем принципиально различаются ФС на этом пути. Всё остальное общее. Как именно ФС хранит эту карту, определяет её характер.
- readahead — почему последовательное чтение в разы быстрее случайного даже на NVMe: ядро угадывает шаблон и запрашивает вперёд (
/sys/block/nvme0n1/queue/read_ahead_kb, по умолчанию 128).
Подробнее про block layer, очереди и DMA — в статье Ввод-вывод, драйверы, прерывания и DMA; про page cache как часть виртуальной памяти — в Управление памятью.
Как хранить карту блоков: от косвенных блоков к экстентам
Историческое решение Unix FFS (и ext2/ext3): в inode лежат 12 прямых указателей на блоки, затем указатель на блок указателей (single indirect), затем двойной и тройной. Файл на 1 GiB при блоке 4 KiB — это 262 144 указателя, разбросанных по косвенным блокам; чтение блока в конце файла требует 3 дополнительных обращения.
ext4 (2006, стабилен с 2008) заменил это на экстенты: одна запись описывает непрерывный диапазон «логические блоки X…X+len → физические Y…Y+len». Одна 12-байтная запись покрывает до 32 768 блоков — 128 MiB.
Посмотреть реальную карту файла:
$ filefrag -v /var/lib/postgresql/16/main/base/16384/2608
Filesystem type is: ef53
File size of ... is 2359296 (576 blocks of 4096 bytes)
ext: logical_offset: physical_offset: length: expected: flags:
0: 0.. 287: 9438208.. 9438495: 288:
1: 288.. 575: 9441024.. 9441311: 288: 9438496:
/var/lib/... : 2 extents found
Два экстента на 2.3 MiB — почти идеально. Когда экстентов сотни, это фрагментация: каждое чтение — новое позиционирование (критично на HDD, заметно на NVMe из-за глубины очереди).
Метаданные ФС читаются штатными утилитами:
$ sudo dumpe2fs -h /dev/nvme0n1p2 | grep -Ei 'block count|free|inode count|features|journal'
Filesystem features: has_journal ext_attr resize_inode dir_index filetype
extent 64bit flex_bg sparse_super large_file huge_file
Inode count: 6553600
Block count: 26214400
Free blocks: 11902317
Free inodes: 6102944
Journal backup: inode blocks
$ sudo tune2fs -l /dev/nvme0n1p2 | grep -i 'mount count\|check'
Mount count: 37
Maximum mount count: -1
Ключевые фичи ext4, которые стоит знать по именам:
- extent — экстенты вместо косвенных блоков (см. выше).
- dir_index — htree-каталоги: хеш-дерево вместо линейного списка. Поиск имени в каталоге с миллионом файлов — O(log n) вместо O(n). Без него каталог на 100 тыс. файлов превращается в катастрофу; это и была причина классического совета «раскладывайте файлы по подкаталогам
ab/cd/abcdef…». - flex_bg — метаданные нескольких групп собираются вместе, чтобы уменьшить блуждания головки.
- delayed allocation (delalloc) — физические блоки выделяются не на
write(), а на сбросе из page cache. К этому моменту ФС знает окончательный размер и может выделить один большой экстент. Это резко снижает фрагментацию — и порождает известный класс сюрпризов при сбое питания (см. ниже проfsync). - 64bit — блочные номера 48 бит, потолок ФС 1 EiB (без фичи — 16 TiB).
- casefold — регистронезависимые каталоги (с 5.2), нужны для игр и Wine.
Журналирование: как пережить выключение питания
Проблема. Удаление файла — это как минимум три независимые записи: убрать запись из каталога, освободить inode в bitmap, освободить блоки данных в bitmap. Питание пропадает между ними. Возможные последствия: «потерянный» inode (место занято, имени нет), блок, помеченный занятым дважды (катастрофа: два файла пишут в одно место), каталог со ссылкой в никуда.
Классическое решение — fsck, полный обход ФС при загрузке. На терабайтном разделе это часы. Неприемлемо.
Журнал (write-ahead log) решает это так: сначала записываем в отдельную область намерение («вот полный набор изменений транзакции»), дожидаемся, что оно на диске, ставим отметку commit — и только потом применяем изменения на место. После сбоя ядро читает журнал: транзакции с commit — доигрывает (redo), без commit — выбрасывает. Восстановление занимает секунды.
(handle открыт) Running --> Running: изменения метаданных
копятся в памяти Running --> Locked: истёк интервал (5 с)
или журнал заполнен Locked --> Flush: новые handle идут
в следующую транзакцию Flush --> Commit: блоки транзакции
записаны в журнал Commit --> Finished: записан commit-блок
+ сброс кэша устройства (FUA) Finished --> Checkpoint: изменения применены
на постоянные места Checkpoint --> [*]: место в журнале
переиспользуется Commit --> Recovery: сбой питания Flush --> Discard: сбой питания Recovery --> [*]: redo при монтировании Discard --> [*]: транзакция отброшена целиком
В ext4 журнал ведёт подсистема JBD2 (Journaling Block Device). Три режима, задаются mount -o data=:
| Режим | Что попадает в журнал | Скорость | Риск |
|---|---|---|---|
data=journal |
метаданные и данные | самый медленный (двойная запись) | минимальный |
data=ordered (по умолчанию) |
только метаданные, но данные форсированно пишутся до коммита метаданных | быстрый | размер файла никогда не опережает данные |
data=writeback |
только метаданные, порядок не гарантируется | самый быстрый | после сбоя в новом файле может оказаться чужой мусор со старых блоков |
data=writeback — не «чуть менее надёжно», а именно утечка данных: файл получает правильный размер, но старое содержимое освобождённых блоков. На многопользовательской машине это дыра в безопасности.
Проверить и посмотреть журнал:
$ findmnt -no OPTIONS /
rw,relatime,errors=remount-ro
$ sudo dumpe2fs -h /dev/nvme0n1p2 2>/dev/null | grep -i 'journal features'
Journal features: journal_incompat_revoke journal_64bit journal_checksum_v3
$ sudo debugfs -R "logdump -S" /dev/nvme0n1p2 | head -20 # содержимое журнала
Метаданные, но не данные. Ключевая мысль, которую упускают чаще всего: журналирование по умолчанию защищает структуру файловой системы, а не содержимое ваших файлов. Гарантия «ФС смонтируется без fsck и не будет перекрёстных ссылок» — не гарантия «мой JSON дописался целиком». Это ответственность приложения.
Альтернатива журналу: soft updates
FreeBSD и OpenBSD исторически пошли другим путём. Soft updates (Ганджер и Патт, 1994; реализация Кирка Маккузика) не пишет журнал вообще. Вместо этого ФС отслеживает зависимости между изменениями метаданных в памяти и упорядочивает записи так, чтобы на диске никогда не было опасного состояния. Инвариант: разрешено «место занято, но не используется» (утечка, безвредно), запрещено «на блок ссылаются, но он помечен свободным».
Плюс: нет двойной записи, нет журнала. Минус: после сбоя ФС консистентна, но может иметь утечки — нужен фоновый fsck (в FreeBSD он работает на снапшоте смонтированной ФС, не блокируя систему). Реализация чудовищно сложна — по общему мнению, именно поэтому подход не распространился.
FreeBSD позже добавил SU+J — soft updates плюс маленький журнал только для отслеживания утечек, чтобы избавиться от фонового fsck. NetBSD пошёл третьим путём: WAPBL (Write Ahead Physical Block Logging) — классический журнал поверх FFS. OpenBSD принципиально остался на FFS без журнала: soft updates доступны опцией монтирования, целостность после сбоя обеспечивает fsck при загрузке. Это осознанный выбор в пользу простоты и аудируемости кода.
# FreeBSD
$ mount -p
/dev/ada0p2 / ufs rw,noatime 1 1
$ tunefs -p /dev/ada0p2
tunefs: soft updates: (-n) enabled
tunefs: soft update journaling: (-j) enabled
# OpenBSD
$ mount
/dev/sd0a on / type ffs (local, softdep)
XFS: масштабирование через параллелизм
XFS создан в Silicon Graphics в 1993 году для IRIX — под задачи вроде реалтаймового видеомонтажа, где нужны десятки терабайт и предсказуемая пропускная способность. Портирован в Linux в 2001-м, сегодня — ФС по умолчанию в RHEL/CentOS/Rocky.
Главная архитектурная идея — allocation groups (AG): раздел делится на несколько (обычно 4–32) почти независимых областей, каждая со своими структурами свободного места и своими inode. Блокировки берутся на уровне AG, поэтому параллельные операции из разных потоков не конкурируют. На NVMe и многоядерных машинах это даёт заметное преимущество по метаданным.
Второе — всё есть B+ дерево. Свободное место индексируется дважды: по адресу блока (для слияния соседних дыр) и по размеру (для быстрого поиска подходящей дыры). Экстенты файла, inode, обратные ссылки (rmap) — тоже B+ деревья. Сложность операций — O(log n) при любом размере ФС.
$ sudo mkfs.xfs -f -d agcount=8 /dev/sdb1
$ xfs_info /mnt/data
meta-data=/dev/sdb1 isize=512 agcount=8, agsize=32768000 blks
= sectsz=4096 attr=2, projid32bit=1
= crc=1 finobt=1, sparse=1, rmapbt=1
= reflink=1 bigtime=1
data = bsize=4096 blocks=262144000, imaxpct=25
= sunit=0 swidth=0 blks
naming =version 2 bsize=4096 ascii-ci=0, ftype=1
log =internal bsize=4096 blocks=128000, version=2
realtime =none extsz=4096 blocks=0, rtextents=0
Практические особенности:
- Логическое журналирование. XFS пишет в журнал не блоки целиком, а описания изменений («в inode 12345 поле size стало 4096»). Журнал компактнее и его можно вынести на отдельное устройство (
-l logdev=). - Speculative preallocation. При последовательной записи XFS резервирует больше места, чем попросили, чтобы файл рос одним экстентом. Излишек освобождается при закрытии. Это часто вызывает недоумение:
duпоказывает больше, чемls -l. - Динамические inode. Нет фиксированного числа при mkfs, как у ext4 — inode выделяются по мере надобности.
df -iна XFS почти никогда не проблема. - reflink (с ядра 4.9) — copy-on-write копии файлов:
cp --reflink=alwaysделает мгновенную копию, физические блоки общие до первой записи. Это то, на чём держатся быстрые снапшоты контейнерных образов. - ФС нельзя уменьшить. Только расширить (
xfs_growfs). Планируйте разметку заранее — самый частый упрёк XFS.
$ cp --reflink=always bigfile.img copy.img # мгновенно, места не занимает
$ du -sh --apparent-size bigfile.img copy.img
8.0G bigfile.img
8.0G copy.img
$ df -h /mnt/data | tail -1 # занято по-прежнему ~8 G
/dev/sdb1 1000G 8.1G 992G 1% /mnt/data
Copy-on-Write: btrfs и ZFS
Журнал — это компромисс: чтобы гарантировать атомарность, мы пишем данные дважды. CoW-подход убирает саму необходимость перезаписи на месте.
Идея (формализована в статье Охада Роде «B-trees, Shadowing, and Clones», IBM, 2007): при изменении любого блока пишем новую копию в свободное место, затем обновляем указатель на неё в родительском узле — который тоже копируется, и так до корня. Последней атомарно записывается новая версия корня. Если питание пропало на любом шаге — корень всё ещё указывает на старое, полностью консистентное дерево.
Из этой конструкции почти бесплатно вытекают три вещи:
- Снапшот = сохранить старый корень и не освобождать блоки, на которые он ссылается. Стоимость создания — O(1).
- Контрольные суммы естественно ложатся в родительский узел: узел хранит и указатель на ребёнка, и его чек-сумму. Получается дерево Меркла, покрывающее всю ФС.
- Клоны файлов (reflink) — то же самое на уровне поддерева.
btrfs
Btrfs (Крис Мейсон, Oracle, 2007) построен на одной универсальной структуре — CoW B+ дереве, в котором хранится всё: метаданные, экстенты, каталоги, чек-суммы, подтома. Штатная ФС в Fedora (с 33) и openSUSE, широко используется в Synology и Meta.
$ sudo mkfs.btrfs -d raid1 -m raid1 /dev/sdb /dev/sdc
$ sudo btrfs filesystem usage /mnt
Overall:
Device size: 2.00TiB
Device allocated: 412.06GiB
Used: 398.11GiB
Free (estimated): 816.94GiB (min: 816.94GiB)
$ sudo btrfs subvolume create /mnt/@data
$ sudo btrfs subvolume snapshot -r /mnt/@data /mnt/.snap/2026-07-16
$ sudo btrfs send -p /mnt/.snap/2026-07-15 /mnt/.snap/2026-07-16 | ssh backup 'btrfs receive /backup'
Сильные стороны: прозрачное сжатие (compress=zstd:3 часто даёт 2–3× на логах и исходниках при почти нулевой стоимости CPU), инкрементальный send/receive, добавление и удаление устройств на живой ФС, обнаружение битой копии по чек-сумме и автоматическое лечение из зеркала.
Слабые места, о которых нужно знать честно:
- RAID5/6 до сих пор имеет write hole и официально не рекомендован для данных, которые нельзя потерять. RAID1/10 — зрелые.
- Фрагментация при случайной перезаписи. База данных или образ VM внутри CoW-ФС превращается в тысячи мелких экстентов. Лечение —
chattr +Cна каталог до создания файлов (отключает CoW; попутно отключает и чек-суммы для них). ENOSPCпри «свободном» месте. btrfs выделяет место чанками отдельно под данные и под метаданные. Когда кончились чанки метаданных, запись падает, хотяdfпоказывает терабайты. Смотреть надоbtrfs filesystem usage, а неdf; лечение —btrfs balance.- qgroups (квоты) заметно замедляют ФС и исторически были источником багов.
# каталог для базы данных: отключаем CoW до наполнения
$ sudo mkdir /var/lib/mysql && sudo chattr +C /var/lib/mysql
$ lsattr -d /var/lib/mysql
---------------C------ /var/lib/mysql
ZFS
ZFS (Sun Microsystems, Джефф Бонвик и Мэтт Аренс, 2005) сделал более радикальный шаг: он объединяет менеджер томов, RAID и файловую систему в одну подсистему. Аргумент авторов: разделение слоёв (диск → RAID → LVM → ФС) мешает — RAID-контроллер не знает, какие блоки заняты, а ФС не знает, что есть вторая копия.
Модель: физические устройства собираются в vdev (зеркало, RAIDZ1/2/3), vdev’ы — в пул, из пула нарезаются datasets (файловые системы) и zvol (блочные устройства). Место общее, квоты и резервации логические.
только синхронные записи"] L2["cache vdev (L2ARC)
расширение кэша чтения"] end POOL --> DS1["dataset tank/pgdata
recordsize=16K
compression=lz4"] POOL --> DS2["dataset tank/backups
recordsize=1M
compression=zstd-9"] POOL --> ZV["zvol tank/vm-disk
volblocksize=16K"] ARC["ARC в RAM
adaptive replacement cache"] -.кэширует.-> POOL
Что делает ZFS уникальным на практике:
- Сквозные чек-суммы всех данных (fletcher4 по умолчанию, опционально SHA-256/BLAKE3) и самолечение: при чтении битого блока в зеркале ZFS берёт хорошую копию, отдаёт приложению и чинит плохую. Это защита от «тихого» повреждения данных, которое RAID-контроллер не видит.
- RAIDZ без write hole: ширина страйпа переменная и он всегда пишется как часть транзакции, поэтому частично записанного страйпа не бывает.
- ARC — собственный кэш вместо page cache, с алгоритмом Adaptive Replacement Cache (Мегиддо и Модха, IBM, 2003), который балансирует между «недавно использованными» и «часто использованными» страницами и устойчив к сканированию большого файла. Отдельный кэш — причина, по которой на Linux память под ZFS не видна как «cached» и требует настройки
zfs_arc_max. - Транзакционные группы (txg): изменения копятся ~5 секунд и фиксируются одной атомарной записью uberblock.
- SLOG (ZIL) — частое заблуждение. Это не кэш записи. Туда попадают только синхронные записи (
O_SYNC,fsync, NFS), чтобы можно было быстро подтвердить долговечность; читается он только при восстановлении после сбоя. Если у вас нет синхронных записей, SLOG не даст ничего.
$ zpool status tank
pool: tank
state: ONLINE
scan: scrub repaired 0B in 02:14:33 with 0 errors on Sun Jul 12 05:14:33 2026
config:
NAME STATE READ WRITE CKSUM
tank ONLINE 0 0 0
raidz2-0 ONLINE 0 0 0
nvme0n1 ONLINE 0 0 0
nvme1n1 ONLINE 0 0 2 <-- чек-суммы чинились
nvme2n1 ONLINE 0 0 0
$ zfs get compressratio,recordsize,atime tank/pgdata
NAME PROPERTY VALUE SOURCE
tank/pgdata compressratio 2.41x -
tank/pgdata recordsize 16K local
tank/pgdata atime off local
$ zfs snapshot tank/pgdata@before-migration
$ zfs rollback tank/pgdata@before-migration # откат за секунды
Лицензия. ZFS распространяется под CDDL, несовместимой с GPLv2, поэтому код не может попасть в mainline Linux; OpenZFS ставится как внешний модуль (DKMS). На FreeBSD и illumos ZFS — первоклассный гражданин, включая root-on-ZFS из установщика. Это чисто юридическое, а не техническое ограничение — и главная причина, почему в мире Linux развивали btrfs.
Журнал против CoW: где что применять
| Защита данных | Стоимость случайной перезаписи | Снапшоты | |
|---|---|---|---|
| ext4 / XFS | только структура ФС | низкая | нет (LVM отдельно) |
ext4 data=journal |
структура + данные | высокая (двойная запись) | нет |
| UFS + soft updates | только структура | низкая | на уровне ФС (FreeBSD) |
| NTFS | только структура | низкая | VSS, внешний механизм |
| APFS | метаданные + CoW | средняя | O(1) |
| btrfs / ZFS | сквозные чек-суммы данных | высокая (фрагментация) | O(1), встроены |
Практические правила, которые стоит держать в голове:
- Нагруженная СУБД на локальном NVMe → XFS или ext4. У СУБД уже есть свой WAL и свои чек-суммы; CoW добавит вторую копию работы и фрагментацию. Если очень нужен ZFS — ставьте
recordsizeравным размеру страницы БД (8K для PostgreSQL, 16K для InnoDB) иlogbias=throughput. - Файловое хранилище, бэкапы, архив, где важна сохранность → ZFS. Сквозные чек-сумы и scrub — единственная реальная защита от bit rot.
- Рабочая станция, где ценны снапшоты перед обновлением → btrfs (Snapper/Timeshift в openSUSE и Fedora — именно этот сценарий).
- Не знаете, что выбрать, и не хотите думать → ext4. Скучно, зрело, отлажено двумя десятилетиями.
- Много мелких файлов и параллельные метаданные → XFS (allocation groups) заметно обгоняет ext4.
Page cache, грязные страницы и запись
Между write() и диском стоит page cache. write() по умолчанию просто копирует данные в страницу памяти, помечает её грязной (PG_dirty) и возвращает успех. На диск это попадёт позже — потоками writeback, по одному из трёх поводов: истёк таймер, пробит порог доли грязной памяти, или приложение вызвало fsync(). Пока страница грязная, повторные write() в неё бесплатны — диск не трогается вообще (write coalescing). Ошибка записи помечает inode через errseq_t, и её увидит следующий fsync() — ровно один раз на дескриптор.
Управляющие ручки (Linux):
$ sysctl vm.dirty_background_ratio vm.dirty_ratio vm.dirty_expire_centisecs
vm.dirty_background_ratio = 10 # с этого % ОЗУ writeback стартует в фоне
vm.dirty_ratio = 20 # с этого % write() начинает блокироваться
vm.dirty_expire_centisecs = 3000 # страница старше 30 с обязана быть записана
$ grep -E '^(Dirty|Writeback):' /proc/meminfo
Dirty: 152304 kB
Writeback: 8192 kB
Типичная проблема на машинах с большим ОЗУ: 20 % от 512 GiB — это 100 GiB грязных данных. Когда порог пробивается, система «встаёт» на минуты, пока всё сбрасывается. На серверах с большим ОЗУ ставят vm.dirty_bytes / vm.dirty_background_bytes в абсолютных величинах (например, 2 GiB / 512 MiB) вместо процентов.
Долговечность: что на самом деле делает fsync()
Здесь сосредоточена большая часть реальных инцидентов с потерей данных.
Что гарантирует успешный write(): ничего, кроме того, что данные попали в page cache. Обрыв питания через секунду — данных нет.
fsync(fd) — «выпихни на устройство все грязные страницы этого файла, все его метаданные, и заставь устройство сбросить свой энергозависимый кэш». Только после его возврата можно считать данные долговечными.
fdatasync(fd) — то же, но не синхронизирует метаданные, не влияющие на чтение данных (например, mtime). На один системный вызов меньше обновлений — заметно быстрее при частых мелких коммитах. Именно его используют PostgreSQL (wal_sync_method=fdatasync) и SQLite.
Каноничный паттерн «атомарно заменить файл» на POSIX:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
/* Атомарная замена содержимого файла. Возвращает 0 при успехе. */
int atomic_replace(const char *dir, const char *final, const void *buf, size_t n)
{
char tmp[4096];
snprintf(tmp, sizeof tmp, "%s/.tmp-XXXXXX", dir);
int fd = mkstemp(tmp); /* 1. пишем во временный файл */
if (fd < 0) return -1;
if (write(fd, buf, n) != (ssize_t)n) goto fail;
if (fsync(fd) != 0) goto fail; /* 2. содержимое реально на диске */
if (close(fd) != 0) goto fail; /* close НЕ делает fsync! */
if (rename(tmp, final) != 0) { /* 3. атомарная подмена имени */
unlink(tmp);
return -1;
}
/* 4. КРИТИЧНО: сам факт переименования тоже надо зафиксировать,
иначе после сбоя каталог может не содержать новой записи. */
int dfd = open(dir, O_RDONLY | O_DIRECTORY);
if (dfd < 0) return -1;
int rc = fsync(dfd);
close(dfd);
return rc;
fail:
close(fd);
unlink(tmp);
return -1;
}
Четыре шага, и пропуск любого ломает гарантию. Шаг 4 забывают чаще всего.
Важные тонкости:
rename()атомарен только внутри одной файловой системы. Между ФС он вернётEXDEV, иmvсделает copy+unlink — не атомарно.close()не выполняетfsync(). Ошибка записи может всплыть вclose()и потеряться, если её не проверять.- fsyncgate (2018). До ядра 4.13 Linux при ошибке writeback помечал страницу чистой и сбрасывал флаг ошибки: первый
fsync()возвращалEIO, повторный — успех, хотя данные потеряны. На этом обжёгся PostgreSQL; разбор — в письме Крейга Ринджера в pgsql-hackers и статье LWN «PostgreSQL’s fsync() surprise». Исправлено введениемerrseq_t: ошибку теперь получает каждый дескриптор ровно один раз. Вывод для приложений:EIOотfsync()— не повод повторить попытку, а повод перейти в аварийный режим. - macOS:
fsync()не сбрасывает кэш накопителя. Он только передаёт данные устройству. Для реальной долговечности нуженfcntl(fd, F_FULLFSYNC, 0)— так и делают SQLite и PostgreSQL. Это документировано вman 2 fsyncна macOS и регулярно всплывает в бенчмарках, где «macOS быстрее Linux». - Windows: аналог —
FlushFileBuffers(), либо открытие файла сFILE_FLAG_WRITE_THROUGH. O_DIRECT— не про долговечность. Он обходит page cache (нужны выровненные буферы и смещения), но данные всё ещё могут сидеть в энергозависимом кэше диска. Долговечность даётO_DIRECT|O_DSYNCлибоO_DIRECT+fdatasync().
Наглядно, кто что делает:
блоки ещё не выделены (delalloc) App->>VFS: fsync(fd) VFS->>FS: сброс грязных страниц inode FS->>FS: выделение экстентов,
открытие транзакции JBD2 FS->>BL: bio с данными BL->>Dev: запись данных Dev-->>BL: ok (данные в кэше устройства) FS->>BL: блоки журнала + commit (REQ_FUA) BL->>Dev: запись журнала + сброс кэша Dev-->>BL: ok (реально на носителе) FS-->>VFS: транзакция зафиксирована VFS-->>App: 0 Note over App,Dev: только здесь данные переживут
отключение питания
Измерить цену fsync на конкретном железе:
$ pg_test_fsync -f /var/lib/postgresql/testfile
5 seconds per test
Compare file sync methods using one 8kB write:
open_datasync 18234.112 ops/sec 55 usecs/op
fdatasync 17920.441 ops/sec 56 usecs/op
fsync 9812.003 ops/sec 102 usecs/op
$ sudo fio --name=sync --rw=write --bs=4k --size=256M --fsync=1 --direct=1 \
--filename=/mnt/test.fio
На потребительском SSD без конденсаторной защиты (PLP, power-loss protection) вы увидите сотни операций в секунду, на серверном — десятки тысяч. Разница в 50 раз — это и есть цена честного fsync, и главный аргумент против потребительских дисков под СУБД.
Другие ОС: чем отличаются их файловые системы
macOS / XNU: APFS. Заменил HFS+ в 2017-м. CoW, снапшоты, клоны файлов (clonefile(2), cp -c), контейнеры с общим пулом места между томами, пофайловое шифрование, наносекундные времена, оптимизация под флеш. Важная особенность: чек-суммы только для метаданных, не для пользовательских данных — Apple исходит из того, что накопитель сам обнаруживает ошибки. С Big Sur системный том защищён Merkle-деревом (Signed System Volume) и монтируется только для чтения.
$ diskutil apfs list
$ tmutil localsnapshot # снапшот APFS, на котором держится Time Machine
$ cp -c bigfile.dmg copy.dmg # клон: мгновенно, места не занимает
Windows NT: NTFS и ReFS. NTFS (1993) построен вокруг MFT (Master File Table): всё, включая саму MFT и битовые карты, — это файлы с записями. Атрибуты бывают резидентными (мелкий файл целиком лежит внутри записи MFT в 1 KiB) и нерезидентными. Журналируются только метаданные ($LogFile), данных в журнале нет. Полезные особенности: альтернативные потоки данных (ADS — file.txt:hidden), разреженные файлы, жёсткие ссылки, junction points, теневые копии через VSS, пофайловое сжатие. ReFS — CoW-ответ Microsoft с чек-суммами (integrity streams) и интеграцией со Storage Spaces, но до сих пор нельзя грузиться с ReFS и часть возможностей NTFS отсутствует.
PS> fsutil fsinfo ntfsinfo C:
PS> fsutil dirty query C:
PS> Get-Item C:\data\file.txt -Stream * # альтернативные потоки данных
Solaris / illumos. Родина ZFS; UFS остался legacy. illumos (OpenIndiana, SmartOS) — эталонная ветка, из которой OpenZFS взял многое.
Plan 9. Довёл «всё есть файл» до логического конца: каждый ресурс — файл, а протокол 9P одинаково работает и локально, и по сети; у каждого процесса своё пространство имён. Идеи прямо повлияли на монтирование в namespaces Linux и на /proc. Подробнее — в Другие ОС.
Псевдо-ФС, наложенные ФС и сеть
Не всякая ФС лежит на диске:
- tmpfs — файлы прямо в page cache, могут уходить в swap.
/dev/shm,/run,/tmpво многих дистрибутивах. - procfs / sysfs — интерфейс к структурам ядра в виде файлов. Ничего на диске нет;
read()вызывает функцию ядра, которая формирует текст на лету. Именно поэтому размер файлов в/proc— нули. - overlayfs — основа контейнеров: несколько «нижних» слоёв только для чтения, один «верхний» для записи. Запись в файл нижнего слоя запускает copy_up — файл целиком копируется наверх (отсюда всплеск latency при первой записи в большой файл внутри контейнера), удаление создаёт «whiteout».
$ sudo mount -t overlay overlay \
-o lowerdir=/img/base:/img/layer1,upperdir=/rw/diff,workdir=/rw/work /merged
$ findmnt -t overlay -o TARGET,SOURCE,OPTIONS
TARGET SOURCE OPTIONS
/var/lib/docker/overlay2 overlay rw,lowerdir=...,upperdir=...,workdir=...
- FUSE — файловая система в пользовательском процессе. Ядро пересылает запросы демону через
/dev/fuse. Так сделаны sshfs, s3fs, rclone, ntfs-3g. Цена — переключения контекста на каждую операцию; на метаданных FUSE в разы медленнее нативной ФС, но зато писать её можно на чём угодно. - NFS/SMB — сетевые ФС с ослабленной семантикой. NFSv3 использует close-to-open consistency: изменения гарантированно видны другому клиенту, только если писатель закрыл файл, а читатель после этого открыл. Блокировки по сети ненадёжны. Никогда не кладите на NFS файл SQLite или каталог данных СУБД.
Про контейнеры и изоляцию — в Виртуализация и контейнеры и Безопасность и изоляция.
Типичные заблуждения
«write() вернул успех — данные на диске». Нет. Только в page cache. Долговечность даёт fsync/fdatasync.
«Журналируемая ФС не теряет мои данные». Журнал по умолчанию защищает метаданные ФС, а не содержимое ваших файлов.
«df показывает свободное место». На btrfs и ZFS df врёт по построению (чанки, сжатие, снапшоты, резервации). Пользуйтесь btrfs filesystem usage и zfs list -o space. На ext4 df не учитывает 5 % зарезервированных для root блоков (tune2fs -m 1 уменьшает резерв). А ещё бывает ENOSPC от исчерпания inode — смотрите df -i.
«du и ls -l должны совпадать». Нет: разреженные файлы, reflink-копии, сжатие, speculative preallocation в XFS, хвостовые блоки. du --apparent-size показывает логический размер, du — занятое место.
«CoW-ФС всегда медленнее/быстрее». Зависит от нагрузки. Последовательная запись и снапшоты — быстро, случайная перезапись в большом файле — медленно из-за фрагментации и лишних чтений родительских узлов.
«flock и fcntl-локи взаимозаменяемы». Нет. Классическая ловушка POSIX-локов (fcntl(F_SETLK)): они привязаны к паре (процесс, файл), а любой close() любого дескриптора этого файла в процессе снимает все локи. Библиотека, которая открыла и закрыла ваш файл, молча снимет вашу блокировку. Решение — OFD-локи (F_OFD_SETLK, Linux 3.15+), привязанные к открытому описанию файла и корректно работающие между потоками.
«noatime — микрооптимизация». На ФС с большим потоком чтений обновление времени доступа превращает чтение в запись. По умолчанию с 2.6.30 действует relatime (обновлять не чаще раза в сутки), но для СУБД и почтовых хранилищ noatime до сих пор даёт измеримый выигрыш.
«discard в mount-опциях — правильный способ делать TRIM». Синхронный discard замедляет удаление файлов. Практика — периодический батч: fstrim.timer в systemd или zpool trim / -o discard=async в btrfs.
Практикум: что смотреть при разборе инцидента
# 1. Что вообще смонтировано и с какими опциями
$ findmnt -D
$ cat /proc/self/mountinfo | head
# 2. Место: и байты, и inode, и удалённые-но-открытые файлы
$ df -h; df -i; lsof +L1 2>/dev/null | head
# 3. Кто и как нагружает I/O
$ iostat -xz 1 3
$ sudo iotop -oPa
$ sudo biolatency-bpfcc 10 1 # гистограмма задержек через eBPF
# 4. Что делает конкретный процесс на уровне syscall
$ sudo strace -f -e trace=openat,read,write,fsync,fdatasync,rename -T -p 1204
# 5. Статистика самой ФС
$ cat /sys/fs/ext4/nvme0n1p2/session_write_kbytes
$ sudo xfs_io -c "stat" -c "bmap -vp" /mnt/data/big.db
$ sudo btrfs filesystem usage /mnt
$ zpool iostat -v 1
# 6. Ошибки уровня блочного устройства и ФС
$ sudo dmesg -T | grep -Ei 'ext4|xfs|btrfs|zfs|I/O error|remount'
$ sudo smartctl -a /dev/nvme0n1 | grep -Ei 'media|error|percentage'
Про perf, eBPF и трассировку целиком — в Наблюдаемость и производительность ОС.
Общий вектор развития виден невооружённым глазом: от «структура на диске + многочасовой fsck» (Unix V6, BSD FFS 1984) через «журнал» (ext3 2001, NTFS, WAPBL) к «copy-on-write + сквозные чек-суммы» (ZFS 2005, btrfs 2007, APFS 2017, bcachefs в mainline с 6.7). Причина — не мода, а изменение железа: ёмкость дисков выросла на четыре порядка, вероятность тихого повреждения на терабайтах перестала быть пренебрежимой, а флеш сделал последовательную запись в свободное место дешевле перезаписи на месте.
Мини-итог
- Файловая система — персистентная структура данных поверх плоского массива блоков, обязанная переживать сбой питания.
- VFS даёт единый интерфейс: superblock, inode, dentry, file. inode — объект, dentry — имя; отсюда жёсткие ссылки и «удалил, а место не вернулось».
- Различие ФС сводится в основном к тому, как хранится карта «логический блок → физический»: косвенные блоки (ext2), экстенты (ext4), B+ деревья (XFS), CoW B-деревья (btrfs, ZFS).
- Журналирование защищает метаданные и убирает многочасовой fsck. Режим
data=ordered— разумный дефолт;data=writebackнебезопасен. - CoW делает снапшоты и чек-суммы почти бесплатными ценой фрагментации при случайной перезаписи.
write()ничего не гарантирует. Долговечность — этоfsync/fdatasync, плюсfsyncкаталога послеrename, плюсF_FULLFSYNCна macOS.- Выбор ФС — инженерное решение под нагрузку: XFS/ext4 под СУБД, ZFS под хранилища данных, btrfs под снапшоты рабочей станции.
Источники
Книги:
- Marshall Kirk McKusick, George Neville-Neil, Robert Watson. The Design and Implementation of the FreeBSD Operating System, 2nd ed. — эталонное описание UFS, soft updates и слоя vnode.
- Robert Love. Linux Kernel Development, 3rd ed. — главы про VFS и page cache.
- Remzi and Andrea Arpaci-Dusseau. Operating Systems: Three Easy Pieces — бесплатно онлайн, часть про персистентность (главы 39–45) — лучший вводный текст по теме.
- Brendan Gregg. Systems Performance, 2nd ed. — методология диагностики файловых и дисковых подсистем.
Статьи и спецификации:
- Ohad Rodeh. «B-trees, Shadowing, and Clones», ACM TOS, 2008 — теоретическая основа btrfs.
- Marshall Kirk McKusick, Gregory Ganger. «Soft Updates: A Technique for Eliminating Most Synchronous Writes», USENIX 1999.
- Mendel Rosenblum, John Ousterhout. «The Design and Implementation of a Log-Structured File System», 1991.
- Nimrod Megiddo, Dharmendra Modha. «ARC: A Self-Tuning, Low Overhead Replacement Cache», FAST 2003 — алгоритм кэша ZFS.
- Vijay Chidambaram et al. «Optimistic Crash Consistency», SOSP 2013 — цена барьеров и как их избежать.
Документация:
- Documentation/filesystems в дереве ядра Linux — VFS, ext4, XFS, overlayfs из первых рук.
- ext4 Data Structures and Algorithms — формат на диске побайтово.
- OpenZFS Documentation и ZFS on Linux performance tuning.
- btrfs Wiki — включая честный статус RAID5/6.
- XFS Algorithms & Data Structures.
- Man-страницы:
man 2 open,man 2 fsync,man 2 rename,man 2 fcntl(раздел про OFD-локи),man 5 ext4,man 8 xfs_repair,man 8 zfsprops,man 4 ffs(OpenBSD/FreeBSD). - LWN: «PostgreSQL’s fsync() surprise», «Toward a better fsync() for Linux».
Что дальше
Мы разобрали, как ядро превращает блоки в файлы, но всё время упирались в фразу «дальше запрос уходит в блочный слой и драйвер». Пора разобрать, что там: как устроены очереди запросов, зачем нужен DMA, как работают прерывания и почему обработчик прерывания нельзя писать как обычную функцию.
Следующая статья: Ввод-вывод, драйверы, прерывания и DMA.