---
title: "fsync и durability: почему теряются данные"
description: "Сервис принимает заказ, пишет его в файл, отвечает клиенту «200 OK, сохранено». Через секунду в дата-центре моргает питание. После перезагрузки заказа в файле нет. Код ошибок не…"
canonical: https://graphlms.ru/os/book/fsync-durability
course: "Операционные системы"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

# fsync и durability: почему теряются данные

> Источник: https://graphlms.ru/os/book/fsync-durability — курс «Операционные системы», GraphLMS.

## write() возвращает успех раньше, чем данные на диске

Сервис принимает заказ, пишет его в файл, отвечает клиенту «200 OK, сохранено».
Через секунду в дата-центре моргает питание. После перезагрузки заказа в файле
нет. Код ошибок не проверял? Проверял: `write` вернул успех.

Дело в том, что `write` почти никогда не ждёт диска. Он копирует байты в
**page cache** — кэш файловых данных в оперативной памяти, о котором шла речь в
главе [о памяти в проде](/os/book/memory-cloud), — и сразу возвращается. На
устройство данные попадут позже, когда ядро ОС решит их записать.

```text
   программа          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`, пока часть не сбросится.

Значит, без явного требования данные могут пролежать в памяти около полуминуты.
Это **окно потери данных**.

```text
   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`. Кажется, всё надёжно. Но имя файла
хранится не в файле, а в каталоге (см. [файлы и каталоги](/os/book/files-directories)),
и `fsync` файла этот каталог на диск не записывает.

```text
   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` каталог.

```go
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))
}
```

```text
{"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): всё, что до
барьера, гарантированно на носителе раньше того, что после.

```text
   fsync → ядро пишет грязные страницы → диск: «принял» (в свой кэш)
         → ядро: FLUSH → диск пишет кэш на носитель → «готово» → fsync возвращается
```

Цена сброса зависит от железа:

- **Серверный SSD с защитой от потери питания** (PLP, конденсаторы, см.
  [SSD](/os/book/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 записей.

```go
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`) вышло так:

```text
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 раз.

```text
   без группировки:  T1 [fsync] → T2 [fsync] → T3 [fsync]      3 × 2,7 мс
   с группировкой:   T1 ┐
                     T2 ├─ [один fsync] → OK всем троим        1 × 2,7 мс
                     T3 ┘
```

## Как это делают базы данных: WAL

Базе данных было бы слишком дорого делать `fsync` всех изменённых страниц
таблиц и индексов при каждом коммите: они разбросаны по диску, и это было бы
множество случайных записей. Поэтому почти все базы используют **WAL**
(write-ahead log, журнал упреждающей записи): прежде чем менять основные данные,
описание изменения дописывается в конец последовательного журнала, и `fsync`
делается только для журнала.

```text
   COMMIT в PostgreSQL:
   1. записи об изменениях дописаны в WAL (page cache)
   2. fsync(WAL)                          ← здесь оплачивается долговечность
   3. клиент получает «COMMIT»
   4. изменённые страницы таблиц попадут на диск позже, при checkpoint

   сбой после шага 2: при старте база проигрывает WAL и восстанавливает изменения
```

Последовательная запись журнала дешевле случайной даже на SSD, и вместо многих
`fsync` разных файлов получается один. После сбоя база читает журнал и
повторяет (replay) те изменения, которые были зафиксированы, но не успели
попасть в основные файлы. Логику восстановления вы реализуете в задаче
[«WAL: восстановление по журналу»](/os/tasks/wal-replay). Та же идея лежит в
основе [журналируемых файловых систем](/os/book/journaling), о них следующая
глава.

Настройки, которые встречаются на практике:

- 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`, диск врёт про
сброс кэша, том эфемерный.

> Квиз по разделу: см. вопросы в конце документа или на https://graphlms.ru/os/book/fsync-durability

## Частые ошибки

- Считать, что `os.WriteFile`, `f.Close()` или выход из программы сбрасывают данные на диск.
- Делать `fsync` файла и забывать `fsync` каталога после создания или `rename`.
- Повторять `fsync` после ошибки и считать данные записанными.
- Путать `O_DIRECT` с долговечностью.
- Мерить скорость базы на ноутбуке и переносить цифры `fsync` на сервер (и наоборот): цена зависит от диска и ОС.
- Делать `fsync` на каждую мелкую запись без группировки.

## Проверьте себя

1. Почему после `kill -9` данные из page cache не теряются, а после потери питания — теряются?
2. Сколько по умолчанию в Linux грязная страница может пролежать в памяти?
3. Почему повторный `fsync` после ошибки ничего не гарантирует?
4. Сервис хранит состояние в `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 после сбоя.

## Вопросы для самопроверки

**Квиз «fsync и durability»**

1. Где оказываются данные сразу после успешного возврата write() (без fsync)?
   - На пластине диска / в NAND, гарантированно
   - В swap на диске
   - В page cache (грязные страницы) в RAM (верно)
   - В регистрах CPU
   - Пояснение: write() копирует данные в page cache в RAM и сразу возвращается. На устройство они попадут позже при writeback или принудительно через fsync.
2. Краш процесса (kill -9) при наличии данных в page cache, но без fsync. Данные потеряны?
   - Зависит от размера файла
   - Нет: ядро живо, page cache переживает падение процесса и будет сброшен на диск (верно)
   - Да, всегда потеряны
   - Только если файл больше 4 КБ
   - Пояснение: Page cache живёт в ядре, а не в процессе. Падение процесса его не стирает. Потеря грозит только при крахе системы / потере питания, когда обнуляется RAM.
3. Что именно гарантирует возврат fsync(fd) с кодом 0?
   - Что данные зашифрованы
   - Что грязные страницы файла протолкнуты на устройство и оно подтвердило запись (верно)
   - Что файл закрыт
   - Что данные скопированы в page cache
   - Пояснение: fsync блокируется до тех пор, пока устройство не подтвердит запись грязных страниц файла. Это и есть достижение durability (при честном диске).
4. Создали новый файл, записали, сделали fsync(file). Что ещё нужно, чтобы файл гарантированно нашёлся после краша?
   - Сменить файловую систему
   - Ничего, fsync файла достаточно
   - fsync на родительский каталог, чтобы запись имени дошла на диск (верно)
   - Перезагрузить ОС
   - Пояснение: Имя файла хранится в каталоге — отдельной структуре. fsync файла не сбрасывает каталог; нужен fsync(dirfd), иначе после краша inode есть, а имени нет.
5. Что такое group commit?
   - Групповое удаление страниц из кэша
   - Запись группы файлов одной командой
   - Коммит в git сразу нескольких веток
   - Объединение нескольких транзакций в один общий fsync ради throughput (верно)
   - Пояснение: fsync дорогой, поэтому несколько транзакций накапливают и фиксируют одним fsync. Латентность одной может чуть вырасти, но общий throughput растёт кратно.
6. Почему БД боятся дисков с собственным кэшем?
   - При потере питания незащищённый кэш диска теряется, несмотря на успешный fsync (верно)
   - Без команды flush/FUA данные могут не дойти до стабильного носителя (верно)
   - Диск может рапортовать 'записано', держа данные в энергозависимом кэше (верно)
   - Page cache ОС становится бесполезным
   - Пояснение: Диск может врать об успехе, держа данные в своём DRAM-кэше. Нужен барьер записи (flush/FUA) и/или диск с защитой питания (конденсатор), иначе питание уносит кэш.
