Persistence · 17 мин
Контент v0.12.0fsync и durability: почему теряются данные
write() возвращает успех раньше, чем данные на диске
Сервис принимает заказ, пишет его в файл, отвечает клиенту «200 OK, сохранено».
Через секунду в дата-центре моргает питание. После перезагрузки заказа в файле
нет. Код ошибок не проверял? Проверял: write вернул успех.
Дело в том, что write почти никогда не ждёт диска. Он копирует байты в
page cache — кэш файловых данных в оперативной памяти, о котором шла речь в
главе о памяти в проде, — и сразу возвращается. На
устройство данные попадут позже, когда ядро ОС решит их записать.
программа RAM (ядро) диск
write() ───────► page cache ── позже ──► носитель
◄────── (грязная страница)
успех сразуАналогия: вы не носите каждое письмо на почту, а кладёте в ящик у двери, и курьер забирает ящик раз в полчаса. Пока курьер не пришёл, письма дома. Сгорел дом — сгорели письма, хотя вы их «отправили».
Грязные страницы и окно потери
Страница page cache, изменённая в памяти, но ещё не записанная на диск,
называется грязной (dirty page). Записью грязных страниц на диск в Linux
занимаются фоновые потоки ядра ОС — это writeback. Когда именно, задают
параметры vm.*:
dirty_writeback_centisecs = 500— потоки writeback просыпаются раз в 5 секунд;dirty_expire_centisecs = 3000— страница, грязная дольше 30 секунд, будет записана при ближайшем пробуждении;dirty_background_ratio = 10иdirty_ratio = 20— если грязные страницы заняли 10% доступной памяти, writeback начинается сразу в фоне; если 20% — пишущие процессы сами останавливаются вwrite, пока часть не сбросится.
Значит, без явного требования данные могут пролежать в памяти около полуминуты. Это окно потери данных.
t0: write("заказ #777") → грязная страница в RAM
│
│ до ~30 секунд: данные только в памяти
│
t1: writeback → данные на диске
сбой питания между t0 и t1 → заказ потерян, хотя клиенту ответили «OK»Важно различать, что именно упало.
- Упал процесс (паника,
kill -9, OOM killer). Page cache принадлежит ядру, а не процессу. Ядро живо, и грязные страницы будут записаны как обычно. Ничего не потеряно. - Упала система (питание, kernel panic, аварийная перезагрузка виртуальной машины). Память обнуляется, грязные страницы пропадают.
Page cache защищает от падения программы, но не от падения машины. От второго
защищает fsync.
fsync: дождаться записи на носитель
fsync(fd) просит ядро записать на устройство все грязные данные и метаданные
этого файла и не возвращаться, пока устройство не подтвердит запись. Когда
fsync вернул 0, данные долговечны (durable): они переживут потерю
питания. Это буква D в ACID.
В Go это f.Sync(). Частая ошибка: считать, что os.WriteFile или
f.Close() что-то сбрасывают на диск. Не сбрасывают: Close только освобождает
дескриптор, WriteFile — это open + write + close.
Родственные способы:
| Способ | Что делает | Когда данные на носителе |
|---|---|---|
write |
копирует в page cache | когда решит ядро, до ~30 с |
fsync |
данные и все метаданные файла | после возврата |
fdatasync |
данные и только нужные для их чтения метаданные (размер), без mtime | после возврата, чуть дешевле |
O_SYNC при открытии |
каждый write работает как write + fsync |
после возврата каждого write |
O_DSYNC при открытии |
каждый write как write + fdatasync |
после возврата каждого write |
O_DIRECT при открытии |
данные идут мимо page cache | не гарантировано, см. ниже |
O_DIRECT часто путают с долговечностью. Этот флаг означает «не используй
page cache»: DMA идёт прямо из буфера программы в устройство. Базы данных с
собственным кэшем (InnoDB, многие движки) используют его, чтобы не хранить
одни и те же страницы дважды. Но после write с O_DIRECT данные могут лежать в
кэше самого диска, а метаданные (новый размер файла, выделенные блоки) — в
памяти ядра. Долговечность всё равно даёт только fsync (или O_DSYNC).
В Go fdatasync доступен как syscall.Fdatasync на Linux, а флаги открытия —
как os.O_SYNC и syscall.O_DSYNC.
Ловушка 1: каталог тоже нужно сбросить
Создали файл, записали, сделали fsync. Кажется, всё надёжно. Но имя файла
хранится не в файле, а в каталоге (см. файлы и каталоги),
и fsync файла этот каталог на диск не записывает.
fd = open("/data/new.txt", O_CREAT|O_WRONLY)
write(fd, ...)
fsync(fd) содержимое и inode new.txt — на диске
запись "new.txt → inode" в каталоге /data — возможно, ещё в памяти
сбой → после загрузки файла /data/new.txt нетЧтобы новое имя пережило сбой, нужно открыть каталог и сделать fsync на нём.
То же касается rename и удаления.
Отсюда главный рецепт безопасной записи файла целиком (конфиг, снапшот, файл
состояния): записать во временный файл, fsync его, атомарно переименовать
поверх старого, fsync каталог.
package main
import (
"fmt"
"log"
"os"
"path/filepath"
)
// WriteFileAtomic записывает data в path так, что после сбоя питания
// на диске окажется либо старая версия файла целиком, либо новая.
func WriteFileAtomic(path string, data []byte) error {
dir := filepath.Dir(path)
// 1. Временный файл в том же каталоге: rename работает только
// в пределах одной файловой системы.
tmp, err := os.CreateTemp(dir, ".tmp-"+filepath.Base(path)+"-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // уберёт мусор, если не дойдём до rename
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
// 2. Данные временного файла — на носитель.
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
// 3. Атомарно подменяем имя: path указывает либо на старый inode, либо на новый.
if err := os.Rename(tmp.Name(), path); err != nil {
return err
}
// 4. Запись каталога (новое имя → новый inode) — тоже на носитель.
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
func main() {
dir, err := os.MkdirTemp("", "atomic")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "config.json")
if err := WriteFileAtomic(path, []byte(`{"version": 2}`)); err != nil {
log.Fatal(err)
}
b, _ := os.ReadFile(path)
fmt.Println(string(b))
}{"version": 2}rename в пределах одной файловой системы атомарен: в любой момент, в том
числе после сбоя, имя указывает либо на старый inode, либо на новый. Без шага 2
после сбоя можно получить новый файл с пустым или частичным содержимым. Без
шага 4 — старую версию, хотя функция уже вернула успех.
Пропуск шага 2 — не теоретическая ошибка. В 2009 году, когда дистрибутивы
перешли на ext4, пользователи после аварийных перезагрузок стали массово
находить пустые конфиги. Программы делали «записать временный файл → rename»
без fsync. ext4 откладывает выделение блоков под данные (delayed allocation), и
переименование успевало попасть на диск раньше самих данных. В ext4 добавили
эвристику, которая в этом случае принудительно сбрасывает данные перед
rename (опция auto_da_alloc), но это поведение одной ФС в одном режиме.
XFS, btrfs и другие так делать не обязаны. Пишите fsync.
Ловушка 2: fsync может вернуть ошибку, и её нельзя «повторить»
fsync возвращает ошибку, если устройство не смогло записать данные (EIO)
или кончилось место (ENOSPC). Интуитивная реакция — повторить fsync. Так
делал и PostgreSQL до 2018 года, пока его разработчики не обнаружили, что в
Linux это не работает. Когда writeback не смог записать страницу, ядро
сообщает об ошибке в ближайший fsync, а саму страницу помечает чистой (или
выбрасывает). Повторный fsync возвращает успех: грязных страниц больше нет. А
данные на диск так и не попали.
Эту историю называют «fsyncgate». PostgreSQL с февраля 2019 года (релизы 11.2, 10.7
и бэкпорты в старые ветки) на ошибке fsync аварийно завершается (PANIC) и при старте восстанавливается по журналу, потому
что журналу можно доверять, а состоянию page cache — нет. Вывод для своего
кода: ошибка f.Sync() означает, что данные с момента прошлого успешного
Sync могли пропасть. Повторять бесполезно; нужно считать запись неудавшейся и
восстанавливать данные из источника, которому можно верить.
Ловушка 3: диски тоже кэшируют
Даже если ядро всё честно отправило на устройство, у устройства есть свой кэш записи: DRAM в контроллере SSD или буфер HDD. Диск может положить данные в кэш и ответить «записано», хотя во флеш или на пластину ещё ничего не попало. При потере питания кэш пропадёт.
Поэтому fsync на Linux после записи данных отправляет устройству команду
сброса кэша (FLUSH CACHE в SATA, Flush в NVMe) или пишет с флагом FUA
(force unit access, «записать мимо кэша»). Файловые системы называют такую
пару «записать, сбросить» барьером записи (write barrier): всё, что до
барьера, гарантированно на носителе раньше того, что после.
fsync → ядро пишет грязные страницы → диск: «принял» (в свой кэш)
→ ядро: FLUSH → диск пишет кэш на носитель → «готово» → fsync возвращаетсяЦена сброса зависит от железа:
- Серверный SSD с защитой от потери питания (PLP, конденсаторы, см.
SSD) может подтвердить FLUSH сразу: содержимое кэша он допишет и
без внешнего питания.
fsyncзанимает десятки–сотни микросекунд. - Потребительский SSD без PLP обязан реально записать данные во флеш — миллисекунды.
- HDD — миллисекунды, иногда десятки: нужно дождаться вращения.
- Диск или RAID-контроллер, который подтверждает FLUSH, ничего не сбросив
(такое бывает у дешёвых устройств и у неправильно настроенных контроллеров
с кэшем без батарейки), делает
fsyncбыстрым и бесполезным.
Отдельная деталь про macOS: там системный fsync не сбрасывает кэш диска, для
этого есть fcntl(F_FULLFSYNC). Go на macOS в f.Sync() использует именно
F_FULLFSYNC, поэтому на Mac он заметно медленнее, чем fsync в программах на
C.
Почему fsync дорогой и что такое group commit
Измерим. Программа пишет 200 коротких записей в лог тремя способами: без
Sync, с Sync после каждой записи и с одним Sync на каждые 20 записей.
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
)
func main() {
dir, err := os.MkdirTemp("", "wal")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
f, err := os.OpenFile(filepath.Join(dir, "wal.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
log.Fatal(err)
}
defer f.Close()
rec := []byte("order:777 status=paid\n")
const n = 200
start := time.Now()
for i := 0; i < n; i++ {
if _, err := f.Write(rec); err != nil {
log.Fatal(err)
}
}
noSync := time.Since(start)
start = time.Now()
for i := 0; i < n; i++ {
if _, err := f.Write(rec); err != nil {
log.Fatal(err)
}
if err := f.Sync(); err != nil {
log.Fatal(err)
}
}
withSync := time.Since(start)
start = time.Now()
for i := 0; i < n; i++ {
if _, err := f.Write(rec); err != nil {
log.Fatal(err)
}
if (i+1)%20 == 0 { // «групповой коммит»: один Sync на 20 записей
if err := f.Sync(); err != nil {
log.Fatal(err)
}
}
}
grouped := time.Since(start)
fmt.Printf("write без Sync: %v на запись\n", noSync/n)
fmt.Printf("write + Sync каждый: %v на запись\n", withSync/n)
fmt.Printf("Sync на 20 записей: %v на запись\n", grouped/n)
}На MacBook с M2 Pro (встроенный SSD, F_FULLFSYNC) вышло так:
write без Sync: 2.169µs на запись
write + Sync каждый: 2.677564ms на запись
Sync на 20 записей: 150.419µs на записьSync после каждой записи сделал её в тысячу раз дороже: больше 2,5 мс, то
есть потолок около 370 записей в секунду с одного потока. На сервере с
NVMe-диском с PLP цифры будут другими, но разрыв в порядки между «в память» и «на
носитель» останется.
Лекарство — групповой коммит (group commit): пока один fsync выполняется,
новые транзакции копятся, и следующий fsync фиксирует их все разом. Один поход
к диску оплачивает долговечность пачки. Задержка каждой транзакции может
немного вырасти (она ждёт, пока соберётся группа), зато пропускная способность
растёт в разы. В примере выше один Sync на 20 записей снизил среднюю цену
записи почти в 20 раз.
без группировки: T1 [fsync] → T2 [fsync] → T3 [fsync] 3 × 2,7 мс
с группировкой: T1 ┐
T2 ├─ [один fsync] → OK всем троим 1 × 2,7 мс
T3 ┘Как это делают базы данных: WAL
Базе данных было бы слишком дорого делать fsync всех изменённых страниц
таблиц и индексов при каждом коммите: они разбросаны по диску, и это было бы
множество случайных записей. Поэтому почти все базы используют WAL
(write-ahead log, журнал упреждающей записи): прежде чем менять основные данные,
описание изменения дописывается в конец последовательного журнала, и fsync
делается только для журнала.
COMMIT в PostgreSQL:
1. записи об изменениях дописаны в WAL (page cache)
2. fsync(WAL) ← здесь оплачивается долговечность
3. клиент получает «COMMIT»
4. изменённые страницы таблиц попадут на диск позже, при checkpoint
сбой после шага 2: при старте база проигрывает WAL и восстанавливает измененияПоследовательная запись журнала дешевле случайной даже на SSD, и вместо многих
fsync разных файлов получается один. После сбоя база читает журнал и
повторяет (replay) те изменения, которые были зафиксированы, но не успели
попасть в основные файлы. Логику восстановления вы реализуете в задаче
«WAL: восстановление по журналу». Та же идея лежит в
основе журналируемых файловых систем, о них следующая
глава.
Настройки, которые встречаются на практике:
- PostgreSQL
fsync = on— выключать можно только для данных, которые не жалко: при сбое база может стать повреждённой. - PostgreSQL
synchronous_commit = off— клиенту отвечают доfsyncжурнала. При сбое потеряются последние доли секунды транзакций, но база останется согласованной. Осознанный обмен долговечности на задержку. - PostgreSQL
commit_delay— подождать немного передfsync, чтобы в группу попало больше транзакций. - etcd делает
fsyncжурнала Raft перед подтверждением каждой записи. Его документация требует, чтобы 99-й перцентиль метрикиetcd_disk_wal_fsync_duration_secondsбыл меньше 10 мс; на медленном диске кластер Kubernetes начинает терять лидера.
Не все системы полагаются на fsync. Kafka по умолчанию не делает его на
каждое сообщение: долговечность обеспечивает репликация. Сообщение считается
записанным, когда его получили несколько брокеров (acks=all и
min.insync.replicas), и одновременный сбой питания у всех реплик считается
маловероятным. Это другой обмен: вместо ожидания диска — ожидание сети.
Облачные диски
Сетевой том в облаке (EBS, Persistent Disk) сам реплицирует данные внутри зоны,
и fsync на нём означает «данные дошли до хранилища и записаны по его
правилам». Задержка fsync складывается из сети и работы хранилища, обычно это
доли миллисекунды или единицы миллисекунд — дольше, чем у локального NVMe с
PLP. Но пока вы не сделали fsync, данные лежат в page cache вашей виртуальной
машины и пропадут при её аварийной перезагрузке, как на любом сервере.
Локальные диски инстанса (instance store, local SSD) быстрые, но живут
только пока живёт виртуальная машина на конкретном хосте: при остановке или
переезде машины их содержимое теряется. Сколько ни делай fsync, долговечное
хранилище из них не получится без репликации.
Итог: долговечность — это цепочка, и каждое звено может подвести: нет fsync
файла, нет fsync каталога, проигнорирована ошибка fsync, диск врёт про
сброс кэша, том эфемерный.
Где оказываются данные сразу после успешного возврата write() (без fsync)?
Краш процесса (kill -9) при наличии данных в page cache, но без fsync. Данные потеряны?
Что именно гарантирует возврат fsync(fd) с кодом 0?
Создали новый файл, записали, сделали fsync(file). Что ещё нужно, чтобы файл гарантированно нашёлся после краша?
Что такое group commit?
Почему БД боятся дисков с собственным кэшем?
Частые ошибки
- Считать, что
os.WriteFile,f.Close()или выход из программы сбрасывают данные на диск. - Делать
fsyncфайла и забыватьfsyncкаталога после создания илиrename. - Повторять
fsyncпосле ошибки и считать данные записанными. - Путать
O_DIRECTс долговечностью. - Мерить скорость базы на ноутбуке и переносить цифры
fsyncна сервер (и наоборот): цена зависит от диска и ОС. - Делать
fsyncна каждую мелкую запись без группировки.
Проверьте себя
- Почему после
kill -9данные из page cache не теряются, а после потери питания — теряются? - Сколько по умолчанию в Linux грязная страница может пролежать в памяти?
- Почему повторный
fsyncпосле ошибки ничего не гарантирует? - Сервис хранит состояние в
state.jsonи обновляет его черезos.WriteFile("state.json", data, 0644). Назовите три сценария сбоя питания, после которых файл окажется пустым, частично записанным или старым, и какой шагWriteFileAtomicисключает каждый.
Показать ответы
Ответы. (1) Page cache принадлежит ядру ОС, которое продолжает работать после смерти процесса; при потере питания память обнуляется. (2) Около 30 секунд (dirty_expire_centisecs = 3000) плюс до 5 секунд до пробуждения writeback. (3) После ошибки ядро может пометить страницы чистыми, и повторный вызов сбрасывать уже нечего. (4) Пустым — на диск попало усечение файла (O_TRUNC), а новые данные нет; частичным — успела записаться часть страниц; старым — до диска не дошло ничего, хотя сервис уже сообщил об успехе. Первые два исключает запись во временный файл с Sync перед rename (живой файл никогда не усекается и не пишется на месте), третий — Sync каталога после rename до сообщения об успехе.
Что спрашивают на собесе
- write вернул успех, а после перезагрузки данных нет. Почему? Данные были
в page cache и не успели попасть на диск; нужен
fsync/fdatasync. - Чем отличается падение процесса от падения системы? Page cache в ядре переживает падение процесса; потерю питания — нет.
- Зачем fsync на каталог? Чтобы на диск попала запись каталога с новым
именем (или результат
rename). - Как атомарно заменить файл? Временный файл в том же каталоге →
fsync→rename→fsyncкаталога. - fsync вернул ошибку. Повторить? Нет: в Linux страницы после ошибки writeback могут быть помечены чистыми, и повтор «успешен» без записи. Данные считать потерянными, восстанавливаться из журнала.
- Почему fsync дорогой и что такое group commit? Он ждёт устройство (вплоть
до сброса его кэша). Group commit объединяет много транзакций в один
fsync. - O_DIRECT даёт долговечность? Нет, только обход page cache. Нужен
fsyncилиO_DSYNC. - Что за проблема с кэшем диска? Диск может подтвердить запись, держа данные в кэше. Нужен FLUSH/FUA и диск с защитой от потери питания.
- Как WAL обеспечивает долговечность?
fsyncпоследовательного журнала перед ответом клиенту, replay после сбоя.