Файлы, диски и надёжная запись

Что стоит за open() и write(), где на самом деле лежат ваши данные после «успешной» записи, почему второе чтение в десять раз быстрее первого и чем NVMe отличается от HDD не только скоростью.

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

Дескриптор → открытый файл → inode

Файл, сокет, пайп, таймер — для процесса всё это число в таблице дескрипторов. Число ссылается на «открытый файл» в ядре: там хранятся текущее смещение и флаги. А тот — на inode, сам файл на диске.

dup() и fork() копируют только номер — смещение остаётся общим. Повторный open() создаёт новый открытый файл со своим смещением. Откройте файл, сделайте fork() и пишите из обоих процессов, затем откройте файл второй раз.

«Too many open files»

  • Число дескрипторов на процесс ограничено: ulimit -n. Мягкий лимит по умолчанию часто 1024, в systemd его задаёт LimitNOFILE. Каждое HTTP-соединение — тоже дескриптор.
  • Ошибка EMFILE обычно значит утечку, а не маленький лимит: незакрытый файл, тело HTTP-ответа, сокет. Поднять лимит — отсрочить падение.
  • Не надейтесь на сборщик мусора. CPython закрывает файл сразу при потере ссылки, но PyPy, Java, Go и Node — когда-нибудь. Закрывайте явно: with, try-with-resources, defer f.Close(), resp.Body.Close().

$ cat /proc/4242/limits | grep 'open files'
Max open files      1024      524288    files

$ ls /proc/4242/fd | wc -l       # сколько открыто сейчас
1021

$ ls -l /proc/4242/fd | tail -3
lr-x------ 1018 -> /var/data/report-17.csv
lr-x------ 1019 -> /var/data/report-18.csv
lrwx------ 1020 -> socket:[884213]

$ lsof -p 4242 | awk '{print $5}' | sort | uniq -c
    812 REG     # обычные файлы — подозрительно
    203 IPv4
      6 FIFO

Путь одной записи

write() возвращает управление, как только данные скопированы в память ядра — в page cache. На диск они попадут позже. А до write() они могут ещё лежать в буфере вашей библиотеки, внутри процесса.

Пишите записи и сбрасывайте их на разные уровни, потом «убейте» процесс или выключите питание. Смотрите, что уцелеет. kill -9 и отключение питания — очень разные аварии.

Грязные страницы и торможение записи

Изменённые страницы page cache называются грязными. Когда их больше dirty_background_ratio (10% памяти), ядро начинает фоновую запись. Когда приближаются к dirty_ratio (20%), ядро тормозит сам процесс: write() начинает ждать диск.

Отсюда «запись 2 ГБ/с первые секунды, потом 150 МБ/с» и внезапные паузы в несколько секунд на обычном write() в логгере. Сравните быстрый и медленный диск.

flush — не fsync

  • flush() в Python и Java переносит данные из буфера библиотеки в ядро, и только. От kill -9 это спасает, от отключения питания — нет.
  • fsync(fd) ждёт, пока данные и метаданные файла дойдут до носителя, включая сброс кэша самого диска. fdatasync пропускает несущественные метаданные вроде времени изменения — на одну запись меньше.
  • O_SYNC / O_DSYNC — то же, но на каждый write(). O_DIRECT обходит page cache, но не заменяет fsync.
  • fsync стоит от десятков микросекунд (NVMe с защитой от потери питания) до 10 мс (HDD). Поэтому его делают пачками.
в ядрона диск
Pythonf.flush()os.fsync(f.fileno())
Javaout.flush()channel.force(true)
Gobw.Flush()f.Sync()
Nodefs.write(…)fh.sync()
Cwrite(fd, …)fsync(fd)

В Go os.File не буферизует: каждый Write — системный вызов, буфер даёт bufio.Writer. close() нигде не делает fsync. А ошибку записи на диск ядро может сообщить только в fsync — его результат надо проверять.

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

Сервис на Python записал строку в файл и вызвал f.flush(), но не fsync. Через секунду OOM killer убил процесс сигналом SIGKILL. Сервер продолжил работать. Строка в файле?

После flush() данные принадлежат ядру, а не процессу. Смерть процесса им не грозит: грязные страницы запишутся фоном в течение ~30 секунд. Потерять их можно только вместе с ядром — при отключении питания или панике ядра. А вот без flush() строка осталась бы в буфере процесса и пропала.

Как безопасно заменить файл

Переписать файл на месте нельзя: упадёте посередине — останется пустой или обрезанный файл. Правильно: записать во временный файл рядом, сделать fsync, атомарно переименовать rename() поверх старого и сделать fsync каталога.

Выберите способ, идите по шагам и нажимайте «Сбой питания» в разные моменты. Схема показывает, что видят программы и что окажется на диске после перезагрузки.

Журнал защищает файловую систему, а не ваши данные

Создание файла меняет несколько мест: inode, битовую карту блоков, запись в каталоге. Журналируемая ФС сначала пишет описание изменений в журнал и только потом — на место. После сбоя она проигрывает журнал и остаётся целой — без многочасового fsck.

ext4 data=ordered

По умолчанию. В журнал идут только метаданные, но блоки данных пишутся раньше, чем фиксируются метаданные, которые на них ссылаются. В файле не окажется чужого мусора.

data=writeback

Порядок не гарантирован: после сбоя в конце файла могут оказаться старые блоки, принадлежавшие другому файлу. Быстрее, но опаснее.

data=journal

Данные тоже идут через журнал: всё пишется дважды. Редко нужно.

XFS, btrfs, ZFS

XFS журналирует метаданные. btrfs и ZFS не перезаписывают блоки на месте (copy-on-write). Но и им для надёжности нужен fsync.

Журнал ext4 фиксируется раз в 5 секунд (commit=5), грязные данные пишутся через ~30 секунд. Ни один режим не делает ваш write() надёжным или атомарным: целостность ФС — да, сохранность последних данных — только fsync.

Inode, имена и ссылки

Файл — это inode: размер, права, список блоков. Имени в нём нет. Каталог — таблица «имя → номер inode». Жёсткая ссылка — ещё одно имя того же inode. Символическая — отдельный маленький файл с путём внутри.

Место освобождается, когда у inode не осталось ни имён, ни открытых дескрипторов. Удалите файл, который держит процесс, и сравните df и du.

Page cache при чтении

Всё прочитанное с диска остаётся в свободной памяти. Второе чтение того же файла идёт из RAM. Когда памяти просят программы, ядро без труда отдаёт чистые страницы кэша.

Поэтому «занято 15 из 16 ГБ» в free — не повод для паники: смотрите на колонку available. Прочитайте файл дважды, потом файл больше, чем помещается в кэш.

Размер буфера и mmap

Каждый read() — системный вызов: переход в ядро и обратно плюс копирование из page cache в ваш буфер. Прочитать 1 ГБ по байту — миллиард вызовов, буфером в 64 КБ — шестнадцать тысяч.

FileInputStream.read() в Java и os.read(fd, 1) в Python читают по байту без буфера. mmap отображает page cache прямо в память процесса: копирования нет, но каждое первое обращение к странице — page fault, который блокирует поток. Подробнее — в презентации про виртуальную память.

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

На сервере с HDD, который пишет 200 МБ/с, вы запускаете:


$ dd if=/dev/zero of=test bs=1M count=1000
1048576000 bytes copied, 0.48 s, 2.2 GB/s

Откуда 2,2 ГБ/с?

Гигабайт поместился в грязные страницы, и write() вернулся, не дождавшись диска. Честный замер: dd … conv=fdatasync (fsync в конце) или oflag=direct (мимо кэша). А лучше — fio с --direct=1. Ту же ошибку делают бенчмарки «записи в файл» на любом языке.

HDD, SSD, NVMe: подряд и вразброс

HDD тратит ~8 мс на каждое случайное чтение: подвести головку и дождаться нужного сектора. Подряд он читает 200 МБ/с, вразброс по 4 КБ — меньше 1 МБ/с. У SSD механики нет, и разница в сотни раз исчезает.

SSD внутри параллелен — десятки кристаллов памяти. Один поток с одним запросом за раз (глубина очереди 1) загружает его на доли процента. Двигайте глубину очереди.

Внутри SSD: страницы, блоки, FTL

  • NAND пишется страницами (~16 КБ), а стирается только блоками из сотен страниц. Перезаписать страницу на месте нельзя.
  • Поэтому контроллер (FTL) пишет новую версию в свободную страницу и переставляет указатель. Старая становится мусором.
  • Сборщик мусора переносит живые страницы из блока и стирает его. Эти лишние записи — усиление записи (WAF): изнашивают диск и отнимают скорость.
  • TRIM сообщает диску, какие блоки ФС освободила, — иначе он бережно переносит удалённые данные.

Очередь к диску

  • Запросы проходят блочный слой ядра (blk-mq): очереди на каждое ядро CPU, затем аппаратные очереди устройства. У NVMe их до 64 тысяч.
  • Планировщик: none для NVMe — диску виднее; mq-deadline — для HDD и SATA; bfq — честное деление между процессами. Смотреть в /sys/block/*/queue/scheduler.
  • Много запросов сразу — потоки, io_uring, асинхронные драйверы БД — дают в разы больше IOPS. Подробнее — в презентации про асинхронный ввод-вывод.
  • %util — доля времени, когда в очереди был хоть один запрос. Для параллельного SSD 100% не значит «предел». Смотрите на r_await/w_await и aqu-sz.

$ iostat -x 1 nvme0n1
Device     r/s   w/s r_await w_await aqu-sz %util
nvme0n1  41250   812    0.21    0.05    8.7  99.6

# 41 тыс. чтений/с, задержка 0,21 мс,
# в среднем 8,7 запроса в очереди.
# %util ≈ 100, но NVMe выдержит в разы больше:
# он выполняет сотни запросов параллельно.

$ cat /sys/block/nvme0n1/queue/scheduler
[none] mq-deadline kyber bfq

Как это использует база данных

База не делает fsync файлов данных на каждый коммит. Она дописывает изменения в журнал (WAL) — последовательно, — и коммит ждёт fsync только журнала. Страницы данных пишутся позже, в фоне.

Group commit: пока идёт один fsync, новые коммиты копятся и уходят следующим одним fsync. Так HDD со 120 fsync в секунду держит тысячи коммитов. Подробнее — в презентации о PostgreSQL. Добавляйте клиентов и включайте group commit.

Логи и ротация

logrotate переименовывает app.log в app.log.1. Но процесс пишет в дескриптор, а не в имя, — и продолжает писать в переименованный файл, пока его не попросят открыть лог заново.

Три способа: переименовать и послать сигнал (SIGHUP, USR1) на переоткрытие; переименовать без сигнала — ошибка; copytruncate — скопировать и обрезать, для программ, которые переоткрывать не умеют. Запустите ротацию в каждом режиме.

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

Мониторинг: диск заполнен на 100%, сервис падает с ENOSPC. Вы удалили старый лог на 40 ГБ — du показывает, что место есть, а df — что по-прежнему 100%. Что происходит?

rm удалил только имя. Inode жив, пока на него есть открытый дескриптор. Найти: lsof +L1 — файлы с нулём ссылок. Освободить: перезапустить процесс или попросить его переоткрыть лог, а в крайнем случае обрезать через : > /proc/PID/fd/N. (Резерв для root и нехватка inode тоже бывают, но du с df тогда не расходятся.)

Как это увидеть у себя

Кто что открылlsof -p PIDls -l /proc/PID/fdlsof +L1 — удалённые, но открытые файлы.
Какие вызовы делает программаstrace -f -e trace=openat,write,fsync,rename -p PIDstrace -c — сводка: сколько read на самом деле.
Нагрузка на дискiostat -x 1IOPS, задержка, очередь. Кто именно пишет: iotop -o, pidstat -d 1.
Грязные страницыgrep -E 'Dirty|Writeback' /proc/meminfoПороги — sysctl vm.dirty_ratio vm.dirty_background_ratio.
Что в page cachefree -hfincore fileСмотрите available, а не free.
Куда делось местоdf -hdu -sh /var/*df -i — кончились inode (миллион мелких файлов).
Что умеет дискfio --name=r --rw=randread --bs=4k --iodepth=32 --direct=1 --size=2GСлучайное чтение с глубиной очереди 32.
Сколько fsync в секундуfio --name=w --rw=write --bs=4k --fsync=1 --size=256MГлавная цифра для баз данных и очередей.

Главное

  • Дескриптор → открытый файл со смещением → inode. Закрывайте файлы явно: утечка дескрипторов кончается EMFILE.
  • Успешный write() значит «в памяти ядра». flush() — «в ядре», надёжно — только после fsync и с проверкой его ошибки.
  • Заменяйте файлы так: временный файл → fsync → rename → fsync каталога.
  • Свободная память — это page cache. Бенчмарки без --direct или fsync измеряют RAM.
  • Случайный доступ на HDD в сотни раз медленнее последовательного. SSD любит параллельные запросы и TRIM.
  • Место освобождается, когда закрыт последний дескриптор. Ротация логов — с переоткрытием файла.

Данные записаны, когда вернулся fsync, а не write.