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: правильный путь к durability
fsync(fd) делает следующее:
- Берёт все dirty-страницы файла из page cache.
- Отправляет их драйверу диска.
- Шлёт «cache flush» команду контроллеру (заставляет проверить, что disk DRAM cache сброшен на NAND).
- Возвращается, когда всё это завершилось.
- Также синхронизирует метаданные файла (размер, 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 обычно эффективнее.
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)
Почему это работает:
- Пишем в
.tmpфайл, делаем fsync — содержимое гарантированно на диске. rename— это атомарная операция на POSIX файловых системах. Либо целевой файл — старый, либо новый. Промежуточного состояния нет.- После rename делаем fsync на ДИРЕКТОРИЮ — иначе запись «переименовано» может зависнуть в page cache. После 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 процесса
# Проверить, сколько строк успело упасть на диск