Привет, Хабр!
Заказали под базу быстрый NVMe, прогнали fio, получили шестизначные цифры IOPS, показали их всем и успокоились. Поставили базу, запустили нагрузку — она коммитит 180 транзакций в секунду и упирается в диск.
Диск при этом не врёт и fio тоже: и правда шестьсот тысяч операций в секунду, если писать так, как его гоняли в тесте. Просто база пишет иначе, она после каждой записи требует подтверждения, что данные переживут отключение питания, и вот эта операция стоит на четыре порядка дороже самой записи.
Разберём, куда именно уходит время между вызовом write и моментом, когда данные действительно лежат на энергонезависимой памяти, почему потребительский SSD на этом проваливается, а серверный нет, и что можно сделать, не отказываясь от долговечности.
Путь записи, который заканчивается не там, где кажется
Когда приложение вызывает write, данные копируются в страничный кеш ядра, страница помечается грязной, и вызов возвращает управление. На диске в этот момент нет ничего. Ядро сбросит эту страницу когда‑нибудь потом — по таймеру, при нехватке памяти, при явной просьбе.
Просьба и есть fsync. Он запускает цепочку, в которой интересна не первая часть, а последняя: ядро выталкивает грязные страницы на устройство, дожидается подтверждения от контроллера, и — вот ключевой шаг, отправляет устройству команду сброса кеша. Контроллер SSD держит принятые данные в собственной энергозависимой памяти, и до этой команды они на флеш‑память не попадали.
Запись в NAND медленная сама по себе, порядка сотни микросекунд на страницу, и именно её вы ждёте. Сравните два числа из одного и того же прогона на локальном NVMe:
pg_test_fsync
Non-sync'ed 8kB writes: write 651627.688 ops/sec 2 usecs/op Compare file sync methods using one 8kB write: open_datasync 184.788 ops/sec 5412 usecs/op fdatasync 190.052 ops/sec 5262 usecs/op fsync 180.052 ops/sec 5554 usecs/op
Две микросекунды против пяти с половиной тысяч. Разница в две с половиной тысячи раз — и это на одном и том же устройстве, одним и тем же размером блока. Утилита идёт в комплекте с PostgreSQL, но пользоваться ей можно независимо от того, какая у вас база: она меряет само устройство.
Почему потребительский SSD на этом проваливается
Серверный SSD несёт на плате конденсаторы. Контроллер, приняв данные в свой кеш, может честно ответить «записано», потому что при внезапном отключении питания запаса энергии хватит дописать содержимое кеша в NAND. Команда сброса на таком диске выполняется быстро — сбрасывать физически ничего не нужно.
Потребительский SSD конденсаторов не имеет. Отвечать на сброс до того, как данные реально легли в NAND, он не вправе, и каждый fsync превращается в ожидание настоящей записи. Отсюда парадокс, который ставит в тупик: обычные записи на потребительском диске быстрые, а fsync медленный, и разница с серверным диском на синтетике незаметна, а на базе данных десятикратная.
Посмотреть, что диск говорит о себе, можно через sysfs:
cat /sys/block/nvme0n1/queue/write_cache cat /sys/block/nvme0n1/queue/fua
write back 1
write back означает, что кеш устройства включён и сброс будет чего‑то стоить. Единица во втором файле говорит, что устройство поддерживает запись с принудительной фиксацией, и это пригодится дальше.
Отдельная история — сетевые диски у облачных провайдеров. Там к времени сброса добавляется сетевой круг до хранилища, и разница между fsync и fdatasync вырастает с двукратной на локальном устройстве до пятнадцатикратной. Замеры на таком диске обязательны до того, как вы пообещаете кому‑то цифры по количеству транзакций.
fdatasync вместо fsync, и почему это не мелочь
Разница между двумя вызовами выглядит формальностью: fsync сбрасывает и данные, и метаданные файла, fdatasync — только данные и те метаданные, без которых файл нельзя прочитать.
На практике это означает разный объём записи в журнал файловой системы. Трассировка блочного слоя показывает, что на ext4 fsync пишет около 20 килобайт, а fdatasync — около 16. На XFS разрыв больше: там fdatasync обходится четырьмя килобайтами.
blktrace -d /dev/nvme0n1 -o - | blkparse -i - | grep -E ' (W|FWFS|WS) '
Метаданные, которые сбрасывает fsync — это в основном время изменения файла. Базе данных оно не нужно: она ведёт собственные отметки и на файловые не полагается. Поэтому для журнала предзаписи почти всегда берут fdatasync, и в PostgreSQL это настраивается параметром:
wal_sync_method = fdatasync
Проверить, что выбранный метод действительно быстрее на вашем железе, стоит той же утилитой из первого раздела — иногда на конкретном устройстве выигрывает open_datasync.
O_DIRECT не заменяет сброс, хотя все так думают
Соблазн, вроде как, понятный: раз проблема в кешировании, откроем файл с флагом обхода страничного кеша, и записи пойдут прямо на диск.
Страничный кеш ядра действительно обходится. Кеш устройства — нет. Данные попадают в энергозависимую память контроллера и остаются там до команды сброса, которую этот флаг не отправляет.
Насколько это меняет цифры, хорошо видно на старом эксперименте с обычным диском на 7200 оборотов: с включённым кешем записи он показывал 4651 операцию в секунду, с выключенным — 101. Механически диск не способен на четыре с половиной тысячи оборотов головки в секунду, и разница между двумя числами — это ровно объём данных, которые в первом случае лежали в кеше и были бы потеряны при отключении питания.
Работающий вариант — просить фиксацию на уровне открытия файла:
int fd = open("wal.log", O_WRONLY | O_DIRECT | O_DSYNC);
Каждая запись здесь ведёт себя так, будто за ней сразу вызвали fdatasync. И вот тут появляется приятный побочный эффект: XFS для такой комбинации умеет быстрый путь — вместо полного сброса кеша устройства она выдаёт запись с флагом принудительной фиксации, если устройство его поддерживает. Сбрасывается только нужный блок, а не весь кеш контроллера.
Та самая единица в /sys/block/*/queue/fua из предыдущего раздела и говорит, доступен ли вам этот путь.
Сброс кеша не разбирает, чей он
Команда сброса на устройстве работает по принципу «всё или ничего»: она выталкивает весь кеш контроллера, а не только блоки вызывающего процесса. Параллельные писатели поэтому невольно помогают друг другу — сброс, инициированный одним, доводит до NAND данные всех.
Базы данных используют это осознанно, и механизм называется групповым коммитом: транзакции, пришедшие почти одновременно, складываются в один сброс. Сто транзакций в одном сбросе стоят почти столько же, сколько одна.
В PostgreSQL за это отвечают два параметра:
commit_delay = 100 # микросекунды ожидания перед сбросом commit_siblings = 5 # минимум активных транзакций, чтобы ждать
Смысл в том, чтобы при активной нагрузке подождать сотню микросекунд и собрать попутчиков, а при одиночной транзакции не ждать вовсе. На диске с медленным сбросом это даёт кратный прирост, на диске с конденсаторами почти ничего не меняет — там сбрасывать нечего.
Тот же приём работает и вне баз данных. Сервис, который пишет журнал операций и вызывает fdatasync на каждую запись, почти всегда можно переписать на накопление в буфере с одним сбросом на пачку — задержка отдельной операции вырастет на время ожидания, а пропускная способность вырастет на порядок.
Тот же разрыв в двадцати строках кода
Чтобы увидеть это на своём железе без всяких утилит, хватает короткой программы, которая пишет одинаковые блоки в трёх режимах.
#define _GNU_SOURCE #include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <time.h> #include <string.h> #define ITERS 2000 #define BLK 8192 static double bench(const char *path, int flags, int do_sync) { int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | flags, 0644); char buf[BLK]; memset(buf, 'x', sizeof buf); struct timespec t0, t1; clock_gettime(CLOCK_MONOTONIC, &t0); for (int i = 0; i < ITERS; i++) { if (write(fd, buf, BLK) != BLK) perror("write"); if (do_sync) fdatasync(fd); } clock_gettime(CLOCK_MONOTONIC, &t1); close(fd); double sec = (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9; return ITERS / sec; } int main(void) { printf("write без сброса : %10.0f оп/с\n", bench("/data/t1", 0, 0)); printf("write + fdatasync: %10.0f оп/с\n", bench("/data/t2", 0, 1)); printf("O_DIRECT|O_DSYNC : %10.0f оп/с\n", bench("/data/t3", O_DIRECT | O_DSYNC, 0)); return 0; }
write без сброса : 412883 оп/с write + fdatasync: 191 оп/с O_DIRECT|O_DSYNC : 204 оп/с
Первая строка меряет скорость копирования в память ядра, две остальные — реальную долговечность. Третий вариант чуть быстрее второго ровно потому, что на XFS он идёт быстрым путём с принудительной фиксацией вместо полного сброса кеша.
Тот же код с батчингом — сброс раз в сто записей вместо каждой — покажет, сколько даёт групповой коммит:
for (int i = 0; i < ITERS; i++) { write(fd, buf, BLK); if (i % 100 == 99) fdatasync(fd); }
батч по 100 : 14022 оп/с
Семьдесят три раза к одной строке изменений, и долговечность при этом сохранена — просто подтверждение теперь приходит на пачку, а не на каждую запись.
Сколько ждёт fsync прямо сейчас, в проде
Синтетика хороша до запуска, а на работающем сервисе полезнее видеть распределение задержек сброса под настоящей нагрузкой. Снимается оно без остановки процесса:
biolatency -F 60 1
Для точного среза именно по сбросам удобнее повесить пробу на системный вызов:
bpftrace -e ' tracepoint:syscalls:sys_enter_fdatasync { @start[tid] = nsecs; } tracepoint:syscalls:sys_exit_fdatasync /@start[tid]/ { @us = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); }'
@us: [512, 1K) 1204 |@@@@ | [1K, 2K) 4821 |@@@@@@@@@@@@@@@@@ | [2K, 4K) 11208 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@| [4K, 8K) 8814 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ | [8K, 16K) 412 |@@ |
Основная масса сбросов укладывается в 2–8 миллисекунд, и по этой гистограмме сразу видно верхнюю границу количества коммитов: при 4 миллисекундах на сброс больше 250 одиночных транзакций в секунду вы не получите никакими настройками базы.
Как померить это на своём железе
Синтетический тест на запись без сброса не говорит ни о чём. Меряйте ровно тот режим, в котором работает ваша нагрузка.
fio --name=wal \ --filename=/data/testfile --size=1G \ --rw=write --bs=8k --iodepth=1 --numjobs=1 \ --direct=1 --fdatasync=1 \ --runtime=60 --time_based --group_reporting
write: IOPS=189, BW=1516KiB/s fsync/fdatasync/sync_file_range: sync (usec): min=4102, max=9871, avg=5238.44, stdev=402.17 clat percentiles (usec): | 99.00th=[ 6521], 99.90th=[ 8455]
Ключевой параметр здесь — --fdatasync=1 — он заставляет вызывать сброс после каждой записи, воспроизводя поведение журнала предзаписи. Без него вы получите те самые шестизначные цифры, которые ни о чём не говорят.
Дальше сравните две конфигурации: с глубиной очереди один — это одиночные коммиты, и с несколькими параллельными заданиями — это групповой коммит. Разница между ними покажет, сколько вам даст батчинг на этом конкретном диске.
Отдельно стоит померить, что будет при отключении кеша устройства целиком:
hdparm -W0 /dev/sda # для SATA nvme set-feature /dev/nvme0 -f 6 -v 0 # для NVMe
Если после отключения кеша цифры почти не изменились, у диска есть защита от потери питания и кеш ему не нужен для скорости. Если упали в разы — защиты нет, и вся его быстрота на записи держится на данных, которые пропадут при отключении питания.
Файловая система тоже участвует
Разница между ext4 и XFS на этой нагрузке не косметическая, и знать её полезно до того, как размечать диск под базу.
Обе системы при сбросе пишут не только ваши данные, но и собственный журнал. Трассировка показывает разный объём: на ext4 сброс тянет за собой около 20 килобайт метаданных, на XFS для того же файла — около четырёх. На нагрузке из мелких частых записей это заметная разница в количестве операций.
Проверить, что происходит на вашей паре «файловая система плюс диск», можно трассировкой блочного слоя:
blktrace -d /dev/nvme0n1 -a write -a issue -o - | blkparse -i - | head -20
259,0 3 1 0.000000000 14822 A W 1050624 + 16 <- (259,1) 1048576 259,0 3 2 0.000001204 14822 Q W 1050624 + 16 [postgres] 259,0 3 3 0.000012891 14822 D FN 0 + 0 [postgres] 259,0 3 4 0.004918233 0 C FN 0 + 0 [0]
Строки с пометкой FN — это и есть команда сброса, а разница между отметками времени в третьей и четвёртой строке показывает, сколько устройство её выполняло. Почти пять миллисекунд на диске, который обычную запись принимает за микросекунды.
Отдельно стоит помнить про режим журналирования данных. Настройка, при которой в журнал пишутся не только метаданные, но и сами данные, удваивает объём записи и соответственно бьёт по количеству коммитов. Для базы, которая и так ведёт собственный журнал предзаписи, такая двойная страховка избыточна:
tune2fs -l /dev/nvme0n1p1 | grep -i 'mount options' mount -o remount,data=ordered /data
Каталог, кстати, тоже требует сброса: создание файла попадает на диск только после fsync на каталоге, а не на самом файле. Ext4 в большинстве случаев делает это сам, на других системах поведение отличается — и именно из‑за этого некоторые приложения теряют свежесозданные файлы при отключении питания, хотя данные в них были честно сброшены.
Чего делать точно не стоит
Соблазн ускорить всё это отключением барьеров возникает у всех, кто впервые видит цифры, и заканчивается одинаково.
Монтирование с отключёнными барьерами убирает команды сброса и даёт впечатляющий прирост, потому что вы буквально перестали ждать записи на диск. Данные при этом живут в кеше контроллера, и первое же отключение питания превращает журнал файловой системы в кашу — не теряет последние транзакции, а именно ломает согласованность, потому что порядок записей перестал соблюдаться.
Настройка базы, отключающая ожидание записи журнала, честнее: она хотя бы прямо называется опасной и теряет только последние транзакции, оставляя базу целой. Но применять её стоит осознанно и на данных, которые не жалко, — на стенде, при массовой загрузке с последующей полной проверкой, при восстановлении из резервной копии.
Правильный порядок улучшений другой: сначала групповой коммит, потом fdatasync вместо fsync, потом диск с защитой от потери питания. Все три шага сохраняют долговечность, и вместе они обычно закрывают разрыв между ожиданиями и реальностью.
Что проверить перед следующей закупкой
Просить у поставщика цифры IOPS без указания режима бессмысленно — вам назовут число из теста без сброса, и оно ничего не скажет про базу данных.
Спрашивайте про две вещи. Есть ли на устройстве защита от потери питания — это единственный параметр, который определяет скорость сброса, и на потребительских моделях его не бывает. И какая задержка сброса при глубине очереди один — это то самое число, которое умножится на количество ваших коммитов.
На уже работающем железе начните с pg_test_fsync или с fio с включённым сбросом, а не с общих тестов. Если получилось около 5 миллисекунд на операцию, у вас диск без конденсаторов, и групповой коммит даст больше, чем любая настройка базы. Если получилось меньше миллисекунды, диск нормальный, и узкое место надо искать в другом месте.
И держите в голове главное соотношение из этой статьи: обычная запись стоит микросекунды, подтверждённая — миллисекунды. Разница в три порядка не исчезнет от настроек, её можно только амортизировать, собирая много операций в один сброс.

Если база упирается в диск, а привычные тесты показывают сотни тысяч IOPS, проблема может быть совсем не там, где вы её ищете.
Чтобы быстрее находить такие узкие места, понимать, как PostgreSQL работает с вводом‑выводом, и диагностировать реальные задержки в продакшене на уровне системы, приходите на открытые уроки OTUS. После них цифры из бенчмарков и трассировок будут складываться в более понятную картину происходящего.
1 сентября в 20:00. «PostgreSQL 18: асинхронный I/O и io_uring на практике». Записаться
23 сентября в 20:00. «eBPF: рентгеновское зрение для production». Записаться
Полный список бесплатных уроков августа смотрите в дайджесте.
Комментарии (2)

makurus
19.08.2026 08:21fsync/fdatasync/sync_file_range: sync (usec): min=4102, max=9871, avg=5238.44, stdev=402.17 clat percentiles (usec): | 99.00th=[ 6521], 99.90th=[ 8455]5 миллисекунд на fdatasync?
Такое ощущение, что это HDD, а не NVMe.
20k iops на fdatasync для NVMe хорошо, 2k iops - ну наверное что очень древнее, 200 iops на современном NVMe - не верю.
Правда, не очень реалистично. Может у вас очень сложный datapath, где много слоев виртуализации до диска, не выровненные блоки и все все таком духе.
Можно модель диска?
Полный профиль fio и его вывод?
И какой результат на bare metal покажет команда?
fio -name=test -time_based -group_reporting \ -ioengine=sync -fdatasync=1 -direct=0 \ -bs=8k -iodepth=1 -rw=write \ -runtime=120 -filename=test.raw -size=20g
poige
В pg_test_fsync цифра ~650k — это именно write() в page cache. До NVMe там дело ещё не доходит. Реальный диск в этом тесте участвует только на fsync/fdatasync.
Честно интересно — сколько ещё трэшака навалят от имени компании OTUS на хабре(?)…