GraphLMS

ОС
Начать

ОС · 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 каталога, диск врёт, том эфемерный) рушит всю гарантию.

Проверь себя· fsync и durability

Где оказываются данные сразу после успешного возврата 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-барьер и/или диск с защитой питания.
Как устроена файловая система (vsfs)Журналирование и согласованность при сбоях