Today

Файловые системы: fsync и потерянные данные

Представьте: вы записали конфигурационный файл. Программа отработала без ошибок. Через секунду отключилось питание. Вы перезагружаете машину и обнаруживаете, что файл пуст. Или, что ещё хуже, содержит смесь старого и нового содержимого, например, половину старого JSON и половину нового.

Это не гипотетическая ситуация. Это реальность, с которой сталкивались пользователи Ubuntu в 2009 году, разработчики PostgreSQL в 2018 году, и которая подстерегает любого программиста, не понимающего, как на самом деле устроена запись на диск. Давайте разберёмся, начиная с самого фундамента.

Иллюзия: write() не пишет на диск

Когда программа вызывает write(), данные попадают не на диск, а в страничный кеш ядра — область оперативной памяти, где ОС буферизует дисковый ввод-вывод. Ядро помечает страницы как «грязные» и возвращает управление программе. С точки зрения программы запись завершена. С точки зрения диска она ещё не начиналась.

Ядро само решает, когда сбросить грязные страницы на диск. Может через секунду, может через тридцать. Если в этот промежуток отключится питание — данные потеряны. write() вернул успех, программа продолжила работу, а на диске ничего не изменилось.

Для буферизации есть веские причины. Прямая запись на диск — операция на порядки медленнее, чем копирование в память. Ядро группирует мелкие записи, объединяет соседние блоки, переупорядочивает операции для оптимальной работы с диском. Без буферизации простой вызов printf() мог бы занимать миллисекунды.

Но это означает, что между «программа считает, что данные записаны» и «данные действительно на диске» — пропасть. И единственный мост через неё — системный вызов fsync().

fsync: единственная гарантия

fsync() блокирует выполнение до тех пор, пока все данные и метаданные указанного файла не окажутся на физическом носителе. Точнее, пока диск не подтвердит, что записал их. Некоторые диски врут про это, но это отдельная история.

Существует облегчённая версия — fdatasync(). Она сбрасывает данные и существенные метаданные (размер файла), но не обновляет временные метки (mtime, atime). Для файлов, размер которых не меняется между записями — например, логов фиксированного размера — это экономит одну дисковую запись. Бенчмарки InnoDB показывают разницу: ~4040 мкс/обновление с fdatasync() против ~6520 мкс с fsync().

На macOS ситуация ещё интереснее. fsync() сбрасывает данные из страничного кеша ядра, но не из кеша самого диска. Для полной гарантии нужен fcntl(F_FULLFSYNC) — команда, которая заставляет контроллер диска сбросить собственный буфер. Go-шный File.Sync() вызывает обычный fsync(), а не F_FULLFSYNC — открытый issue #26650, который не решён по сей день.

Казалось бы, достаточно вызывать fsync() после каждой записи. Но нет — это только начало.

Метаданные: вторая половина проблемы

Файловая система хранит два типа информации. Данные — содержимое файлов. Метаданные — имена файлов, размеры, права доступа, расположение блоков на диске, каталоги. Обновление одного файла может потребовать нескольких записей: обновить блоки данных, обновить inode (размер, время модификации), обновить битовую карту свободных блоков.

Сбой питания посередине этой цепочки — катастрофа. Данные записаны, а метаданные нет? Inode указывает на блоки, которые не были выделены? Каталог ссылается на inode, который ещё не создан? Результат — повреждённая файловая система. До появления журналирования после каждого сбоя приходилось запускать fsck — полную проверку целостности. На больших дисках это занимало часы.

Журналирование: сначала запиши, что собираешься делать

Решение пришло из мира баз данных. Прежде чем изменить файловую систему, ядро записывает намерение в специальную область — журнал (journal или log). «Собираюсь обновить inode 42, выделить блоки 1000–1003, добавить запись в каталог.» Только после того как намерение зафиксировано на диске, ядро выполняет реальные изменения. Если сбой произойдёт до фиксации журнала — ничего не менялось. Если после фиксации, но до завершения операции — при следующей загрузке ОС проиграет журнал и завершит работу.

Первой массовой файловой системой с полноценным журналированием стала NTFS — ещё в Windows NT 3.1 (1993). Её транзакционный журнал ($LogFile) защищает метаданные: если сбой произойдёт посередине операции, при загрузке NTFS проиграет или откатит незавершённые транзакции. Данные, как и в большинстве журналируемых систем, не журналируются. В мире UNIX и Linux полноценное журналирование пришло позже.

ext3 (2001) принесла журналирование в Linux. ext4 (2008) стала её наследником и текущим стандартом. Три режима журналирования ext4 отражают компромисс между надёжностью и производительностью:

  • journal — журналируются и данные, и метаданные. Самый безопасный, самый медленный: каждый блок записывается дважды (сначала в журнал, потом на место).
  • ordered (по умолчанию) — журналируются только метаданные, но данные записываются до фиксации метаданных в журнале. Гарантия: метаданные не будут указывать на мусорные блоки.
  • writeback — журналируются только метаданные, данные записываются в произвольном порядке. Самый быстрый, но при сбое в файле может оказаться мусор.

Журналирование защищает целостность файловой системы — каталоги не будут повреждены, inode не будут потеряны. Но оно не защищает содержимое ваших файлов. Для этого нужен fsync(). И правильный паттерн записи.

Почему rename атомарен, а write — нет

write() может записать данные частично. Если сбой произойдёт на полпути, файл будет содержать смесь старого и нового содержимого. Ни старая версия, ни новая — мусор.

rename() работает иначе. POSIX гарантирует: переименование файла в пределах одной файловой системы атомарно. Файл имеет либо старое имя, либо новое — никогда промежуточное состояние. Это потому, что rename() обновляет одну запись в каталоге — а обновление одной записи в журналируемой файловой системе атомарно.

Из этих двух фактов рождается паттерн, который используется повсюду — от текстовых редакторов до баз данных:

  1. Создать временный файл в той же директории (и, значит, той же файловой системе).
  2. Записать новые данные во временный файл.
  3. fsync() временного файла — гарантировать, что данные на диске.
  4. rename() временного файла поверх целевого — атомарная замена.
  5. fsync() родительской директории — гарантировать, что запись в каталоге на диске.

Шаг 5 забывают чаще всего. fsync() файла не гарантирует, что запись в каталоге (то есть сам факт, что файл теперь называется по-новому) тоже сохранена. Без fsync() директории после перезагрузки может оказаться, что rename не произошёл.

Шаг «та же файловая система» — критичен. rename() между разными файловыми системами возвращает EXDEV, и атомарность теряется. Нужна копия + удаление — и с ней все прежние проблемы.

Библиотеки, реализующие этот паттерн правильно: google/renameio (Go), python-atomicwrites (Python). В Rust — rust-atomic-write-file, хотя в нём обнаружен баг: fsync() на анонимный файл (O_TMPFILE) на Btrfs — операция-пустышка.

Два знаменитых провала

ext4 и пустые файлы (2009)

Многие программы использовали упрощённый паттерн: записать данные → закрыть файл → rename(). Без fsync(). На ext3 это работало: режим ordered гарантировал запись данных в течение примерно пяти секунд, до фиксации метаданных.

ext4 ввёл отложенную аллокацию (delayed allocation) — оптимизацию, при которой ядро не выделяет дисковые блоки в момент write(), а откладывает это до момента фактической записи на диск. Данные могут оставаться в памяти до минуты. И вот что получалось: rename() попадал в журнал метаданных и фиксировался. А содержимое нового файла всё ещё оставалось в страничном кеше. При сбое: переименование произошло, а файл пуст. Старые данные потеряны, новые так и не записались.

Баг Ubuntu #317781: пользователи обнаруживали файлы нулевой длины после сбоев. Тед Цо, мейнтейнер ext4, объяснил позицию разработчиков: ext4 ведёт себя строго по POSIX — write() без fsync() не даёт никаких гарантий. ext3 давал «бонусную» гарантию, которой не было в спецификации, и программы привыкли на неё полагаться.

Начиная с Linux 2.6.30 (2009) ядро обнаруживает паттерн «write-rename» и принудительно выделяет блоки перед завершением rename(). Это митигация — не полное решение. Правильный подход — всегда вызывать fsync() перед rename().

PostgreSQL и fsync (2018)

Вернёмся к истории из вступления — теперь у нас есть контекст, чтобы понять её глубину.

PostgreSQL, как и большинство баз данных, полагается на fsync() для гарантии персистентности. При контрольной точке (checkpoint) PostgreSQL вызывает fsync() для каждого изменённого файла данных. Если fsync() вернул ошибку, PostgreSQL повторяет контрольную точку.

Последовательность событий бага:

  1. PostgreSQL записывает данные. Ядро помечает страницы как «грязные».
  2. Ядро пытается записать на диск. Диск возвращает ошибку ввода-вывода.
  3. Ядро помечает страницу флагом EIO. PostgreSQL вызывает fsync(), получает EIO.
  4. PostgreSQL повторяет контрольную точку — снова вызывает fsync().
  5. Но предыдущий fsync() уже очистил флаг EIO. Повторный вызов возвращает успех.
  6. Данные не на диске, но PostgreSQL считает контрольную точку успешной. Тихая потеря данных.

Тед Цо объяснил логику ядра: страницы помечаются как «чистые» после ошибки, потому что самый частый сценарий ошибки — пользователь вынул USB-накопитель. Если оставить «грязные» страницы для отсутствующего устройства, можно исчерпать оперативную память.

Ещё одна грань проблемы — многопроцессность. PostgreSQL предполагал, что если один процесс получил ошибку fsync(), то другие процессы с дескриптором того же файла тоже её увидят. В Linux до 4.13 иногда никто не получал ошибку. С 4.13 ошибку получает только первый вызвавший fsync().

Исправление в PostgreSQL 12+: при ошибке fsync() — немедленный PANIC (аварийное завершение). При перезапуске WAL replay повторяет потерянные записи. Это грубо, но безопасно. Аналогичные изменения были внесены в MySQL/InnoDB и MongoDB/WiredTiger.

Долгосрочное решение — переход на Direct I/O: прямая запись, минуя страничный кеш ядра, с полным контролем над буферизацией. Андрес Фройнд, один из ведущих разработчиков PostgreSQL, признал, что это единственный надёжный путь.

Другой подход: Copy-on-Write

Всё описанное выше — проблемы перезаписывающих файловых систем: ext3, ext4, XFS. Данные изменяются на месте, а журнал нужен, чтобы не потерять целостность при сбое посередине. Но что, если вообще не перезаписывать данные?

ZFS: файловая система, которая не доверяет дискам

Джефф Бонвик и Мэтт Аренс создали ZFS в Sun Microsystems. Первый релиз — 2005 год (OpenSolaris). Фундаментальный принцип — Copy-on-Write (CoW): данные никогда не перезаписываются. Новая версия блока записывается в свободное место на диске. Когда запись завершена, указатель в родительском блоке атомарно переключается на новое место. Старый блок становится свободным.

Это решает проблему целостности элегантнее, чем журналирование. Не нужен журнал — каждая операция и так атомарна: указатель либо переключился (новая версия), либо нет (старая версия). Снимки (snapshots) становятся практически бесплатными — достаточно запретить освобождение старых блоков: один указатель, и вся файловая система «заморожена» в текущем состоянии.

ZFS идёт дальше: контрольная сумма каждого блока хранится в родительском блоке, а не рядом с данными. Если данные повреждены (тихий сбой диска, bit rot), ZFS обнаруживает это при чтении и, если есть зеркало или RAID-Z, автоматически восстанавливает. ext4 об этом не узнает — просто отдаст повреждённые данные.

Проблема ZFS — лицензия CDDL, несовместимая с GPL. Это не позволяет включить ZFS в основное ядро Linux. В феврале 2025 года в OpenZFS создан issue #17047 с планом двойного лицензирования — но Oracle, основной правообладатель, молчит.

Btrfs: CoW, рождённый для Linux

Крис Мэйсон, бывший разработчик ReiserFS, начал Btrfs в Oracle в 2007 году. Идея: а что если вообще всё в файловой системе — данные, метаданные, каталоги, снимки — будет элементами в CoW B-tree? Единый механизм для всего. Btrfs включена в ядро Linux с 2009 года.

Признанная в РФ экстремистской компания Meta (Facebook) развернула Btrfs на миллионах серверов. Снимки для мгновенного создания контейнеров, компрессия для исходного кода, интеграция с cgroup2 для изоляции ввода-вывода. Йозеф Бацик (инженер Meta) заявил в 2025 году: Btrfs сэкономила компании миллиарды долларов на инфраструктуре.

Но есть ложка дёгтя: RAID-5/RAID-6 в Btrfs содержит write hole bug с ~2012 года. Более десяти лет не исправлен, официально помечен как нестабильный. Ни одна компания, нанимающая разработчиков Btrfs, не заинтересована в починке.

bcachefs: талантливый изгнанник

Кент Оверстрит создал bcachefs — CoW-файловую систему с шифрованием, сжатием, контрольными суммами — и добился включения в ядро Linux 6.7 (январь 2024). Технически bcachefs вызывала интерес: современный дизайн, единый код для SSD и HDD.

Но межличностные конфликты оказались фатальны. Бан за нарушение Code of Conduct (ноябрь 2024), объявление Торвальдса о планах удаления (июнь 2025), и окончательное исключение из ядра 6.18 (сентябрь 2025). Теперь bcachefs — внешний DKMS-модуль. Причина удаления — не технические проблемы, а рабочий процесс: крупные патчи поздно в merge window, разработка во время release candidates. Предостережение для авторов: код — не единственное, что имеет значение в ядре.

SQLite: как правильно работать с файлами

Все проблемы, описанные выше, — это проблемы надёжной записи на диск. И есть программа, которая решила их лучше всех: SQLite.

«Think of SQLite not as a replacement for Oracle but as a replacement for fopen()»

— Ричард Хипп, создатель SQLite

SQLite — самая распространённая база данных (и, вероятно, одна из самых распространённых программ) в мире. Более 1 триллиона активных экземпляров: каждый телефон, каждый браузер, лётное ПО Airbus A350, мультимедийные системы Bosch в автомобилях GM и Nissan. SQLite используется чаще, чем все остальные СУБД вместе взятые.

SQLite решает проблему надёжности через WALWrite-Ahead Logging (опережающая запись в журнал, начиная с версии 3.7.0). Принцип тот же, что в журналируемых файловых системах: прежде чем изменить основной файл базы данных, SQLite записывает изменения в отдельный WAL-файл. Читатели работают с основной базой и проверяют WAL на наличие свежих версий страниц. Ключевое преимущество: читатели и писатель не блокируют друг друга.

PRAGMA journal_mode=WAL;    -- включить WAL (один раз, персистентно)
PRAGMA synchronous=NORMAL;  -- рекомендуемая пара с WAL

Периодически фоновый процесс checkpointing переносит данные из WAL в основной файл. По умолчанию — при накоплении 1000 страниц. SQLite не притворяется серверной СУБД. Он не масштабируется горизонтально, не поддерживает одновременных писателей, не работает через сеть. Но в том, что он делает — атомарные транзакции в одном файле — он делает это лучше, чем что-либо ещё.

Ричард Хипп и команда из трёх человек не принимают внешних контрибуций. Код SQLite — в общественном достоянии.

SSD: другие правила

Все файловые системы, о которых мы говорили, проектировались в эпоху вращающихся дисков. Головка чтения-записи перемещается к нужной дорожке — seek time, десятки миллисекунд. Оптимизация — минимизировать перемещения: последовательная запись быстрее случайной.

SSD перевернули эти предположения. Случайное чтение — быстрое, seek time отсутствует. Но перезапись невозможна напрямую: чтобы записать данные в занятую ячейку, нужно стереть целый блок (128–512 КБ), перенести «живые» данные из этого блока в другое место, и лишь затем записать. Это write amplification — реальные записи на диск превышают объём данных от приложения.

F2FS (Flash-Friendly File System, Samsung, 2012, автор Чжэгук Ким) спроектирована с учётом физики NAND flash: log-structured подход (данные всегда пишутся в новое место), multi-head logging для разных типов данных, минимизация write amplification. F2FS развёрнута на сотнях миллионов Android-устройств — Google Pixel, Samsung Galaxy.

Что это значит для программиста

Ни Go, ни Rust, ни Zig не предоставляют в стандартной библиотеке высокоуровневого API для атомарной замены файлов с правильным fsync. Везде — ручная работа или сторонние библиотеки.

Go. Последовательность: os.Createf.Writef.Sync()f.Close()os.Rename(). Важный нюанс: defer f.Close() — антипаттерн для записываемых файлов. Close() может вернуть ошибку (данные не сброшены), а defer её проигнорирует. Библиотека google/renameio реализует паттерн правильно, но только для POSIX.

Rust. std::fs::File::sync_all() = fsync(), sync_data() = fdatasync(). Но в стандартной библиотеке нет API для fsync директории — нужно вручную открыть её как file descriptor. Сторонние крейты: rustix::fs::fsync.

Zig. std.fs.File.sync() = fsync(). Документация сообщает: «this does not ensure that metadata for the directory containing the file has also reached disk». Открытый issue #15563: fsync на открытой директории вызывает panic.

Windows / NTFS. FlushFileBuffers() — аналог fsync(). Атомарного rename() в POSIX-смысле нет: MoveFileEx() с флагом MOVEFILE_REPLACE_EXISTING не даёт таких гарантий. Для безопасной замены файла существует специальный API — ReplaceFile(). Microsoft пыталась решить проблему системно: Transactional NTFS (TxF) в Windows Vista (2006) давал ACID-транзакции для файловых операций, но оказался настолько сложным в использовании и обслуживании, что был депрекейтнут в Windows 8. Рекомендация Microsoft — использовать ReplaceFile() или SQLite.

Общая проблема: все три языка дают инструменты, но не дают рецепт. Правильная запись файла — пять шагов с неочевидными граничными случаями, и ни один из них не автоматизирован стандартной библиотекой.

Что дальше: io_uring

Традиционный ввод-вывод в Linux — это один системный вызов на каждую операцию. read() — переключение контекста из пользовательского пространства в ядро и обратно. write() — ещё одно. fsync() — ещё. Для базы данных, выполняющей миллионы операций в секунду, накладные расходы на переключение контекста становятся узким местом.

Linux AIO (libaio, ~2002) пытался решить эту проблему, но оказался слишком ограничен: поддерживал только O_DIRECT, только блочный ввод-вывод, не умел fsync(), не работал с сетевыми сокетами. На практике годился лишь для узкого класса задач.

io_uring (Linux 5.1, 2019, Йенс Аксбё) решил проблему элегантнее — через два кольцевых буфера в памяти, разделяемой между ядром и пользовательским пространством:

  • Submission Queue (SQ) — приложение записывает сюда описания операций (SQE, Submission Queue Entry): код операции, файловый дескриптор, смещение, буфер, длина.
  • Completion Queue (CQ) — ядро записывает сюда результаты (CQE, Completion Queue Entry): статус, количество обработанных байт.

Ключевая идея — амортизация системных вызовов. Вместо одного вызова на каждую операцию приложение накапливает десятки SQE в очереди и отправляет их одним вызовом io_uring_enter(). А в режиме SQPOLL даже этот вызов не нужен: выделенный поток ядра сам опрашивает SQ — ноль системных вызовов в горячем пути.

Связанные операции (linked SQE) позволяют выстроить цепочку: open → write → fsync → close — и отправить её за одну подачу. Если какое-то звено цепочки завершится ошибкой, остальные отменяются. Это именно то, что нужно для нашего паттерна атомарной записи: создать файл, записать данные, fsync, переименовать — всё в одной цепочке без возврата в пользовательское пространство между шагами.

Фиксированные буферы и дескрипторы. Можно зарегистрировать буферы памяти и файловые дескрипторы один раз при инициализации. Дальнейшие операции используют индексы вместо реальных дескрипторов — ядро не тратит время на повторный поиск и проверку.

NVMe passthrough. IORING_OP_URING_CMD отправляет команды напрямую на NVMe-устройство, минуя блочный уровень ядра. Для баз данных это +20–30 % пропускной способности — прямой доступ к SSD без прослоек.

Результаты: Netflix в 2020 году сообщил о 100 Гбит/с TLS-трафика через io_uring. PostgreSQL 18 (сентябрь 2025) добавил поддержку io_uring для асинхронного ввода-вывода — шаг к Direct I/O, о котором мечтали разработчики PostgreSQL после бага 2018 года. RocksDB, Ceph, SPDK — всё больше систем переходят на io_uring.

Но есть обратная сторона. io_uring — это огромная поверхность атаки в ядре. Множество опкодов, сложная логика управления буферами, асинхронное выполнение — всё это привело к серии CVE. Google отключил io_uring для непривилегированных процессов в ChromeOS и Android. Ряд дистрибутивов последовал примеру. Дебаты о балансе между производительностью и безопасностью продолжаются — и это ещё одна иллюстрация того, что в системном программировании серебряных пуль не бывает.

Примеры кода

В файлах — три примера:

  • fs_demo.rs — простая запись, sync_all(), паттерн write-fsync-rename с временным файлом;
  • fs_demo.gofile.Sync(), os.Rename(), fsync директории, история бага PostgreSQL, SQLite;
  • fs_demo.zig — концептуальная демонстрация: fsync, атомарная замена, журналирование, CoW, размеры блоков.

Источники