Справочник DBA: базы данных изнутри читается по порядку

Статья 3.5 · раздел 3 «WAL, буферы и надёжность записи» · 20 из 109

fsync в PostgreSQL: write() не значит «записано», PANIC при ошибке и AIO

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнере postgres:18; pg_test_fsync из образа; Docker 29.7.2, ядро хоста Linux 7.1.13, том контейнера на btrfs поверх NVMe Kingston SKC3000D2048G; опыт выполнен 27 октября 2026
содержание решать задачи SQL на собеседовании

write() возвращает управление за микросекунду, и данные при этом лежат только в оперативной памяти ядра: сбой питания их заберёт. Долговечность даёт fsync(), а он в тысячи раз дороже. На стенде из опыта 8 КБ без синхронизации записались за 1 мкс, с fdatasync за 2,8 и за 9,0 мс в двух прогонах подряд. Эту цену платит каждый COMMIT (WAL) и каждая контрольная точка. Ниже: где write() и fsync() разделяются в самом PostgreSQL, что сервер делает с ошибкой fsync, почему диску нельзя верить на слово и что в PostgreSQL 18 изменил асинхронный ввод-вывод. Страницы таблиц до записи лежат в shared_buffers, и путь, описанный здесь, начинается уже после них.

DBA / 01

write() кладёт в память, долговечность даёт flush

Путь записи от write() до флэш-памяти и где теряются данные при сбое питания Четыре слоя слева направо: процесс сервера, page cache ядра, кэш накопителя в режиме write back и флэш-память. Вызов write() доходит до page cache за 1 мкс. Вызов fdatasync проводит данные до накопителя за 2 782 мкс в первом прогоне и 9 033 мкс во втором. Под процессом и page cache данные пропадают при сбое ОС или питания. Под кэшем накопителя данные пропадают, если накопитель не выполнил команду сброса кэша. Во флэш-памяти данные пережили сбой. write() fdatasync flush процесс СУБД write() 1 мкс page cache ОС грязные страницы кэш диска write back флэш-память энергонезависима сбой ОС или питания данные потеряны без команды сброса данные потеряны пережили сбой 1 write() 8 КБ: 723 553 оп/с, 1 мкс 2 fdatasync 8 КБ: 359 оп/с (2 782 мкс), в повторе 111 оп/с (9 033 мкс) 3 /sys/block/nvme1n1/queue/write_cache: write back, fua: 1 4 16 174 write() контрольной точки и 4 fsync()
Между первым и вторым слоем проходит одна микросекунда, между вторым и четвёртым — миллисекунды. Всё, что не прошло дальше второго слоя, сбой питания не переживёт, поэтому 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_datasyncfdatasyncfsyncopen_sync
том btrfs, прогон 151135966200
том btrfs, прогон 2106111109110
overlay контейнера98111115115
/dev/shm (tmpfs)3 625 9072 782 3822 732 7513 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, и разрешённый системный вызов в окружении.

Чек-лист

  1. Выполни pg_test_fsync -f <файл на томе pg_wal> три раза в разное время и запиши разброс fdatasync в мкс.
  2. Прочитай /sys/block/<устройство>/queue/write_cache и выясни, защищён ли накопитель от потери питания; без этого подтверждению не верь.
  3. Убедись, что SHOW fsync — on, а SHOW data_sync_retry — off.
  4. Посмотри SHOW io_method и pg_aios во время сканирования большой таблицы; для io_uring проверь сборку и seccomp.
  5. После опыта удали контейнер вместе с томом: docker rm -f -v dba-fsync-durability-and-io-path.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru