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

Статья 13.3 · раздел 13 «Инфраструктура и облако» · 97 из 109

Диски под PostgreSQL: IOPS не равны скорости коммита — fdatasync 889 мкс на NVMe и 73 мс под полкой 100 IOPS

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнере postgres:18; fio 3.39 из пакета Debian, поставлен внутри контейнера; Docker 29.7.2, ядро хоста Linux 7.1.13, том контейнера на btrfs (compress=zstd:1) поверх NVMe; файлы fio до 512 МБ; машина рабочая, диск занят на 97 %, на ней идут другие контейнеры; сетевые диски, RAID, xfs, ext4 и ZFS не измерялись; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Диск под базу выбирают по IOPS, а коммит ждёт не IOPS, а одного fdatasync на журнал. На стенде из опыта диск отдаёт 294 тыс. случайных чтений по 8 КБ в секунду, а запись тех же 8 КБ с fdatasync проходит 1 107 раз в секунду, и один клиент PostgreSQL делает 904 коммита в секунду. Первое число зависит от глубины очереди, второе от латентности одной синхронной записи. Почему COMMIT ждёт именно её, разобрано в статье про fsync и путь записи; WAL описывает, что лежит в журнале. Здесь на fio измерено, как латентность и очередь связаны с IOPS, как fdatasync переходит в TPS коммитов и что происходит, когда провайдер ставит на диск полку IOPS.

DBA / 01

Латентность одной операции растёт на порядки, когда она синхронная и под полкой

Лестница латентности: чтение 8 КБ, запись с fdatasync и та же запись под полкой в 100 IOPS Четыре горизонтальные полосы в логарифмической шкале от 10 мкс до 100 мс. Чтение 8 КБ при очереди 1 занимает 98 мкс и даёт 10,1 тысячи операций в секунду. Чтение при очереди 64 занимает 218 мкс и даёт 294 тысячи операций в секунду. Запись 8 КБ с fdatasync занимает 889 мкс и даёт 1 107 операций в секунду. Та же запись под полкой в 100 операций в секунду занимает 73 418 мкс и даёт 13 операций в секунду. Пунктирная линия отмечает 1 мс. шкала логарифмическая: от 10 мкс до 100 мс 1 мс чтение 8 КБ, очередь 1 libaio, direct=1 98 мкс 10,1 тыс. оп/с чтение 8 КБ, очередь 64 та же команда, iodepth=64 218 мкс 294 тыс. оп/с запись 8 КБ + fdatasync fio sync, один поток 889 мкс 1 107 оп/с то же под полкой 100 оп/с --device-write-iops 73 418 мкс 13 оп/с
Первые две полосы — одно устройство, разница только в очереди. Третья на порядок длиннее, потому что fdatasync ждёт, пока данные дойдут до носителя. Четвёртая показывает, что делает с той же записью ограничение IOPS: 73 мс вместо 0,9 мс.

Стенд: на чём идёт опыт

docker run -d --name dba-storage-for-databases -e POSTGRES_PASSWORD=pg postgres:18
docker exec dba-storage-for-databases sh -c 'apt-get update -qq; apt-get install -y -qq fio'
docker exec dba-storage-for-databases sh -c 'fio --version; stat -f -c %T /var/lib/postgresql; grep " /var/lib/postgresql " /proc/self/mounts'

вывод:

fio-3.39
btrfs
/dev/nvme1n1p5 /var/lib/postgresql btrfs rw,seclabel,relatime,compress=zstd:1,ssd,discard=async,space_cache=v2,subvolid=258,subvol=/root 0 0

Том контейнера — btrfs со сжатием zstd:1 на NVMe хоста. Другого типа диска на стенде нет: NVMe против сетевого диска здесь не сравнивается, облачные числа взяты из документации провайдера ниже. Машина рабочая, и латентность fdatasync на ней плавает (опыт 2). Поэтому числа показывают порядок величин и связи между ними, а не паспорт диска. Все файлы fio лежат в /var/lib/postgresql/fio, не больше 512 МБ каждый, и в конце удаляются.

Опыт 1: IOPS — это очередь, делённая на латентность

Случайное чтение 8 КБ мимо page cache (direct=1), глубина очереди 1, 4, 16 и 64:

docker exec dba-storage-for-databases sh -c 'cd /var/lib/postgresql/fio && \
 for q in 1 4 16 64; do fio --name=r$q --filename=rr.dat --size=512m --rw=randread \
 --bs=8k --ioengine=libaio --direct=1 --iodepth=$q --runtime=10 --time_based --group_reporting; done'

Выдержка из вывода при очереди 1 (остальные прогоны того же формата):

  read: IOPS=10.1k, BW=78.9MiB/s (82.8MB/s)(789MiB/10001msec)
    clat (usec): min=3, max=11160, avg=91.76, stdev=65.09
     lat (usec): min=66, max=11171, avg=98.20, stdev=65.39
     | 99.00th=[  149], 99.50th=[  155], 99.90th=[  306], 99.95th=[ 1876],

Сводка по четырём прогонам. lat — среднее от отправки запроса до завершения, перцентили взяты из clat:

ОчередьIOPSlat, мксp99 clat, мксp99,9 clat, мксочередь / lat
110 10098,214930610 180
441 50095,916524341 700
16135 000118,1660857135 500
64294 000217,64692 008294 100

Последний столбец — глубина очереди, делённая на среднюю латентность, в операциях в секунду. Он совпадает с IOPS из fio с точностью до округления: IOPS не свойство диска, а результат того, сколько запросов одновременно в полёте и сколько каждый ждёт. Скачок с 10 тыс. до 294 тыс. дала только очередь. Запрос, который читает страницы по одной, держит очередь 1, и паспортные «сотни тысяч IOPS» его не ускоряют: латентность одного чтения 98 мкс, и до очереди 16 она почти не меняется (96 и 118 мкс). Меняется хвост: p99,9 вырос с 306 до 2 008 мкс.

Опыт 2: fdatasync задаёт, сколько коммитов в секунду

Журнал PostgreSQL пишется последовательно и после каждого коммита синхронизируется. Метод по умолчанию на Linux — fdatasync; код вызова в issue_xlog_fsync (REL_18_6, строки 8753–8760 и 8780–8783):

	/*
	 * Quick exit if fsync is disabled or write() has already synced the WAL
	 * file.
	 */
	if (!enableFsync ||
		wal_sync_method == WAL_SYNC_METHOD_OPEN ||
		wal_sync_method == WAL_SYNC_METHOD_OPEN_DSYNC)
		return;
	...
		case WAL_SYNC_METHOD_FDATASYNC:
			if (pg_fdatasync(fd) != 0)
				msg = _("could not fdatasync file \"%s\": %m");
			break;

Ранний выход возвращает управление, если fsync = off или WAL открыт с O_SYNC/O_DSYNC, иначе в ветке по умолчанию на Linux вызывается pg_fdatasync. Тот же профиль в fio: запись 8 КБ, fdatasync после каждой, один поток, затем 4 и 16 потоков в отдельных файлах:

docker exec dba-storage-for-databases sh -c 'cd /var/lib/postgresql/fio && \
 for j in 1 4 16; do fio --name=w --directory=. --size=64m --rw=write --bs=8k \
 --ioengine=sync --fdatasync=1 --numjobs=$j --runtime=8 --time_based --group_reporting; done'

вывод (выдержка, по потокам):

jobs=1   write: IOPS=1107   sync (usec): min=583, max=8600,   avg=888.77
jobs=4   write: IOPS=2265   sync (usec): min=932, max=10781,  avg=1751.16
jobs=16  write: IOPS=8018   sync (usec): min=703, max=123100, avg=1980.38

Для сравнения та же запись без синхронизации (--rw=write --bs=8k --ioengine=sync, 5 секунд) дала 154 000 операций в секунду: fdatasync делает её в 139 раз реже. Теперь тот же сценарий на уровне базы, INSERT одной строки на коммит (wal_sync_method = fdatasync, synchronous_commit = on, fsync = on):

docker exec dba-storage-for-databases sh -c 'psql -U postgres -qc "create table t(i int)"; \
 echo "insert into t values (1);" > /tmp/ins.sql; \
 for c in 1 4 16; do pgbench -U postgres -n -f /tmp/ins.sql -c $c -j $c -T 8 postgres | grep -E "tps|latency"; done'

вывод (выдержка):

c=1   latency average = 1.106 ms   tps = 904.095382
c=4   latency average = 1.986 ms   tps = 2013.634662
c=16  latency average = 2.197 ms   tps = 7281.388933
Клиентов / потоковfio, оп/сpgbench, TPS
11 107904
42 2652 014
168 0187 281

Один клиент упирается в 1/0,89 мс = около 1 100 синхронизаций в секунду, и коммитов получается 904. Отсюда и вся арифметика коммита: время транзакции — работа плюс ожидание fdatasync, и если синхронизация занимает 1–9 мс, один клиент не сделает больше 1 000–111 коммитов в секунду, как бы быстро диск читал. Параллельные клиенты делят одну синхронизацию журнала, и TPS растёт: при 16 клиентах 7 281.

Но число fdatasync на этом томе нестабильно. Серии на одном и том же томе подряд, средняя латентность синхронизации:

СерияСредняя латентность fdatasync
первый прогон, один поток, 256 МБ10 346 мкс (96 оп/с)
повтор, три прогона по 10 с7 317, 4 093 и 913 мкс
пять прогонов по 8 с подрядот 903 до 942 мкс

Между первой серией и последней разница в 11 раз; повтор и пять прогонов шли с одной и той же командой. Причина не выяснялась: машина рабочая, том общий. Для DBA вывод практический: одно измерение fdatasync ничего не доказывает, а хвост sync percentiles важнее среднего.

Опыт 3: полка IOPS

Облачный диск режет не только латентность, но и число операций. Docker умеет то же без привилегий через cgroup v2 (io.max) на блочное устройство хоста. Контейнер с полкой записи 100 операций и чтения 2 000 операций в секунду:

docker run -d --name dba-storage-for-databases-thr \
 --device-write-iops /dev/nvme1n1:100 --device-read-iops /dev/nvme1n1:2000 \
 -e POSTGRES_PASSWORD=pg postgres:18
docker exec dba-storage-for-databases-thr cat /sys/fs/cgroup/io.max

вывод:

259:1 rbps=max wbps=max riops=2000 wiops=100

Тем же fio и pgbench внутри этого контейнера (установка fio под полкой заняла больше двух минут вместо секунд):

read   iodepth=1    IOPS=1997   lat avg=499 мкс     p99=537 мкс      p99,5=53 740 мкс
read   iodepth=16   IOPS=1999   lat avg=8 000 мкс   p99=103 285 мкс
write  fdatasync    IOPS=13     sync avg=73 418 мкс
write  direct qd1   IOPS=100    lat avg=9 987 мкс
pgbench c=1   latency average = 69.494 ms   tps = 14.389737
pgbench c=16  latency average = 153.689 ms  tps = 104.106413

Чтение упёрлось ровно в 2 000: очередь 16 не добавила ни одной операции, а лишь растянула латентность с 0,5 до 8 мс. Прямая запись 8 КБ при очереди 1 дала ровно 100. fdatasync дал 13, то есть на одну синхронизацию пришлось в среднем около 7–8 операций записи на устройство (100 / 13); это вывод из двух чисел, число операций напрямую не считалось. Коммит под полкой стоит 69 мс, а 16 клиентов вытягивают 104 TPS: число одного порядка с полкой, как именно коммиты делят операции, не разбиралось. Полка записи — это не «медленный диск», а потолок TPS, который не лечится ни ядрами, ни shared_buffers.

Что говорят провайдеры

Сетевой диск в этом стенде не измерялся. Лимиты и латентность — со страниц документации AWS на 28.10.2026:

Тип томаЧто гарантирует документация
EBS gp3базовые 3 000 IOPS и 125 МиБ/с включены в цену; IOPS докупаются до 80 000, пропускная способность до 2 000 МиБ/с; «single-digit millisecond latency»
EBS io2 Block Expressна инстансах Nitro средняя латентность меньше 500 мкс для операций 16 КиБ; до 256 000 IOPS

Страница gp3 называет задержку «однозначными миллисекундами», то есть по арифметике из опыта 2 это 111–1 000 коммитов в секунду для одного клиента, а базовая полка в 3 000 IOPS работает так же, как полка в 100 из опыта 3, только выше. Числа тома своего облака берут тем же fio: цифра из документации гарантия провайдера, а не измерение под твоей нагрузкой. Страница лимитов Yandex Cloud при проверке отдала защиту от роботов, остальные российские провайдеры не проверялись, поэтому их лимиты здесь не приведены.

Файловая система, RAID и ZFS: что подтверждено документацией

Сравнение xfs и ext4, RAID 10 и RAID 5 на стенде не измерялось, и числа «из опыта» в статье нет: те же профили fio из опытов 1 и 2 запускаются на каждом варианте отдельно. Из документации PostgreSQL известно одно, что касается RAID: кэш контроллера может быть write-back и терять данные при отключении питания, если у него нет батарейного модуля (надёжность WAL). Значит, fdatasync в 50 мкс на массиве подозрителен и проверяется, а не принимается как рекорд.

Про ZFS документация OpenZFS советует отдельные наборы данных для данных и WAL, compression=lz4, recordsize=32K и full_page_writes = off, потому что ZFS не фиксирует частичную запись. Документация PostgreSQL про этот параметр предупреждает, что выключение может привести к неисправимому или тихому повреждению данных. На стенде ZFS нет, full_page_writes не менялся, и выключать его без собственного измерения и без проверки, что копия конфига не уедет на другую файловую систему, нельзя.

Что из этого следует

  • Выбирая диск, сравнивай fdatasync на 8 КБ, а не IOPS при глубокой очереди: коммит ждёт первую цифру, а паспорт описывает вторую.
  • TPS одного клиента — это 1 / латентность синхронизации журнала: 0,89 мс дали 904 TPS, 73 мс дали 14.
  • Полка IOPS у облака даёт потолок коммитов; искать её нужно в метриках тома, а не в планах запросов.
  • Одно измерение ничего не стоит: повторяй, смотри хвост перцентилей и не доверяй среднему.

Чек-лист

  1. Замерь fio с --fdatasync=1 --bs=8k на том же томе, что и pg_wal, минимум пять прогонов в разное время.
  2. Смотри sync percentiles, а не только среднее и IOPS.
  3. Найди лимит IOPS и пропускной способности тома и инстанса в документации провайдера и сравни с замером.
  4. Проверь, что кэш контроллера или накопителя защищён от потери питания, прежде чем верить малой латентности.
  5. После опыта удали контейнеры вместе с томами: docker rm -f -v dba-storage-for-databases dba-storage-for-databases-thr.

Источники

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