ОС · Persistence · 15 мин
fsync и durability: почему теряются данные
write() обманывает тебя
Самое больное и самое частое заблуждение бэкендера звучит так: «я вызвал write(), значит данные на диске». Нет. write() почти всегда возвращает управление до того, как байты реально оказались на пластине диска или в ячейках SSD. И если в этот момент выдернут питание — данные пропадут, хотя твоя программа «думала», что всё успешно сохранено.
Чтобы понять почему, разберём по шагам, что вообще происходит. Начнём с термина, без которого дальше никак.
Page cache (страничный кэш) — это область в RAM, которую ОС использует как буфер между программами и диском. Когда ты пишешь в файл, данные сначала попадают сюда, в оперативку. Когда читаешь файл — ОС тоже сначала смотрит, нет ли нужного куска уже в page cache, чтобы не дёргать медленный диск.
Бытовая аналогия. Представь, что ты не бегаешь относить каждое письмо на почту по отдельности, а складываешь письма в коробку у двери. Коробка — это page cache в RAM (быстро, рядом). Раз в какое-то время курьер забирает всю коробку и везёт на почту — это ОС сбрасывает данные на диск. Пока курьер не приехал, письма физически ещё дома. Если дом сгорит (пропадёт питание) до приезда курьера — письма в коробке сгорят вместе с ним.
Программа RAM Диск
┌─────────┐ ┌──────────────────┐ ┌──────────────┐
│ write() │────────▶│ page cache │ позже │ устройство │
│ │◀────────│ (грязные стр.) │ ───────▶│ (надёжно) │
└─────────┘ return └──────────────────┘ ОС └──────────────┘
▲ сама решает когда
│
write вернулся СРАЗУ, данные ещё только в RAM, на диске их НЕТЧто нарисовано: write() доносит данные только до page cache в RAM и сразу возвращает управление. На диск они попадут когда-то потом, по решению ОС. Между этими двумя моментами данные существуют лишь в оперативке.
Грязные страницы и окно потери данных
Грязная страница (dirty page) — это страница в page cache, которую изменили в RAM, но ещё не записали на диск. «Грязная» = «в RAM новее, чем на диске, их надо синхронизировать». ОС периодически запускает сброс грязных страниц на диск — это называется writeback (или flush). В Linux за это отвечают фоновые потоки и параметры вроде dirty_writeback_centisecs (как часто просыпаться) и dirty_ratio (сколько RAM можно занять грязными страницами, прежде чем тормозить пишущих).
Ключевой момент: между моментом write() и моментом реального writeback существует окно потери данных (data loss window). По умолчанию в Linux оно может достигать нескольких секунд (часто называют ~5–30 секунд под нагрузкой). Если в это окно случится сбой питания или паника ядра — всё, что в нём, исчезнет.
t0: write("заказ #777") данные в page cache (грязная страница)
│
│ ◀───────── ОКНО ПОТЕРИ (секунды) ──────────▶
│
t1: writeback данные на диске, страница "чистая"
Если КРАШ между t0 и t1 ──▶ заказ #777 ПОТЕРЯН,
хотя клиенту уже ответили "200 OK, сохранено"Что нарисовано: временная шкала одной записи. Опасный участок — между write и writeback. Краш на этом участке стирает данные, про которые приложение уже отчиталось как про сохранённые. Для платежей, заказов, очередей это катастрофа.
Важно различать два типа сбоев:
- Краш процесса (упало твоё приложение, panic, kill -9). Данные уже в page cache ядра — ОС жива и спокойно сбросит их на диск сама. Тут потери НЕТ, page cache переживает падение процесса.
- Краш системы (питание, kernel panic, ребут хоста). RAM обнуляется вместе с page cache. Всё, что не успело на диск, — потеряно.
Поэтому page cache защищает от падения приложения, но НЕ защищает от пропадания питания. За второе отвечаешь ты — через fsync.
fsync: «дождись, пока реально запишется»
fsync(fd) — это системный вызов, который говорит ОС: «возьми все грязные страницы вот этого файла, протолкни их на устройство и не возвращай мне управление, пока устройство не подтвердит, что записало». То есть fsync блокируется до завершения физической записи. Когда fsync вернул 0 — вот теперь данные durable (устойчивы к сбою питания).
Durability (долговечность) — это и есть гарантия «если я подтвердил запись, она переживёт перезагрузку/потерю питания». Та самая буква D в ACID.
fd = open("orders.log")
write(fd, "заказ #777") ──▶ page cache (грязная страница)
fsync(fd) ──▶ ОС: проталкиваю на диск... жду...
│ диск: "ОК, записал"
▼
fsync вернулся ──▶ ТЕПЕРЬ данные переживут потерю питанияЧто нарисовано: добавив fsync после write, мы закрываем окно потери. fsync не вернётся, пока диск не подтвердит запись, — окна между «ответил клиенту» и «лежит на диске» больше нет.
Связанный вызов — fdatasync(fd). Он тоже сбрасывает данные файла, но может пропустить часть метаданных (например, время модификации), если они не влияют на чтение данных. За счёт этого он чуть дешевле fsync. Размер файла он сбрасывает, а вот «неважные» атрибуты — нет.
| Вызов | Что делает | Когда данные на диске | Цена |
|---|---|---|---|
| write() | копирует в page cache | потом, когда решит ОС | дёшево |
| fsync() | data + все метаданные файла | когда вернулся | дорого |
| fdatasync() | data + критичные метаданные | когда вернулся | дорого, но дешевле fsync |
| O_DIRECT | минует page cache | сразу при write (но без барьеров — не панацея) | особый случай |
O_DIRECT — флаг открытия файла, при котором запись идёт мимо page cache прямо к устройству. Звучит как «вот оно, надёжно», но это про другое: про обход кэша ОС (БД часто ведут СВОЙ кэш и не хотят дублирования в page cache). O_DIRECT сам по себе НЕ гарантирует, что данные дошли до устройства и пережили барьеры, — durability всё равно достигается через fsync или явные flush-команды. Не путай «мимо page cache» с «гарантированно на блинах».
Подводный камень №1: каталог тоже надо fsync
Вот ловушка, на которой горят даже опытные. Ты создаёшь новый файл, пишешь в него, делаешь fsync(fd). Кажется — всё надёжно. Но запись о том, что файл вообще существует (его имя в каталоге), — это отдельная структура данных, каталог. Про устройство каталогов и inode см. Файлы и каталоги. fsync файла сбрасывает содержимое файла, но НЕ запись о нём в родительском каталоге.
fd = open("/data/new.txt", O_CREAT|O_WRONLY)
write(fd, ...) содержимое ─┐
fsync(fd) содержимое ─┘ на диске ✔
но запись "new.txt" в каталоге /data ── ещё в page cache ✗
КРАШ ──▶ файл существует на диске как inode, но имени НЕТ:
найти его нельзя, как будто файла и не было
Правильно:
dirfd = open("/data", O_RDONLY|O_DIRECTORY)
fsync(dirfd) запись "new.txt" ──▶ на диске ✔Что нарисовано: чтобы новый файл гарантированно «появился» после краша, нужно сделать fsync не только на сам файл, но и на каталог, в котором он лежит. Это классический паттерн «безопасной замены файла»: пишем во временный файл → fsync файла → rename поверх старого → fsync каталога.
Подводный камень №2: диски врут
Даже честный fsync ОС может оказаться недостаточным, потому что у самого накопителя есть свой кэш — встроенная DRAM или энергозависимый буфер. Многие диски, получив команду записи, кладут данные в свой кэш и сразу рапортуют «записано», хотя на пластину/в NAND ещё ничего не легло. С точки зрения скорости это прекрасно, с точки зрения durability — ложь.
Чтобы с этим бороться, существует write barrier (барьер записи) и команда сброса кэша устройства (FLUSH CACHE / FUA — Force Unit Access). Корректный fsync должен не просто отправить данные на диск, но и послать команду «сбрось свой внутренний кэш на стабильный носитель». Если диск игнорирует эту команду или у него нет защиты питания (конденсатора), то при пропадании питания его кэш теряется — и снова потеря данных, несмотря на «успешный» fsync.
ОС fsync ──▶ контроллер диска ──▶ кэш диска (DRAM) ──▶ NAND/пластина
│ ▲
"записал!" (врёт) │
нужен FLUSH/FUA,
чтобы дойти сюда
Честный путь: fsync ОС + barrier/flush + диск с защитой питанияЧто нарисовано: между ОС и физическим носителем стоит ещё один кэш — внутри диска. Без команды сброса этого кэша данные могут зависнуть в нём и сгореть при потере питания. Именно поэтому серверные SSD имеют конденсаторы (power loss protection): они успевают дописать кэш на питании от конденсатора. Вот почему «энтерпрайзный» диск стоит в разы дороже «десктопного» — за честный fsync приходится платить железом.
Почему fsync дорогой и как с этим жить: group commit
fsync — медленная операция. Это не копирование в RAM, это ожидание физического устройства: для HDD — порядка единиц–десятков миллисекунд (см. HDD), для SSD — сотни микросекунд–единицы миллисекунд. Если делать fsync на каждую крошечную транзакцию, пропускная способность обвалится: ты упрёшься в «сколько fsync в секунду тянет диск».
Решение — group commit (групповая фиксация, она же батчинг). Идея: пока один fsync «в полёте», накапливаем несколько транзакций и фиксируем их одним общим fsync. Один поход к диску оплачивает durability сразу для пачки запросов.
Без group commit (1 fsync = 1 транзакция):
T1 ─ fsync(10ms) ─▶ ok
T2 ─ fsync(10ms) ─▶ ok
T3 ─ fsync(10ms) ─▶ ok
3 транзакции = 30 мс, ~100 транзакций/сек
С group commit (1 fsync = пачка):
T1 ┐
T2 ├─ один fsync(10ms) ─▶ ok всем троим
T3 ┘
3 транзакции = 10 мс, в разы выше throughputЧто нарисовано: вместо того чтобы каждая транзакция честно ждала свой fsync, они складываются в группу и оплачивают один общий поход к диску. Латентность одной транзакции может чуть вырасти (ждёт группу), зато throughput системы растёт кратно. Это фундаментальный компромисс durability vs производительность: чем чаще fsync — тем безопаснее, но медленнее.
Как это делают настоящие БД: WAL + fsync
Postgres, etcd, Kafka, SQLite — все они зависят от честного fsync. Главный приём — WAL (Write-Ahead Log, журнал упреждающей записи). Принцип: прежде чем менять основные данные, запиши намерение в последовательный лог и сделай fsync именно лога. Лог пишется строго в конец (последовательно — это быстро даже для HDD), и достаточно зафиксировать на диске только его.
COMMIT транзакции в Postgres:
1. дописать запись в WAL (page cache)
2. fsync(WAL) ◀── вот ТУТ оплачивается durability
3. ответить клиенту "committed"
4. основные таблицы (heap) сбросятся на диск ЛЕНИВО, потом
Краш после шага 2? При старте БД проигрывает WAL ──▶ данные целыЧто нарисовано: durability «прибита» к одному fsync журнала, а не к записи во все таблицы. После краша БД при запуске читает WAL и повторяет (replay) зафиксированные, но не дошедшие до основных файлов изменения. Это прямой мост к журналируемым ФС, где файловая система ровно так же сначала пишет намерение в журнал, делает fsync, и только потом меняет основные структуры — чтобы после сбоя восстановиться в согласованное состояние.
Параметры, которые встретишь на практике: в Postgres fsync = on (выключать = ускорение ценой риска потери данных), synchronous_commit (можно ли отвечать клиенту до fsync — это осознанный размен durability на latency), commit_delay (искусственная задержка ради лучшего group commit). В etcd каждый Raft-лог fsync-ается перед подтверждением — иначе консенсус может «забыть» подтверждённое.
Облачные диски и fsync
В облаке (EBS, Persistent Disk, сетевые тома) «диск» — это на самом деле сетевое блочное хранилище. fsync там означает «данные дошли до удалённого хранилища и реплицированы по его правилам», и стоит он обычно дороже по латентности, чем локальный NVMe, потому что добавляется сеть. Зато такие тома обычно уже имеют durable-бэкенд (репликацию) — но всё равно: пока ты не сделал fsync, данные могут сидеть в page cache твоей виртуалки и сгореть при её крахе. А вот локальные эфемерные диски (instance store, local SSD) теряются при остановке/миграции инстанса целиком — для durable-данных они непригодны, сколько fsync ни делай.
Вывод для бэкендера: durability — это сквозная цепочка. Слабое звено в любом месте (нет fsync файла, нет fsync каталога, диск врёт, том эфемерный) рушит всю гарантию.
Где оказываются данные сразу после успешного возврата write() (без fsync)?
Краш процесса (kill -9) при наличии данных в page cache, но без fsync. Данные потеряны?
Что именно гарантирует возврат fsync(fd) с кодом 0?
Создали новый файл, записали, сделали fsync(file). Что ещё нужно, чтобы файл гарантированно нашёлся после краша?
Что такое group commit?
Почему БД боятся дисков с собственным кэшем?
Что спрашивают на собесе
- Почему write() вернулся успешно, но после ребута данных нет? Ответ: данные осели в page cache (грязные страницы) в RAM и не были сброшены на диск; нужен fsync/fdatasync.
- В чём разница между крахом процесса и крахом системы для page cache? При падении процесса page cache в ядре жив и данные дойдут до диска; при потере питания/ребуте RAM обнуляется и несброшенное теряется.
- Зачем делать fsync на каталог? Чтобы запись об имени нового файла (или rename) попала на диск; иначе после краша файл-inode есть, а имени нет.
- Почему fsync дорогой и что такое group commit? fsync ждёт физическое устройство; чтобы не платить за каждую транзакцию отдельно, несколько фиксаций объединяют в один общий fsync — растёт throughput.
- Как WAL обеспечивает durability и чем это похоже на journaling? БД делает fsync журнала перед ответом клиенту и проигрывает его после краша; ФС в journaling точно так же сначала фиксирует намерение в журнал.
- В чём подвох «диск со своим кэшем» и причём тут write barrier? Накопитель может рапортовать об успехе, держа данные в энергозависимом кэше; нужен flush/FUA-барьер и/или диск с защитой питания.