Операционные системы Файловые системы: VFS, ext4, XFS, btrfs, ZFS, журналирование
0%

Файловые системы: VFS, ext4, XFS, btrfs, ZFS, журналирование

Файловые системы: 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) и импульсом на шине:

Три вывода для практики:

  1. Попадание в page cache — это memcpy. Разница между кэш-хитом и NVMe-чтением — примерно 200 нс против 80–100 мкс, то есть в 400 раз. Основная работа по ускорению I/O — не «быстрее диск», а «больше попаданий».
  2. Трансляция «логический блок файла → физический блок устройства» — это единственное, чем принципиально различаются ФС на этом пути. Всё остальное общее. Как именно ФС хранит эту карту, определяет её характер.
  3. 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.

Раскладка ext4 на диске: группы блоков, inode и дерево экстентов

Посмотреть реальную карту файла:

$ 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 — выбрасывает. Восстановление занимает секунды.

В 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): при изменении любого блока пишем новую копию в свободное место, затем обновляем указатель на неё в родительском узле — который тоже копируется, и так до корня. Последней атомарно записывается новая версия корня. Если питание пропало на любом шаге — корень всё ещё указывает на старое, полностью консистентное дерево.

Copy-on-Write: обновление дерева без перезаписи на месте

Из этой конструкции почти бесплатно вытекают три вещи:

  1. Снапшот = сохранить старый корень и не освобождать блоки, на которые он ссылается. Стоимость создания — O(1).
  2. Контрольные суммы естественно ложатся в родительский узел: узел хранит и указатель на ребёнка, и его чек-сумму. Получается дерево Меркла, покрывающее всю ФС.
  3. Клоны файлов (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 (блочные устройства). Место общее, квоты и резервации логические.

Что делает 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().

Наглядно, кто что делает:

Измерить цену 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. — методология диагностики файловых и дисковых подсистем.

Статьи и спецификации:

Документация:

Что дальше

Мы разобрали, как ядро превращает блоки в файлы, но всё время упирались в фразу «дальше запрос уходит в блочный слой и драйвер». Пора разобрать, что там: как устроены очереди запросов, зачем нужен DMA, как работают прерывания и почему обработчик прерывания нельзя писать как обычную функцию.

Следующая статья: Ввод-вывод, драйверы, прерывания и DMA.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов