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

Статья 14.3 · раздел 14 «Консалтинг: аудит, оптимизация, миграции» · 104 из 109

Нагрузочное тестирование PostgreSQL честно: pgbench, прогрев, перцентили и coordinated omission

опубликовано
читать
≈ 10 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) и pgbench 18.6 в контейнере postgres:18 (--memory 2g, --cpus 4, shared_buffers=256MB, checkpoint_timeout=1min, max_wal_size=1GB, fsync и synchronous_commit включены); база pgbench scale 75, 1129 МБ; клиент pgbench запускался внутри того же контейнера; общая машина, параллельно работали другие опыты; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Один сервер можно описать как «820 tx/s, 20 мс» и как «p99 4,8 с при пятисекундной паузе». Первое даёт pgbench по умолчанию, второе — нагрузка заданным темпом и латентность по каждой транзакции. Про checkpoint — статья про всплески ввода-вывода, про выбор запросов — pg_stat_statements.

Стенд и что здесь «больше чего»

docker run -d --name dba-bench --memory 2g --cpus 4 -e POSTGRES_PASSWORD=pg postgres:18 \
  -c shared_buffers=256MB -c checkpoint_timeout=1min -c max_wal_size=1GB \
  -c track_io_timing=on -c log_checkpoints=on
docker exec dba-bench pgbench -U postgres -i -s 75 -q postgres
docker exec dba-bench psql -U postgres -Atc "select pg_size_pretty(pg_database_size('postgres')), current_setting('shared_buffers')"

вывод:

1129 MB|256MB

База (1129 МБ) в 4,4 раза больше shared_buffers, но меньше лимита памяти контейнера (2 ГБ). Page cache входит в этот лимит и вмещает всю базу, поэтому «данные больше RAM» здесь не выполнено: чтения идут из кэша ядра, а не с диска. Больше 2 ГБ базу делать нельзя из-за места на диске машины, так что опыт показывает работу с shared_buffers, а не с накопителем. Опыт шёл на общей машине вместе с чужими контейнерами, поэтому разброс между повторами включает посторонний шум; именно поэтому повторов не меньше трёх.

checkpoint_timeout снижен с 5 минут по умолчанию (документация) до 1 минуты: прогон должен быть длиннее интервала, и на стенде это достигается за две минуты, а не за часы. На продовых настройках правило то же: прогон охватывает несколько интервалов checkpoint_timeout.

Для каждого запуска pgbench пишет лог всех транзакций (-l) в /tmp/lg, число checkpoint за прогон — разность select num_timed + num_requested from pg_stat_checkpointer до и после, а перцентили считает короткий скрипт. В логе третья колонка — латентность транзакции в микросекундах; скрипт читает docker exec dba-bench sh -c 'cat /tmp/lg/pgbench_log.*' | python3 -I stats.py:

import sys
v = sorted(int(f[2]) for f in map(str.split, sys.stdin) if len(f) > 2 and f[2].isdigit())
p = lambda q: v[-(-len(v) * q // 100) - 1] / 1000
print(f"n={len(v)} mean={sum(v)/len(v)/1000:.1f} p50={p(50):.1f} p95={p(95):.1f} p99={p(99):.1f} max={v[-1]/1000:.1f} мс")

Строки skipped в лог попадают без числа в третьей колонке, фильтр isdigit их отбрасывает.

Правильный запуск

Протокол: 120 секунд прогрева (результат отбрасывается), затем три повтора по 120 секунд. Сначала закрытая модель определяет потолок: три прогона -c 16 дали 981, 820 и 796 tx/s, среднее 865. Затем темп задаётся явно, в долях потолка: 30 %, 60 % и 90 % (259, 519 и 778 tx/s). Каждый уровень повторён три раза:

docker exec dba-bench sh -c 'mkdir -p /tmp/lg && cd /tmp/lg && \
  pgbench -U postgres -n -l -c 16 -j 4 -T 120 --rate=519 -L 100 postgres'

вывод (один из трёх повторов уровня 60 %):

number of transactions actually processed: 60753
number of transactions skipped: 1266 (2.041%)
number of transactions above the 100.0 ms latency limit: 1588/60753 (2.614%)
latency average = 23.880 ms
latency stddev = 29.119 ms
rate limit schedule lag: avg 6.059 (max 99.991) ms
tps = 506.186304 (without initial connection time)

--rate включает открытую модель: транзакции приходят по расписанию независимо от того, успел ли сервер ответить на предыдущие. -L 100 заставляет pgbench не отправлять транзакции, которые уже опоздали на 100 мс и больше, и считать их отдельно (skipped). Результат по трём уровням, медиана трёх повторов, в скобках — минимум и максимум:

Нагрузкаp50, мсp95, мсp99, мсПропущено -L
30 % (259 tx/s)13,6 (9,3–13,7)29,4 (26,8–30,1)56,5 (46,8–56,6)0,3 % (0,04–0,5)
60 % (519 tx/s)16,2 (15,8–16,6)75,8 (64,6–96,4)121,8 (113,7–136,4)2,0 % (0,9–2,9)
90 % (778 tx/s)29,5 (13,3–34,9)104,2 (96,5–121,0)125,3 (122,6–149,7)2,9 % (2,4–9,5)

Все 120-секундные прогоны видели ровно два checkpoint (pg_stat_checkpointer, разность до и после).

DBA / 01

p50, p95 и p99 при трёх уровнях нагрузки

Перцентили латентности pgbench при 30, 60 и 90 процентах от потолка Три панели с общей шкалой 0–150 мс. При 30 процентах нагрузки, 259 tx/s: p50 13,6, p95 29,4, p99 56,5 мс, пропущено 0,3 процента. При 60 процентах, 519 tx/s: 16,2, 75,8 и 121,8 мс, пропущено 2,0 процента. При 90 процентах, 778 tx/s: 29,5, 104,2 и 125,3 мс, пропущено 2,9 процента. 30% нагрузки 259 tx/s p50 13,6 p95 29,4 p99 56,5 пропущено: 0,3 % 60% нагрузки 519 tx/s p50 16,2 p95 75,8 p99 121,8 пропущено: 2,0 % 90% нагрузки 778 tx/s p50 29,5 p95 104,2 p99 125,3 пропущено: 2,9 % Шкала общая для трёх панелей: 0–150 мс. Значения — медианы трёх повторов.
Медиана почти не растёт, пока нагрузка не подходит к потолку, а хвост уходит вверх уже на 60 %. Плато p99 на 90 % — не здоровье, а граница -L 100: опоздавшие транзакции не выполняются и уходят в «пропущено».

Следствие для отчёта: p99 не выше сотни миллисекунд на 90 % нагрузки читается правильно только вместе с долей пропущенных транзакций. Без -L хвост был бы длиннее, а с ним он обрезан и переехал в счётчик.

Пять ошибочных запусков

Каждый из них на той же базе даёт число, которое выглядит как результат.

ЗапускПовторыTPS (мин–макс)Что видноВывод, который из него делают
Закрытая модель, 120 с, только среднее3796–981среднее 16,3–20,1 мс; p99 48–63 мс; максимум 0,8–1,0 с«20 мс»
Короткий, 10 с5735–877checkpoint за прогон: 0 или 1; максимум 73–262 мс«максимум ниже 0,3 с»
Без прогрева: перезапуск, 30 с3817, 1223, 1262checkpoint за прогон: 0«база даёт до 1260 tx/s»
Малый масштаб: scale 1, 60 с3109–114среднее 141–147 мс; p99 634–730 мс«сервер даёт 110 tx/s»

Только среднее и закрытая модель. При -c 16 в полёте всегда ровно 16 транзакций, поэтому среднее — это 16 / TPS: 16 / 819,8 = 19,5 мс, pgbench напечатал 19,517. Число не описывает сервер, оно описывает выбранное число клиентов. Хвост в тех же прогонах: p99 в 2,7 раза выше среднего (медианы 52,7 и 19,5 мс), максимум 0,8–1,0 с при двух checkpoint за прогон.

Короткий прогон. За 10 секунд checkpoint не случился в четырёх из пяти запусков; максимум 73–262 мс против 0,8–1,0 с у двухминутных. Короткий прогон измеряет интервал между checkpoint и обещает отсутствие пауз, которые на самом деле есть. Разброс TPS (735–877) включает посторонний шум машины.

Без прогрева. Перезапуск контейнера опустошает shared_buffers, но в этом стенде не замедлил прогон: два из трёх повторов дали 1223 и 1262 tx/s, быстрее установившихся 796–981 из двухминутных, третий — 817. Условия всех трёх одинаковы, checkpoint ни в одном не было, причина разброса не выделена. Вероятная роль страничного кэша ядра (он переживает перезапуск контейнера и вмещает 1129 МБ) не проверялась: сбросить его без прав суперпользователя на хосте нельзя. Вывод для методики: на этой базе и в этих условиях эффект прогрева не выделен из шума. Чтобы утверждать обратное для другой базы, нужны оба варианта запуска с повторами.

Малый масштаб. Scale 1 — это одна строка в pgbench_branches и десять в pgbench_tellers: 16 клиентов обновляют одни и те же строки и ждут друг друга. 109–114 tx/s против 796–981 при scale 75 — разница примерно в восемь раз (865 в среднем против 110), и это не мощность сервера, а блокировка строки. Диагноз виден по среднему 141–147 мс при том же числе клиентов.

Coordinated omission: пауза, которая не попадает в среднее

Закрытая модель теряет данные о паузе: пока сервер стоит, клиенты заблокированы и новых запросов не присылают. Те запросы, которые пользователь успел бы прислать за эти секунды, в статистике не появляются. Это называют coordinated omission. Открытая модель их планирует заранее, и pgbench в режиме --rate считает латентность от запланированного старта:

				thread->throttle_trigger +=
					getPoissonRand(&thread->ts_throttle_rs, throttle_delay);
				st->txn_scheduled = thread->throttle_trigger;

Расписание движется по throttle_trigger, который сдвигается на пуассоновский интервал независимо от того, закончилась ли предыдущая транзакция (строки 3777–3779).

		/* compute latency & lag */
		latency = (*now) - st->txn_scheduled;
		lag = st->txn_begin - st->txn_scheduled;

Латентность в итоге и в логе — now − txn_scheduled, а не now − txn_begin; задержка расписания — их разность (строки 4752–4754). Документация говорит то же: при --rate латентность включает время ожидания предыдущей транзакции.

Пауза воспроизводится блокировкой, в которой каждая транзакция упирается в pgbench_branches:

docker exec dba-bench psql -U postgres -c "BEGIN; LOCK TABLE pgbench_branches IN ACCESS EXCLUSIVE MODE; SELECT pg_sleep(5); COMMIT;"

Команду запускали на 20-й секунде 60-секундного прогона. Три варианта, по три повтора, медианы (в скобках минимум и максимум):

ВариантTPSСреднее, мсp99, мсМаксимум, мсТранзакций дольше 1 с
Закрытая, -c 16846 (764–1117)18,9 (14,3–20,9)58,5 (40,4–60,2)505532 (16–35) из 46–67 тыс.
Открытая, --rate=519515 (514–520)670 (355–745)4757 (4616–4781)50475864 (3501–7105) из ~31 тыс.
Открытая, --rate=519 -L 100458 (450–461)30,4 (22,8–32,2)137 (111–143)510216 из ~27,5 тыс.; пропущено 3449–4027

Закрытая модель получила пятисекундную паузу и осталась со средним 18,9 мс — таким же, как у прогона без паузы (16,3–20,1). Максимум 5055 мс виден, но это 16–35 транзакций из 50 тысяч: на p99 они не влияют. Открытая модель за те же 5 секунд накопила расписание, по которому транзакции опоздали: p99 вырос до 4,8 с, среднее до 670 мс.

С -L 100 картина снова спрятана: p99 137 мс, среднее 30 мс. Пауза ушла в skipped (11–13 % расписания) и в разброс: latency stddev вырос до 125–129 мс, у двухминутных прогонов на 60 % без паузы он был 22–30 мс. Вывод в отчёт: цифры латентности с -L приводятся только с процентом пропущенных.

вывод (открытая модель без -L, первый повтор):

latency average = 670.464 ms
latency stddev = 1225.437 ms
rate limit schedule lag: avg 647.037 (max 5006.399) ms

Свой профиль нагрузки, TPC и HammerDB

Встроенный сценарий TPC-B-подобен и мало похож на нагрузку приложения. Запросы для воспроизведения берут из pg_stat_statements (разбор), каждому дают скрипт и вес @N в одном запуске:

pgbench -U postgres -n -c 16 -j 4 -T 30 -f /tmp/read.sql@9 -f /tmp/write.sql@1 postgres

вывод:

latency average = 1.604 ms
tps = 9974.710865 (without initial connection time)
SQL script 1: /tmp/read.sql
 - latency average = 0.072 ms
SQL script 2: /tmp/write.sql
 - latency average = 15.702 ms

Общее среднее 1,6 мс скрывает запись на 15,7 мс: сводную строку нельзя читать без разбивки по скриптам. Скрипты:

-- read.sql
\set aid random(1, 100000 * :scale)
SELECT abalance FROM pgbench_accounts WHERE aid = :aid;
-- write.sql
\set aid random(1, 100000 * :scale)
UPDATE pgbench_accounts SET abalance = abalance + 1 WHERE aid = :aid;

TPC-C и TPC-H — стандартизованные тесты: первый моделирует учётную систему оптового поставщика и измеряется в tpmC, второй — набор аналитических запросов (decision support) с параллельными изменениями данных (TPC-C, TPC-H). Их результат определён спецификацией, схемой, масштабом и правилами отчётности, поэтому число со своего стенда не равно опубликованному и не переносится на твою нагрузку. HammerDB реализует нагрузки TPROC-C и TPROC-H, «derived from» TPC-C и TPC-H, как называет их его документация: это готовый генератор, а не сертифицированный результат.

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

  • Нагрузку задают темпом (--rate), а не числом клиентов. Число клиентов определяет среднее в закрытой модели через клиенты / TPS.
  • Прогон длиннее интервала checkpoint_timeout и с несколькими повторами: у двухминутных прогонов были ровно два checkpoint и максимум около секунды, у десятисекундных — ноль или один и максимум в разы меньше.
  • В отчёте — p50, p95, p99, максимум и доля пропущенных, а не одно среднее. На 60 % нагрузки p99 был в 7,5 раза выше p50.
  • Масштаб данных называют вместе с тем, что он превышает: здесь 1129 МБ больше shared_buffers, но меньше памяти контейнера.
  • Пауза сервера проверяется отдельным прогоном: если она не видна в p99, нагрузка не открытая или данные урезаны.

Чек-лист

Перед тем как показывать число:

  1. Прогрев отброшен, длительность охватывает несколько checkpoint, повторов не меньше трёх, разброс показан.
  2. Темп задан --rate, -L указан вместе с долей пропущенных.
  3. Масштаб больше shared_buffers, и написано, влезает ли база в кэш ядра.
  4. Перцентили посчитаны по логу -l, а не взяты из среднего.
  5. Для приложения использован профиль из pg_stat_statements, а не встроенный TPC-B.

Контейнер и его том убирают одной командой: docker rm -f -v dba-bench.

Источники

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