Файловые системы

Виртуальная память | Планировщик

Виртуальная память дала каждому процессу изолированное адресное пространство из 4 КБ страниц. Но вся эта память — volatile: при отключении питания содержимое RAM исчезает за миллисекунды. Веб-сервер Nginx записывает access log вызовом write(fd, buf, len). Вызов возвращается за ~1 мкс — данные в оперативной памяти, не на диске. Если через секунду сервер теряет питание, лог потерян. Между write(), который работает с RAM, и диском, который переживает сбои, нужна прослойка. Эта прослойка — файловая система.

Файловая система связывает пять компонентов. Файл — это метаданные (inode: размер, владелец, права, расположение блоков) плюс данные (блоки на диске). Директория — специальный файл, хранящий пары «имя номер inode». Page cache держит в RAM недавно прочитанные и записанные страницы файлов, превращая read() и write() в операции с памятью, а не с диском. Журналирование (journaling) записывает описание изменений до их применения — при сбое ядро повторяет незавершённые транзакции за секунды вместо часовой проверки fsck. Наконец, fsync() даёт программе явный контроль: «запиши всё на диск прямо сейчас».

Диск как плоский массив блоков

С точки зрения ядра, диск — массив блоков фиксированного размера. HDD отдаёт секторы по 512 байт или 4 КБ; SSD — страницы по 4-16 КБ. Ядро Linux работает с блоками (blocks) — логическими единицами, обычно по 4 КБ, выровненными с размером страницы виртуальной памяти.

На диске 1 ТБ помещается ~256 миллионов блоков по 4 КБ. Каждый блок адресуется номером (LBA — Logical Block Address). Ядро может прочитать блок 17 или записать блок 42, но сам диск ничего не знает ни о файлах, ни об именах, ни о правах доступа.

Программе нужен совсем другой интерфейс: открыть /var/log/nginx/access.log, дописать строку, закрыть. Файловая система превращает плоский массив блоков в иерархию именованных файлов с метаданными.

Файловая система — это формат разметки диска плюс код в ядре, который этот формат понимает. При создании ФС (mkfs.ext4 /dev/sda1) утилита записывает на диск служебные структуры: суперблок (superblock) — глобальные параметры ФС (размер блока, число inode, число свободных блоков, UUID (Universally Unique Identifier)), таблицу inode, битовые карты (bitmaps) занятых inode и блоков данных. Суперблок дублируется в нескольких местах на диске — если первая копия повреждена, fsck восстанавливает её из резервной.

Ext4 делит раздел на группы блоков (block groups) — обычно по 128 МБ. Каждая группа содержит свою копию суперблока (не во всех группах — ext4 использует разреженное размещение), bitmap inode, bitmap блоков данных, часть таблицы inode и сами блоки данных. Группа блоков — единица локальности: при создании файла ext4 старается выделить inode и блоки данных в одной группе, чтобы минимизировать перемещение головки HDD при чтении.

раздел ext4
+----------+----------+----------+----------+-----
| group 0  | group 1  | group 2  | group 3  | ...
| super    | super    |          | super    |
| bitmap   | bitmap   | bitmap   | bitmap   |
| inode tbl| inode tbl| inode tbl| inode tbl|
| data     | data     | data     | data     |
+----------+----------+----------+----------+-----

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

Каждый файл на диске описывается структурой фиксированного размера — inode (index node). В ext4 inode занимает 256 байт (настраивается при создании ФС). Inode содержит всё, что ядро знает о файле, кроме имени:

  • тип (обычный файл, директория, символическая ссылка, сокет, pipe)
  • размер в байтах
  • UID/GID (User ID / Group ID — числовые идентификаторы владельца и группы) владельца
  • права доступа (rwx для owner/group/others)
  • временные метки: atime (последний доступ), mtime (последняя модификация данных), ctime (последнее изменение метаданных). Разница между mtime и ctime: chmod меняет ctime, но не mtime (данные не изменились, изменились метаданные). write() меняет обе: данные изменились, метаданные (размер) тоже. Ext4 добавляет четвёртую метку — crtime (creation time), время создания файла
  • счётчик жёстких ссылок (link count)
  • указатели на блоки данных

Каждый inode имеет уникальный номер в пределах файловой системы. Номер inode — это и есть «настоящий адрес» файла. Команда stat /var/log/nginx/access.log покажет номер inode, размер, права и все три временные метки. Имя файла в inode не хранится — это задача директории.

Количество inode определяется при создании файловой системы (mkfs.ext4) и не меняется после. По умолчанию ext4 создаёт один inode на каждые 16 КБ дискового пространства. На разделе 1 ТБ это ~64 миллиона inode. Если все inode заняты, создание новых файлов невозможно, даже если свободное место на диске есть. На практике это бывает при хранении миллионов мелких файлов — например, кэш Nginx с миллионами файлов по 1-4 КБ.

inode #41502 (256 байт)
+---------------------------+
| тип: обычный файл         |
| размер: 84 КБ             |
| uid: 1000  gid: 1000      |
| права: rw-r--r--          |
| atime: 2026-03-23 14:00   |
| mtime: 2026-03-23 13:55   |
| ctime: 2026-03-23 13:55   |
| link count: 1              |
| указатели на блоки данных  |
|   [блок 8001]              |
|   [блок 8002]              |
|   ...                      |
+---------------------------+

Адресация блоков данных

Inode должен как-то указать, в каких блоках на диске лежит содержимое файла. Для маленького файла — тривиально: записать номера блоков прямо в inode. Для файла на 4 ТБ — номера не поместятся в 256 байт. Классическая схема ext2/ext3 решает это через уровни косвенной адресации.

Прямые указатели (direct pointers). 12 указателей прямо в inode, каждый ссылается на один блок по 4 КБ. Итого 12 * 4 КБ = 48 КБ. Этого достаточно для мелких файлов — конфигов, скриптов, логов на несколько десятков килобайт. Один переход: inode блок данных.

Одинарная косвенная (single indirect). Один указатель в inode ведёт на блок, целиком заполненный указателями. В блоке 4 КБ помещается 1024 указателя (4 байта каждый). Ещё 1024 * 4 КБ = 4 МБ. Два перехода: inode indirect-блок блок данных.

Двойная косвенная (double indirect). Указатель ведёт на блок указателей, каждый из которых ведёт на свой indirect-блок. 1024 * 1024 * 4 КБ = 4 ГБ. Три перехода.

Тройная косвенная (triple indirect). 1024^3 * 4 КБ = 4 ТБ. Четыре перехода.

inode
+---------------------+
| direct[0]  -------->  блок данных
| direct[1]  -------->  блок данных
| ...                  |
| direct[11] -------->  блок данных          (48 КБ)
|                      |
| single -------->  [ptr][ptr]...[ptr]       (+4 МБ)
|                       |
|                       +-->  блок данных
|                       +-->  блок данных
|                       ...
| double -------->  [ptr][ptr]...[ptr]       (+4 ГБ)
|                       |
|                       +-->  [ptr]...[ptr]
|                              |
|                              +-->  блок данных
|                      |
| triple -------->  ...                      (+4 ТБ)
+---------------------+

Для маленького файла всё сводится к прямым указателям — одно обращение к диску за данными (inode уже в памяти). Для файла 1 ГБ нужно пройти через double indirect — три обращения на каждый блок. На HDD с ~10 мс на случайное чтение это критично: чтение одного блока из середины большого файла стоит ~30 мс (три уровня косвенности * ~10 мс). На практике indirect-блоки кэшируются в page cache, и повторный доступ обходит диск. Но первое обращение к «холодному» файлу оплачивает полную цену.

Extent-деревья в ext4

Классическая схема указателей дорого обходится большим файлам: база данных PostgreSQL хранит файл таблицы на 8 ГБ — это 2 миллиона блоков, для каждого нужен отдельный указатель. Ext4 заменяет массив указателей на extent-дерево. Extent (экстент) — запись вида «начиная с блока N, следующие M блоков принадлежат файлу». Один extent описывает непрерывный диапазон: вместо миллиона отдельных указателей — одна запись «блок 500000, длина 1000000».

В inode помещается 4 экстента. Когда файл фрагментирован и экстентов больше четырёх, ext4 строит B-дерево экстентов, корень которого лежит в inode. На практике большинство файлов укладываются в 1-2 экстента, если файловая система не сильно фрагментирована. Посмотреть экстенты файла можно через filefrag -v /path/to/file — команда покажет количество экстентов и их расположение на диске.

Директория: имя inode

Inode хранит всё о файле, кроме имени. Имя живёт в директории. Директория — тоже файл (со своим inode), содержимое которого — таблица пар «имя номер inode».

Когда процесс вызывает open("/var/log/nginx/access.log", O_RDONLY), ядро разбирает путь по компонентам:

/             inode 2 (корневая директория, всегда inode 2 в ext4)
  |
  v
  var/        ищем "var" в директории inode 2 -> inode 131073
  |
  v
  log/        ищем "log" в директории inode 131073 -> inode 393217
  |
  v
  nginx/      ищем "nginx" в директории inode 393217 -> inode 393301
  |
  v
  access.log  ищем "access.log" в директории inode 393301 -> inode 393412

Четыре компонента — четыре чтения директорий. Каждое чтение: загрузить inode директории, прочитать её блоки данных, найти нужное имя. На HDD каждое чтение — потенциально ~10 мс случайного доступа. Четыре уровня вложенности — до 40 мс только на разрешение пути, прежде чем начнётся чтение самого файла. В ext4 директории используют хеш-дерево (HTree) для быстрого поиска по имени. Имя файла хешируется функцией half-MD4 (усечённый вариант MD4, оптимизированный для коротких строк), хеш определяет позицию в B-дереве блоков директории. Линейный формат (перебор всех записей) используется только для маленьких директорий, которые помещаются в один блок.

Ядро кэширует результаты в dentry cache (directory entry cache). После первого разрешения /var/log/nginx/access.log повторный open() того же пути находит все компоненты в памяти без обращения к диску.

Жёсткие и символические ссылки

Имя файла в директории — это ссылка на inode. Поскольку inode и имя хранятся раздельно, один inode может иметь несколько имён.

Команда ln /var/log/nginx/access.log /backup/access.log добавляет в директорию /backup/ запись access.log -> inode 393412. Теперь два имени указывают на один inode. Поле link count в inode увеличивается до 2. Оба пути ведут к одним и тем же данным — это не копия, а второе имя.

rm в Unix не удаляет файл — он выполняет системный вызов unlink(), который удаляет запись из директории и уменьшает link count на 1. Данные на диске остаются, пока link count не дойдёт до нуля.

У каждой директории link count >= 2: одна ссылка из родительской директории (запись nginx в /var/log/) и одна из самой себя (запись . внутри /var/log/nginx/). Каждая поддиректория добавляет ещё одну ссылку через свою запись ... Поэтому link count директории = 2 + число поддиректорий. У файла link count = число жёстких ссылок, обычно 1.

Ограничение: жёсткая ссылка работает только в пределах одной файловой системы. Номер inode уникален внутри ФС, но inode 393412 на /dev/sda1 и inode 393412 на /dev/sdb1 — два разных файла. Жёсткие ссылки на директории (кроме . и .., которые создаёт сама ФС) запрещены, потому что они создали бы циклы в дереве каталогов — утилиты вроде find и rm -r зацикливались бы.

ln -s /var/log/nginx/access.log /tmp/log-link создаёт новый файл со своим inode. Содержимое этого файла — строка /var/log/nginx/access.log. При open("/tmp/log-link") ядро читает содержимое ссылки и начинает разрешение пути заново.

Symlink может указывать на несуществующий путь (dangling link), может пересекать границы файловых систем, может ссылаться на директорию. Цена — дополнительное разрешение пути при каждом доступе (на практике дешёво благодаря dentry cache). Ядро ограничивает глубину разрешения symlink до 40 переходов (константа MAXSYMLINKS) — защита от циклических ссылок, где a -> b и b -> a.

Короткие symlink (путь до ~60 байт в ext4) хранят строку-путь прямо в inode — в области, предназначенной для указателей на блоки. Это fast symlink: чтение ссылки не требует отдельного обращения к блоку данных. Для длинных путей выделяется отдельный блок данных — лишнее обращение к диску (или page cache).

У файла два счётчика, управляющих его временем жизни. Link count — количество имён в директориях, указывающих на inode. Open count — количество файловых дескрипторов, открытых на этот inode.

rm уменьшает link count. Ядро освобождает блоки данных и inode только когда оба счётчика равны нулю: ни одного имени и ни одного открытого fd.

Типичная ситуация: приложение открывает лог-файл, получает fd 3. Администратор выполняет rm /var/log/app.log — link count падает до нуля, файл исчезает из директории, ls его не покажет. Но приложение продолжает писать через fd 3 — данные записываются, файл растёт. Место на диске не освобождается, пока приложение не закроет fd (или не завершится). Увидеть такие файлы можно через lsof +L1 — он показывает открытые файлы с link count = 0.

VFS: единый интерфейс для разных файловых систем

На одной машине могут сосуществовать десятки файловых систем: корневой раздел на ext4, раздел данных на XFS, сетевая шара через NFS (Network File System), RAM-диск tmpfs, псевдофайловые системы /proc и /sys. Программа вызывает read(fd, buf, 4096) и не знает, откуда придут данные — с локального SSD, с удалённого сервера или из структуры ядра.

Это возможно благодаря VFS (Virtual File System) — уровню абстракции в ядре, который определяет единый набор операций (open, read, write, close, stat, mkdir, …) и делегирует их конкретной файловой системе через таблицу указателей на функции.

  userspace:  cat /proc/cpuinfo       cat /tmp/data.txt
                  |                        |
  -------  syscall: read()  -------  syscall: read()  ------
                  |                        |
  kernel:       VFS                      VFS
                  |                        |
              procfs                     ext4
            (читает из                (читает блоки
           структур ядра)              с NVMe SSD)

cat /proc/cpuinfo работает ровно так же, как cat /tmp/data.txt. В первом случае VFS вызывает обработчик procfs, который формирует текст на лету из внутренних структур ядра — никакого файла на диске нет. Во втором — VFS вызывает ext4, который находит inode, читает блоки данных из page cache или с диска. Приложение не различает эти случаи.

Каждая файловая система регистрирует в VFS набор callback-функций: как прочитать inode, как найти имя в директории, как записать блок. Структура super_operations содержит указатели на функции для работы с inode (alloc_inode, destroy_inode, write_inode). Структура inode_operations — на функции поиска в директории, создания и удаления файлов. Структура file_operations — на read, write, mmap, fsync. Когда ext4 регистрирует себя в VFS, она заполняет эти структуры своими реализациями. NFS заполняет те же структуры функциями, которые отправляют RPC-запросы (Remote Procedure Call) на удалённый сервер.

Когда ядро получает open("/mnt/nfs/report.csv"), оно по точке монтирования (каталог, к которому подключена отдельная файловая система) определяет, что /mnt/nfs — файловая система NFS, и делегирует все операции NFS-клиенту через его file_operations. Добавление новой файловой системы в Linux — это реализация набора callback-функций и регистрация их в VFS; userspace-интерфейс не меняется.

Page cache: файловые данные в оперативной памяти

Диск медленный: случайное чтение 4 КБ с HDD стоит ~10 мс, с SSD — ~100 мкс. Оперативная память отдаёт те же 4 КБ за ~100 нс — в 100 000 раз быстрее HDD и в 1000 раз быстрее SSD. Ядро использует всю свободную RAM как кэш файловых страниц — page cache.

Чтение: проверяем page cache первым

Когда процесс вызывает read(), ядро не обращается к диску сразу. Сначала проверяет: есть ли нужная страница файла в page cache. Если да — данные копируются из RAM в буфер пользователя. Обращения к диску нет. Это аналог minor page fault в виртуальной памяти: страница уже в RAM, просто не в том адресном пространстве.

Если страницы в кэше нет — ядро читает блок с диска, помещает в page cache и отдаёт процессу. Следующий read() того же участка файла (тем же или другим процессом) найдёт страницу в кэше.

Ядро отслеживает паттерн доступа. Если обнаруживает последовательное чтение (sequential read), включает read-ahead (упреждающее чтение): вместе с запрошенной страницей загружает следующие 32-128 страниц (128-512 КБ). Пока процесс обрабатывает текущие данные, следующие уже в памяти. Для последовательного чтения большого файла это превращает латентность ~100 мкс на каждые 4 КБ в пропускную способность ~500-1000 МБ/с — диск работает в оптимальном режиме, без пауз между запросами.

Запись: page cache как буфер

write() не записывает данные на диск. Вызов копирует данные из буфера пользователя в page cache и помечает страницу как dirty (грязную). Управление возвращается процессу за ~1 мкс. Данные в RAM, не на диске.

Фоновый поток ядра — writeback (обратная запись) — периодически сбрасывает грязные страницы на диск. Два порога управляют его поведением. dirty_background_ratio (по умолчанию 10% RAM) — когда объём грязных страниц достигает этого порога, writeback начинает фоновый сброс, не блокируя процессы. dirty_ratio (по умолчанию 20% RAM) — жёсткий предел: если грязных страниц больше, write() блокируется до тех пор, пока writeback не освободит достаточно места. Кроме того, writeback запускается по таймеру каждые ~5 секунд (dirty_writeback_centisecs = 500). На сервере с 64 ГБ RAM до 12.8 ГБ грязных данных может накапливаться в памяти перед принудительной блокировкой write().

Page cache занимает всю свободную RAM — и это не утечка памяти. Когда процессу нужна память для аллокации, ядро вытесняет чистые (не-dirty) страницы кэша мгновенно — они просто освобождаются, без записи на диск. Грязные страницы сначала записываются на диск, затем освобождаются. Команда free -h показывает buff/cache — это page cache. Его объём — не повод для беспокойства, а признак того, что ядро эффективно использует свободную RAM.

На сервере с 64 ГБ RAM, на котором работает PostgreSQL, типичная картина: 8 ГБ занято процессами, 50 ГБ в buff/cache, 6 ГБ free. При этом available (доступная для аллокации) — 56 ГБ, потому что 50 ГБ кэша могут быть вытеснены по запросу. Если запустить ещё один процесс, требующий 20 ГБ, ядро освободит 20 ГБ из кэша за миллисекунды. Последующие чтения тех файлов пойдут мимо кэша — на диск, но память отдаётся немедленно.

O_DIRECT: обход page cache

Некоторые приложения управляют кэшированием самостоятельно. PostgreSQL поддерживает shared_buffers — собственный кэш страниц в разделяемой памяти с привязкой к WAL-позициям и clock-sweep вытеснением. Двойное кэширование (в shared_buffers и в page cache) расходует RAM впустую. Флаг O_DIRECT при open() говорит ядру: не пропускать ввод-вывод через page cache, писать и читать напрямую с устройства.

O_DIRECT требует, чтобы буфер в памяти и смещение в файле были выровнены по размеру блока (обычно 512 байт или 4 КБ). Невыровненный вызов вернёт ошибку EINVAL. PostgreSQL по умолчанию не использует O_DIRECT (двойное кэширование — осознанный компромисс: page cache помогает при fsync(), упрощает read-ahead). Но некоторые базы данных (MySQL с InnoDB, RocksDB) активно используют O_DIRECT, потому что их внутренний кэш лучше знает приоритеты страниц, чем универсальный LRU (Least Recently Used) ядра.

Crash consistency: что ломается при сбое

Создание файла /var/log/nginx/access.log — не одна атомарная операция, а последовательность шагов:

  1. Найти свободный inode в таблице inode пометить его как занятый в bitmap
  2. Инициализировать inode (тип, права, размер, timestamps)
  3. Найти свободные блоки данных пометить в bitmap как занятые
  4. Записать данные в блоки
  5. Добавить запись access.log -> inode N в директорию /var/log/nginx/

Если питание пропадёт между шагами 3 и 5, на диске окажутся выделенные блоки, на которые не ссылается ни один inode. Bitmap говорит «заняты», но ни одна директория не ведёт к ним. Это утечка дискового пространства — блоки потеряны.

Если сбой между шагами 2 и 3 — inode создан, запись в директории добавлена, но inode указывает на неинициализированные блоки. При чтении файла вернётся мусор — или данные другого файла, если блоки позже будут выделены кому-то ещё.

Ещё один сценарий: дописывание данных в существующий файл. Nginx пишет строку в access.log. Файл вырос за пределы текущего экстента, ext4 выделяет новый блок, обновляет inode (размер, указатель на блок), обновляет bitmap. Сбой после записи данных в новый блок, но до обновления inode — inode по-прежнему указывает на старый размер. Последняя строка лога потеряна, хотя данные физически на диске.

Без файловой системы с журналом единственный способ восстановить консистентность — fsck (file system check): полный обход всех inode, блоков и директорий с проверкой ссылочной целостности. На диске 1 ТБ с миллионами файлов fsck занимает от минут до часов. Всё это время файловая система недоступна.

Журналирование: WAL для файловой системы

Идея та же, что и WAL (Write-Ahead Log) в PostgreSQL: прежде чем менять основные структуры на диске, записать описание всех изменений в отдельный журнал (journal). Журнал — кольцевой буфер фиксированного размера (128 МБ по умолчанию в ext4).

Последовательность операций с журналом:

  1. Собрать все изменения в транзакцию (не в смысле БД — просто группа связанных изменений)
  2. Записать транзакцию в журнал
  3. Записать маркер commit в журнал
  4. Дождаться, пока запись дойдёт до диска (fsync журнала)
  5. Применить изменения к основным структурам на диске
  6. Пометить транзакцию в журнале как завершённую, освободить место

При сбое возможны два состояния. Сбой до записи commit-маркера — транзакция неполная, ядро при монтировании игнорирует её. Основные структуры не тронуты, файловая система консистентна. Сбой после commit-маркера, но до применения к основным структурам — ядро при монтировании проигрывает (replay) транзакцию из журнала. Изменения применяются повторно, файловая система консистентна.

Конкретный пример. Nginx создаёт новый лог-файл error.log. В журнал записывается: «выделить inode 393500, инициализировать метаданные, добавить запись error.log -> 393500 в директорию inode 393301, обновить bitmap inode». Commit-маркер записан, fsync журнала прошёл. Ядро начинает применять изменения к основным структурам на диске — обновляет bitmap, записывает inode, обновляет блок директории. Между записью inode и обновлением директории — сбой питания. При монтировании ядро находит в журнале транзакцию с commit-маркером, видит, что она не отмечена как завершённая, и проигрывает все операции заново: запись в директорию повторяется, файловая система консистентна.

Восстановление занимает секунды: нужно проиграть только незавершённые транзакции из журнала, а не проверять весь диск. На продакшн-серверах это разница между 2-3 секундами простоя и часом с fsck.

Режимы журналирования в ext4

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

journal — полное журналирование: и метаданные, и данные записываются в журнал. Максимальная защита, но все данные пишутся дважды. Используется редко — слишком дорого по I/O.

ordered (режим по умолчанию) — в журнал записываются только метаданные, но данные файла записываются на диск до того, как журнал фиксирует commit-маркер. Порядок гарантирует: если метаданные обновлены (inode указывает на новые блоки), данные в этих блоках уже на диске. При сбое невозможна ситуация, когда inode ссылается на блок с мусором от предыдущего владельца.

writeback (не путать с writeback daemon из раздела о page cache выше — здесь режим журналирования, там фоновый сброс страниц) — в журнал записываются только метаданные, данные могут быть записаны в любой момент — до или после commit-маркера. Самый быстрый режим, но при сбое возможно чтение стейла: inode обновлён, а блок данных содержит старое (или чужое) содержимое.

fsync(): явная гарантия durability

Page cache и writeback daemon создают окно уязвимости: до 5 секунд (или больше при высокой нагрузке) данные существуют только в RAM. Для Nginx access log потеря нескольких строк при сбое — допустимо. Для базы данных, подтвердившей COMMIT клиенту, — катастрофа.

Системный вызов fsync(fd) заставляет ядро записать все грязные страницы файла на диск и дождаться подтверждения от контроллера. После возврата fsync() данные гарантированно на устройстве хранения (при условии, что контроллер не врёт — серверные SSD с конденсаторным PLP честно сбрасывают буфер в NAND при потере питания; десктопные без PLP иногда сообщают о завершении записи до реального сброса в NAND).

Стоимость fsync() определяется устройством. На HDD — 5-15 мс: нужно дождаться, пока головка допишет данные и пластина провернётся. На серверном SSD — 50-200 мкс: контроллер переносит данные из DRAM-буфера (Dynamic RAM) в NAND-флеш и подтверждает. На NVMe SSD с конденсаторным резервированием (PLP — Power Loss Protection) — от 10 мкс: данные считаются надёжными, как только попали в DRAM-буфер контроллера, потому что конденсаторы удержат питание на десятки миллисекунд — достаточно, чтобы контроллер сбросил буфер в NAND.

PostgreSQL обеспечивает Durability в ACID через fsync WAL-файла — журнала упреждающей записи. При COMMIT ядро сбрасывает WAL до позиции commit-записи. На практике несколько одновременных транзакций объединяются в один fsync через group commit — один процесс делает fsync и покрывает все транзакции, записанные с момента предыдущего. Data files (таблицы, индексы) не fsynс’ятся при каждом COMMIT — их сбрасывает checkpoint раз в несколько минут. При 10 000 транзакций в секунду group commit превращает 10 000 потенциальных fsync в сотни, укладываясь в пропускную способность NVMe SSD.

fdatasync(fd) — облегчённая версия: сбрасывает данные файла, но не обязательно обновляет метаданные (например, atime). На практике разница мала, но для write-heavy нагрузок экономит один дополнительный ввод-вывод.

Важный нюанс: fsync() гарантирует durability данных самого файла, но не гарантирует, что запись о файле появилась в директории. Если приложение создаёт новый файл и вызывает fsync() на нём — данные файла на диске, но запись имя -> inode в директории может быть только в page cache. При сбое файл существует (inode и блоки на диске), но в директории его нет — он «потерян». Для полной гарантии нужен fsync() и на fd директории: fsync(dirfd). PostgreSQL делает именно так при создании новых файлов WAL-сегментов.

Файловые системы: ext4 и XFS

Ext4 — файловая система по умолчанию в большинстве дистрибутивов Linux. Поддерживает тома до 1 ЭБ (эксабайт), файлы до 16 ТБ, extent-деревья, журналирование, отложенное выделение блоков (delayed allocation — блоки назначаются не при write(), а при сбросе на диск, что снижает фрагментацию). Ext4 работает надёжно и предсказуемо, но для очень больших файлов и высокого параллелизма записи существуют более эффективные варианты.

XFS — файловая система, разработанная Silicon Graphics в 1993 году для IRIX, портированная в Linux в 2001. Оптимизирована для больших файлов и параллельного I/O: использует B+ деревья для метаданных, выделяет пространство экстентами, поддерживает allocation groups — независимые области диска, позволяющие нескольким потокам выделять блоки параллельно без блокировки. На серверах баз данных и хранилищах с десятками терабайт данных XFS часто показывает лучшую пропускную способность, чем ext4. RHEL (Red Hat Enterprise Linux) 7+ использует XFS как файловую систему по умолчанию.

Обе файловые системы используют журналирование, extent-деревья и page cache. Выбор между ними — вопрос рабочей нагрузки, а не принципиальных различий в архитектуре. Ext4 проще в обслуживании (поддерживает онлайн-расширение и shrink), XFS масштабируется лучше при параллельных операциях с метаданными. Для типичного сервера приложений разница в производительности в пределах 5-10%.

Итоги: от плоского массива блоков до гарантий durability

Файловая система решает задачу в три шага. Inode и директории превращают плоский массив блоков в иерархию именованных файлов — программа работает с путями и правами доступа, а не с номерами секторов. Page cache помещает файловые данные в оперативную память — read() и write() работают со скоростью RAM, а не диска. Журналирование и fsync() обеспечивают durability: журнал защищает метаданные от повреждения при сбое, а fsync даёт программе явный контроль над моментом записи на диск.

Вся эта инфраструктура работает, пока ядро решает, какой процесс выполнять в данный момент. Мы уже видели, что write() возвращается мгновенно, а writeback daemon сбрасывает данные позже — но кто решает, когда запустить writeback, когда вернуть управление Nginx, когда переключить CPU на PostgreSQL? Планировщик (scheduler) — механизм, который распределяет процессорное время между процессами.

Sources


Виртуальная память | Планировщик