Daily MaverickFRENCH LETTER: Pastafarians staring intently at the sunPunchOsun’s renewable energy policies earn Adeleke top awardוואלהאוקראינה: ארבעה נהרגו מתקיפת כטב"ם רוסי על אוטובוס בחרסוןThe Jerusalem PostHind Rajab Foundation files war crimes complaint in India against anonymous Israeli travelerInquirer EntertainmentSue Ramirez mourns mom Chit’s deathBollywood HungamaEXCLUSIVE: Namit Malhotra on taking Ramayana to ScreenX and 4DX, “Our collaboration with CJ 4DPLEX enables us to bring this iconic film in innovative formats that match its scope and vision”UOLIrã amplia pressão sobre países do Golfo em meio à crise no Estreito de OrmuzSky TG24Prezzo diesel, Marsiglia (FederPetroli): "Rischiamo gasolio a 3 euro al litro"Hong Kong Free PressHong Kong father jailed for 22 years for decade-long sexual abuse of daughterХабрДрайвер шагового двигателя с векторным управлениемDigital SpyNetflix strikes back against Tyra Banks after docuseries falloutGlobal NewsDNA technology helps identify New Brunswick woman decades after death
The Daily Newsstand · Free, Always
Wednesday, August 19, 2026

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

Translate

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

Полный список бесплатных уроков августа смотрите в дайджесте.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.