Learning Platform
Глоссарий Troubleshooting
Урок 11.03 · 22 мин
Средний
fsyncDurabilityCrash SafetyPerformanceLinux

fsync и durability — когда write на самом деле записан на диск

Главный обман современного файлового I/O в том, что write() возвращает успех, когда на диске ещё ничего нет. Данные лежат в page cache в RAM, и kernel сбросит их на диск «когда-нибудь» — через 5-30 секунд или при memory pressure. Если в этот промежуток сервер потеряет питание, данные пропадут навсегда, хотя приложение видело успешный return.

Для большинства задач это нормально. Но для всего, где «потеря данных» = «реальные деньги/жизни», нужен явный fsync() — syscall, который заставляет kernel дождаться, пока диск физически примет байты. Это медленно. Это и есть фундаментальный trade-off durability vs performance, на котором стоят все базы данных мира.


Что значит «данные на диске»

Когда мы говорим «данные на диске», мы имеем в виду гарантию: если в эту секунду выдернут вилку, данные не пропадут. Без этой гарантии все остальные слова не имеют смысла.

«На диске» — это значит:

  • Не в stdio-буфере процесса (упал процесс — пропало).
  • Не в page cache kernel (упал сервер — пропало).
  • Не в buffer контроллера диска (упало питание без supercap — пропало).
  • На физическом носителе: NAND-ячейках SSD или магнитных доменах HDD.

Чтобы туда добраться — нужно несколько уровней принудительной синхронизации. Главный инструмент — fsync(fd).

Путь fsync -- проталкиваем данные через все уровни до физического носителя
App: write()Данные кладутся в stdio (если есть) и идут в kernel через syscall
stdio flushЕсли есть stdio-буфер (file.write в Python), нужен fflush или file.flush() чтобы вытолкнуть в kernel
Page cacheДанные в kernel RAM, помечены как dirty. write() уже вернул успех, но на диске пусто
fsync
Disk writefsync блокирует процесс, пока kernel не отправит дiritty страницы драйверу диска и не получит ack
Disk cache (DRAM)Встроенный кеш диска. На enterprise SSD защищён конденсатором, на consumer часто нет
FUA / cache flush
NAND / platterФизический носитель. Здесь и только здесь данные пережили бы power loss

fsync: правильный путь к durability

fsync(fd) делает следующее:

  1. Берёт все dirty-страницы файла из page cache.
  2. Отправляет их драйверу диска.
  3. Шлёт «cache flush» команду контроллеру (заставляет проверить, что disk DRAM cache сброшен на NAND).
  4. Возвращается, когда всё это завершилось.
  5. Также синхронизирует метаданные файла (размер, mtime).

Это медленно. Типичные цифры:

  • fsync на NVMe SSD: 50-500 мкс.
  • fsync на SATA SSD: 200-2000 мкс.
  • fsync на HDD: 5-30 мс.
  • fsync на сетевом FS (NFS): может быть десятки и сотни мс.

Если ваша программа делает миллион fsync на HDD — это часы. Поэтому базы данных группируют fsync (group commit): несколько транзакций ждут одного fsync, делят его стоимость.

# Сколько fsync делает Postgres за одну транзакцию INSERT:
sudo strace -p $(pgrep -f 'postgres.*walwriter') -e fsync,fdatasync 2>&1 | head -10
# Будут регулярные fsync на WAL-файлах

fdatasync: чуть быстрее, чем fsync

fdatasync(fd) похож на fsync, но НЕ синхронизирует метаданные, если изменения метаданных не критичны для чтения.

Что значит «не критичны»: если вы записали в существующий файл по уже выделенным блокам, метаданные (mtime) можно не сбрасывать — данные всё равно прочитаются корректно. Если же файл вырос (выделились новые блоки) — то размер файла в inode тоже должен быть сброшен, иначе мы потом прочитаем «короткий» файл.

// Записать и обеспечить durability только данных:
write(fd, buf, 4096);
fdatasync(fd);   // быстрее fsync, метаданные могут отстать

Разница в производительности обычно 10-30% — ext4 и xfs делают fdatasync почти как fsync, но избегают лишнего seek для inode-блока на HDD.

PostgreSQL по умолчанию использует fdatasync для WAL (быстрее, безопасно для append).


O_SYNC: каждый write — это write + sync

Альтернативный подход: открыть файл с флагом O_SYNC. Тогда каждый write() неявно делает fsync.

int fd = open("data.log", O_WRONLY | O_CREAT | O_SYNC, 0644);
write(fd, "transaction\n", 12);  // не вернётся, пока не на диске

Плюс: не забудешь. Минус: ужасная производительность, если делаешь много мелких write. Каждый short write становится синхронным.

O_DSYNC — то же, но с поведением fdatasync вместо fsync. Чуть быстрее.

В реальной жизни эти флаги используют редко — группированный fsync обычно эффективнее.

WARNING

fsync файла НЕ синхронизирует директорию, в которой этот файл лежит. Если вы создали новый файл — запись в директории (имя файла -> inode) тоже в page cache. Чтобы файл «существовал» после power loss, нужен fsync ещё и на ОТКРЫТОЙ ДИРЕКТОРИИ. Это критично для fsync-and-rename pattern (см. ниже).


Атомарная замена файла: fsync-and-rename

Классическая задача: «записать конфиг или snapshot БД атомарно». Если просто write в существующий файл — в момент крэша файл может содержать половину старых, половину новых данных. Это испорчено навсегда.

Правильный паттерн:

import os

def atomic_write(path, data):
    tmp = path + '.tmp'
    # 1. Открыть временный файл
    with open(tmp, 'wb') as f:
        f.write(data)
        f.flush()                    # вытолкнуть stdio в kernel
        os.fsync(f.fileno())          # дождаться записи на диск
    # 2. Атомарно переименовать
    os.rename(tmp, path)
    # 3. fsync на директорию, чтобы запись 'переименовано' тоже была durable
    dir_fd = os.open(os.path.dirname(path) or '.', os.O_RDONLY)
    try:
        os.fsync(dir_fd)
    finally:
        os.close(dir_fd)

Почему это работает:

  1. Пишем в .tmp файл, делаем fsync — содержимое гарантированно на диске.
  2. rename — это атомарная операция на POSIX файловых системах. Либо целевой файл — старый, либо новый. Промежуточного состояния нет.
  3. После rename делаем fsync на ДИРЕКТОРИЮ — иначе запись «переименовано» может зависнуть в page cache. После crash директория покажет старое имя.
fsync-and-rename: атомарная замена файла
1. write config.tmpЗаписали новое содержимое во временный файл. Старый config.json не тронут
2. fsync(tmp)Дождались, пока временный файл лёг на диск. Теперь даже при крэше его содержимое сохранится
3. rename(tmp, config)rename атомарен на POSIX FS. Либо config -- старый, либо новый. Состояние 'наполовину' невозможно
4. fsync(dir)Запись в директорию ('новый файл с этим именем') тоже dirty. Без fsync директории crash покажет старое имя

Этот паттерн используется буквально везде: Postgres для WAL-сегментов, git для object-файлов, SQLite для базы. Если вы пишете долгоживущий сервис, который должен переживать crash — учите этот паттерн наизусть.


Что не делает fsync (и страшные истории)

Не контролирует disk cache. На consumer SSD без конденсатора disk cache может потерять данные при power loss даже после успешного fsync. Современные kernel посылают FLUSH-команды контроллеру, но если drive врёт о её выполнении (а некоторые consumer SSD так делают) — ничего не поделаешь.

Не синхронизирует другие открытые копии. Если файл открыт двумя процессами, fsync одного из них не вытолкнет данные из stdio-буфера другого.

Не помогает при corruption. Если приложение записало мусор — fsync аккуратно сохранит мусор на диск.

Может ВЕРНУТЬ ОШИБКУ. На некоторых файловых системах исторически после ошибки fsync последующий fsync мог вернуть успех, хотя данные потерялись. Этот баг известен как «fsyncgate» (2018). После него Linux исправили, но проверять errno на fsync теперь обязательно.

if (fsync(fd) < 0) {
    // CRITICAL: данные потеряны, нельзя притворяться, что ничего не было
    log_error("fsync failed");
    abort();  // или другая стратегия
}
# Проверить, какая файловая система у /var:
findmnt /var
# Узнать ваш SSD и его кеш-настройки:
sudo hdparm -W /dev/sda
# write-caching = on/off для disk cache

Где fsync реально критичен

Базы данных. Commit транзакции = fsync WAL-файла. Без этого ACID-D (durability) не работает. Postgres: synchronous_commit=on (по умолчанию) — каждый commit делает fsync. Можно выключить (off), и тогда commit вернётся раньше fsync — быстрее, но при crash последние коммиты могут пропасть.

Очереди сообщений. Kafka, RabbitMQ — если они говорят клиенту «принято», они должны это записать durably. Иначе после краша сообщения теряются.

Filesystem-as-database (etcd, ZooKeeper). Каждое изменение state — fsync.

Конфигурационные файлы и snapshot-ы. Через fsync-and-rename.

Email-серверы, SCM (git). Когда вам говорят «коммит сохранён», это должна быть правда.

Где fsync не нужен:

  • Логи (если потеря последних 5 секунд логов терпима).
  • Временные файлы.
  • Кеш.
  • Метрики (если бэкап у Prometheus где-то ещё).

Бенчмарк: цена fsync

# Без fsync:
time python3 -c "
with open('/tmp/test.log', 'w') as f:
    for i in range(10000):
        f.write(f'line {i}\n')
"
# обычно <0.1 секунды

# С fsync на каждой строке:
time python3 -c "
import os
with open('/tmp/test_sync.log', 'w') as f:
    for i in range(10000):
        f.write(f'line {i}\n')
        f.flush()
        os.fsync(f.fileno())
"
# на NVMe: 5-15 секунд (200-500 fsync/sec)
# на HDD: МИНУТЫ (десятки fsync/sec)

Это и есть ответ на вопрос «почему производительная база делает 100,000 RPS, а с force-sync еле 1000 RPS». Group commit, WAL, fsync-batching — все эти трюки про то, как амортизировать стоимость fsync.

Иерархия памяти: почему NVMe fsync в 50–500 мкс, а HDD в 5–30 мс

Попробуй сам

# 1. Замерить fsync latency своего диска:
dd if=/dev/zero of=/tmp/test bs=4K count=1000 oflag=dsync 2>&1 | tail -3
# 'oflag=dsync' = O_DSYNC на каждый write. Покажет реальную скорость синхронной записи.

# 2. Сравнить с асинхронной:
dd if=/dev/zero of=/tmp/test bs=4K count=1000 2>&1 | tail -3

# 3. Посмотреть, сколько fsync делает приложение под нагрузкой:
sudo strace -c -e fsync,fdatasync -p $(pgrep -f 'your-app') -- sleep 30

# 4. Реализовать fsync-and-rename в Python:
cat > atomic.py << 'EOF'
import os, sys

def atomic_write(path, data):
    tmp = path + '.tmp'
    with open(tmp, 'wb') as f:
        f.write(data)
        f.flush()
        os.fsync(f.fileno())
    os.rename(tmp, path)
    dir_fd = os.open(os.path.dirname(path) or '.', os.O_RDONLY)
    try:
        os.fsync(dir_fd)
    finally:
        os.close(dir_fd)

atomic_write('/tmp/safe.txt', b'hello durable world\n')
print('saved')
EOF
python3 atomic.py

# 5. Тест на crash safety (только в виртуалке/контейнере!):
# В одном терминале: пишем 1000 раз без fsync
# В другом: kill -9 процесса
# Проверить, сколько строк успело упасть на диск

Проверка знанийKnowledge check
Junior спрашивает: 'Я записал данные через write() и получил успех -- зачем ещё какой-то fsync?'. Объясни, что физически произойдёт без fsync при power loss, и в каких 3-4 сценариях fsync критичен.
ОтветAnswer
write() возвращает успех, как только kernel скопировал данные в page cache -- это RAM, не диск. Kernel сбрасывает грязные (dirty) страницы на диск асинхронно через writeback thread, обычно каждые 5-30 секунд или при memory pressure. Между write() и реальной записью на физический носитель проходит окно потери. Если в этом окне случится power loss или kernel panic -- данные пропадут. Приложение успешно их записало (write вернул успех), но на диске их нет. После reboot файл будет либо в старом состоянии, либо обрезанным, либо с дырами. fsync(fd) меняет это: блокирует процесс, пока kernel не отправит грязные страницы файла драйверу диска и не получит ack, что они приняты. Также шлёт cache flush команду контроллеру SSD/HDD. Возвращается только когда данные физически в NAND/на пластинах. Где fsync критичен: 1) Базы данных. Commit транзакции = fsync WAL-файла. Без этого ACID-D (Durability) не работает -- 'мой счёт пополнен' окажется обманом после reboot. 2) Очереди сообщений (Kafka, RabbitMQ). Если producer получил 'accepted', сообщение должно быть durable. Иначе after crash потеря. 3) Filesystem-as-DB: etcd, ZooKeeper -- их consensus log должен переживать crash. 4) Атомарные конфиги, snapshot-ы, чекпоинты ML-моделей -- через fsync-and-rename. Цена: fsync медленный (50 мкс на NVMe, 5-15 мс на HDD). Поэтому в production base применяют group commit, batched fsync, дорогие NVMe с supercap. Где durability не критична (логи, метрики, кеш) -- fsync можно не делать, теряя последние секунды.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Что физически делает fsync(fd)?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4