write() возвращает управление за микросекунду, и данные при этом лежат только в оперативной памяти ядра: сбой питания их заберёт. Долговечность даёт fsync(), а он в тысячи раз дороже. На стенде из опыта 8 КБ без синхронизации записались за 1 мкс, с fdatasync за 2,8 и за 9,0 мс в двух прогонах подряд. Эту цену платит каждый COMMIT (WAL) и каждая контрольная точка. Ниже: где write() и fsync() разделяются в самом PostgreSQL, что сервер делает с ошибкой fsync, почему диску нельзя верить на слово и что в PostgreSQL 18 изменил асинхронный ввод-вывод. Страницы таблиц до записи лежат в shared_buffers, и путь, описанный здесь, начинается уже после них.
write() кладёт в память, долговечность даёт flush
COMMIT ждёт fdatasync, а не write().Стенд: на чём идёт опыт
docker run -d --name dba-fsync-durability-and-io-path -e POSTGRES_PASSWORD=pg postgres:18
docker exec dba-fsync-durability-and-io-path df -T / /var/lib/postgresql /dev/shm
cat /sys/block/nvme1n1/queue/write_cache /sys/block/nvme1n1/queue/fua
вывод:
Filesystem Type 1K-blocks Used Available Use% Mounted on
overlay overlay 521577472 451976488 17847064 97% /
/dev/nvme1n1p5 btrfs 521577472 451976488 17847064 97% /var/lib/postgresql
shm tmpfs 65536 1056 64480 2% /dev/shm
write back
1
Каталог данных лежит на томе Docker: это btrfs со сжатием zstd:1 на потребительском NVMe, кэш которого ядро считает write back. Корень контейнера — overlay на том же разделе, /dev/shm — память. Машина рабочая: диск заполнен на 97 %, рядом идут другие контейнеры. Поэтому числа ниже показывают порядок величин и разброс, но не скорость серверного диска: у другого накопителя, RAID-контроллера или сетевого тома они будут другими, и снимать их надо там.
write() и fsync(): где они разделяются
pg_test_fsync лежит в образе. Документация требует запускать его на файловой системе, где лежит pg_wal, поэтому имя файла задаётся флагом -f:
docker exec dba-fsync-durability-and-io-path sh -c \
'mkdir -p /var/lib/postgresql/t && /usr/lib/postgresql/18/bin/pg_test_fsync -s 3 -f /var/lib/postgresql/t/f.out'
Первый блок вывода (одна запись 8 КБ) и строка без синхронизации из конца вывода, прогон 1 на томе:
open_datasync 510.628 ops/sec 1958 usecs/op
fdatasync 359.518 ops/sec 2782 usecs/op
fsync 66.030 ops/sec 15145 usecs/op
fsync_writethrough n/a
open_sync 199.535 ops/sec 5012 usecs/op
Non-sync'ed 8kB writes:
write 723553.680 ops/sec 1 usecs/op
Та же запись 8 КБ без синхронизации занимает 1 мкс, с fdatasync — 2 782 мкс. Первая цифра описывает копирование в память ядра, а не запись на диск. Сводка по четырём прогонам (одна запись 8 КБ, -s 3), операций в секунду:
| Место | open_datasync | fdatasync | fsync | open_sync |
|---|---|---|---|---|
| том btrfs, прогон 1 | 511 | 359 | 66 | 200 |
| том btrfs, прогон 2 | 106 | 111 | 109 | 110 |
| overlay контейнера | 98 | 111 | 115 | 115 |
/dev/shm (tmpfs) | 3 625 907 | 2 782 382 | 2 732 751 | 3 616 144 |
Два прогона на одном томе подряд дали fdatasync 359 и 111 операций в секунду, то есть 2,8 и 9,0 мс; в статье про WAL тот же том в другой момент показал около 0,94 мс. Одного замера мало: диск и соседи по машине колеблются в разы. На tmpfs синхронизировать нечего, и fsync стоит доли микросекунды. Если pg_test_fsync на реальном сервере выдаёт такие же цифры, где-то в цепочке сброс ничего не ждёт.
Цена синхронизации задаёт потолок одной сессии: 1 / 0,0090 с даёт около 110 коммитов в секунду на втором прогоне против около 360 на первом. Групповой коммит поднимает общий потолок, но не задержку одного COMMIT. В этой сборке wal_sync_method по умолчанию fdatasync, а fsync_writethrough на Linux недоступен (n/a). Выбирать способ имеет смысл только по своему тому: на прогоне 1 open_datasync обогнал fdatasync, а на прогоне 2 все четыре способа стоили одинаково.
Внутри PostgreSQL то же разделение видно в pg_stat_io: контрольная точка пишет страницы, а долговечность даёт отдельный вызов.
create table big as select g as id, repeat('x',200) as pad from generate_series(1,1500000) g;
checkpoint;
select pg_stat_reset_shared('io');
update big set pad = pad where id <= 20000;
checkpoint;
select pg_stat_force_next_flush();
select backend_type, object, writes, write_bytes, fsyncs from pg_stat_io
where fsyncs>0 and object='relation';
вывод:
backend_type|object|writes|write_bytes|fsyncs
checkpointer|relation|16174|132497408|4
16 174 страницы по 8 КБ (126 МиБ) ушли в ядро вызовами write(), а долговечность им дали 4 вызова fsync(). Точные числа плавают от прогона к прогону: часть страниц успевают записать другие процессы. Страница может лежать в page cache, пока до неё не дойдёт синхронизация. Когда контрольная точка запускается и как растягивает запись, описано в статье про контрольные точки.
fsync=off и ошибка fsync
int rc;
if (!enableFsync)
return 0;
retry:
rc = fsync(fd);
if (rc == -1 && errno == EINTR)
goto retry;
return rc;
Это тело pg_fsync_no_writethrough (src/backend/storage/file/fd.c, REL_18_6, строки 443-454). При fsync = off функция возвращает 0: сервер быстр, но каждое «записано» означает «лежит в page cache». Повтор здесь возможен только при прерывании сигналом (EINTR); любая другая ошибка уходит наверх.
Что с ней делать? Примечания к релизу 11.2 описывают проблему так: некоторые популярные ОС отбрасывают буферы ядра, которые не смогли записать, и повторный fsync проходит успешно, хотя данные потеряны. Поэтому PostgreSQL перестал повторять fsync и стал паниковать (backport на ветки 9.4-10 описан в вики PostgreSQL, там же история «fsyncgate 2018»). Решение принимает одна функция:
* Failure to fsync any data file is cause for immediate panic, unless
* data_sync_retry is enabled. Data may have been written to the operating
* system and removed from our buffer pool already, and if we are running on
* an operating system that forgets dirty data on write-back failure, there
* may be only one copy of the data remaining: in the WAL. A later attempt to
* fsync again might falsely report success. Therefore we must not allow any
...
int
data_sync_elevel(int elevel)
{
return data_sync_retry ? elevel : PANIC;
}
Это data_sync_elevel из fd.c (REL_18_6, строки 3983-4004). Контрольная точка вызывает её в ProcessSyncRequests (sync.c, строка 444): ereport(data_sync_elevel(ERROR), ... "could not fsync file ..."). Единственная копия страницы может остаться в WAL: буфер уже освободился, а ядро могло забыть данные. Поэтому сервер падает и восстанавливается из журнала, а не работает дальше с неизвестным состоянием файла.
select name, setting, context from pg_settings
where name in ('fsync','wal_sync_method','data_sync_retry','io_method','io_workers','io_combine_limit') order by name;
вывод:
data_sync_retry|off|postmaster
fsync|on|sighup
io_combine_limit|16|user
io_method|worker|postmaster
io_workers|3|sighup
wal_sync_method|fdatasync|sighup
data_sync_retry = on документация разрешает только после проверки, как именно ОС обращается с буферизованными данными после неудачной записи. Вики PostgreSQL сообщает, что в ядре Linux 4.13 обработку таких ошибок улучшили, но документация PostgreSQL 18 по-прежнему оговаривает «некоторые ОС», и значение по умолчанию остаётся off. Воспроизвести ошибку fsync на этом стенде нельзя без прав на блочное устройство, которое возвращает EIO, поэтому PANIC показан кодом и документацией, а не выводом.
Накопители, которые подтверждают раньше времени
fsync доходит до драйвера и превращается в команду сброса кэша накопителя. Документация описывает цепочку: кэш ОС, кэш контроллера, кэш самого диска; write-back кэши теряют содержимое при потере питания, а многие SSD по умолчанию не исполняют команду сброса. На стенде ядро сообщает write_cache: write back и fua: 1: кэш есть, принудительная запись (FUA) поддерживается. Этого мало, чтобы утверждать, что именно этот SSD сохранил бы данные при обрыве питания: нужен настоящий обрыв, и документация предлагает проверять его утилитой diskchecker.pl.
Без обрыва питания остаётся три проверки: сравнить fdatasync из pg_test_fsync с тем, что физически может устройство (результат уровня /dev/shm значит либо «сброс ничего не ждёт», либо «кэш защищён питанием»: выясни, что именно); прочитать write_cache и спросить поставщика о защите от потери питания; снимать цифры на томе pg_wal и несколько раз, потому что разброс на этом стенде достиг трёх раз. Чисел для серверных дисков здесь нет намеренно: без измерения на нужной модели они были бы выдумкой.
Асинхронный ввод-вывод PostgreSQL 18
В 18 появилась подсистема AIO: бэкенд ставит в очередь несколько чтений сразу, и их исполняют отдельные процессы или io_uring. Это не новый способ записи на диск, поэтому всё выше остаётся в силе. Методы перечислены в aio.c (REL_18_6, строки 64-71), по умолчанию worker (DEFAULT_IO_METHOD в aio.h):
const struct config_enum_entry io_method_options[] = {
{"sync", IOMETHOD_SYNC, false},
{"worker", IOMETHOD_WORKER, false},
#ifdef IOMETHOD_IO_URING_ENABLED
{"io_uring", IOMETHOD_IO_URING, false},
#endif
{NULL, 0, false}
};
Метод io_uring в этом контейнере не запускается:
docker run --rm -e POSTGRES_PASSWORD=pg postgres:18 -c io_method=io_uring
вывод:
FATAL: could not setup io_uring queue: Operation not permitted
HINT: Check if io_uring is disabled via /proc/sys/kernel/io_uring_disabled.
На хосте /proc/sys/kernel/io_uring_disabled равен 0: системный вызов запрещает профиль seccomp Docker. С --security-opt seccomp=unconfined тот же образ стартует, и show io_method возвращает io_uring.
Укрупнение запросов видно в pg_stat_io. Склеивать соседние блоки в один readv до io_combine_limit PostgreSQL научился ещё в 17-й версии (потоковое чтение), а AIO в 18-й отправляет такие запросы заранее, не дожидаясь, пока бэкенд дойдёт до блока. Одно и то же сканирование big (350 МБ) при io_combine_limit = 16 и после set io_combine_limit = 1:
select pg_stat_reset_shared('io');
set max_parallel_workers_per_gather = 0;
select count(*) from big;
select pg_stat_force_next_flush();
select context, reads, read_bytes, round(read_bytes::numeric/nullif(reads,0)) as avg_read_bytes, hits
from pg_stat_io where reads>0 and backend_type='client backend';
вывод (строки bulkread: сначала при 16, затем при 1 блоке):
context|reads|read_bytes|avg_read_bytes|hits
bulkread|1805|234373120|129847|16138
context|reads|read_bytes|avg_read_bytes|hits
bulkread|28574|234078208|8192|16174
Прочитано примерно 223 МиБ из 350 МБ таблицы, остальное нашлось в shared_buffers (16 138 и 16 174 попаданий). Запросов в первом случае 1805 по 129 847 байт (почти 16 страниц), во втором 28 574 по 8 КБ: почти в 16 раз больше. Во время сканирования pg_aios показывает запросы в полёте:
select state, operation, length, f_buffered, target_desc from pg_aios order by io_id limit 3;
вывод:
COMPLETED_SHARED|readv|131072|t|blocks 16665..16680 in file "base/5/16393"
COMPLETED_SHARED|readv|131072|t|blocks 16633..16648 in file "base/5/16393"
COMPLETED_SHARED|readv|131072|t|blocks 16649..16664 in file "base/5/16393"
Один readv на 131 072 байта — 16 блоков подряд. f_buffered = t: чтение идёт через page cache, как раньше. В полученных выборках были только readv; запись страниц, как показал pg_stat_io выше, шла прежним путём через write() и fsync(). Сравнения worker и sync по времени здесь нет: таблица лежит в page cache хоста, и такой тест измерил бы память, а не накопитель.
Что из этого следует
- Закрытый
COMMITзначит, чтоfdatasyncжурнала вернул успех. Всё до него (write(), страница вshared_buffers) сбой питания не переживает. fsync = offгодится только для одноразовой загрузки, которую можно повторить с нуля:pg_fsync_no_writethroughпросто возвращает 0.data_sync_retry = offи PANIC при ошибкеfsyncоставь по умолчанию: после такого сбоя восстановление из WAL — единственный способ узнать, что страницы целы.- AIO в 18 касается только чтения: запросы по
io_combine_limitблоков (16 вместо одного в опыте) уходят заранее, а запись иfsyncидут прежним путём. Дляio_uringнужны и сборка с liburing, и разрешённый системный вызов в окружении.
Чек-лист
- Выполни
pg_test_fsync -f <файл на томе pg_wal>три раза в разное время и запиши разбросfdatasyncв мкс. - Прочитай
/sys/block/<устройство>/queue/write_cacheи выясни, защищён ли накопитель от потери питания; без этого подтверждению не верь. - Убедись, что
SHOW fsync—on, аSHOW data_sync_retry—off. - Посмотри
SHOW io_methodиpg_aiosво время сканирования большой таблицы; дляio_uringпроверь сборку и seccomp. - После опыта удали контейнер вместе с томом:
docker rm -f -v dba-fsync-durability-and-io-path.