Привет, Хабр!

Заказали под базу быстрый 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)


  1. poige
    19.08.2026 08:21

    NVMe выдаёт 600 000 записей в секунду, а база коммитит 180

    В pg_test_fsync цифра ~650k — это именно write() в page cache. До NVMe там дело ещё не доходит. Реальный диск в этом тесте участвует только на fsync/fdatasync.

    Честно интересно — сколько ещё трэшака навалят от имени компании OTUS на хабре(?)…


  1. makurus
    19.08.2026 08:21

        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]
    

    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