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

Статья 10.5 · раздел 10 «Мониторинг и диагностика» · 75 из 109

Хост под PostgreSQL: page tables без huge pages, cgroup OOM, iostat при checkpoint и что не видно из контейнера

опубликовано
читать
≈ 11 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнерах postgres:18 (shared_buffers = 1GB при --memory 8g и 128MB при --memory 512m), ядро хоста 7.1.13, 20 ядер, 64 ГБ; procps-ng 4.0.4, sysstat 12.7.5, perf 6.12.113 из пакетов Debian внутри контейнера; таблица big 874 МБ, 300 соединений; опыт выполнен 28 октября 2026, настройки хоста не менялись
содержание решать задачи SQL на собеседовании

Хост под PostgreSQL тратит на сотнях соединений память, которой не видно в запросах, а потом ядро убивает один процесс сигналом 9, и все сессии рвутся разом. Причины лежат в хосте, а не в SQL: каждый backend держит собственные таблицы страниц для общей памяти (при 4-килобайтных страницах это около 2 МиБ на гигабайт прочитанных буферов), а лимит памяти и OOM killer не отличают «большой запрос» от «всего сервера». Сами буферы разобраны в статье про clock sweep, запись на диск — в статье про checkpoint; здесь измерено то, что лежит под ними, без изменения хоть одной настройки хоста.

DBA / 01

Куда уходит память под сотнями backend

Куда уходит память под сотнями backend shared_buffers 1 ГБ без huge pages читают 300 backend. Сумма VmPTE растёт с 71 584 до 657 184 кБ. Расчёт для страниц по 2 МБ: около 4 кБ на backend. В memory.stat контейнера pagetables растут с 75 до 675 МБ. Контейнер с лимитом 512 МБ и четырьмя сортировками по 235 МБ получает signal 9 на одном backend, postmaster перезапускает все процессы за 0,9 с. shared_buffers 1 ГБ huge_pages_status = off 300 backend читают big +1 952 кБ VmPTE на процесс Σ VmPTE по процессам postgres 71 584 → 657 184 кБ страницы 2 МБ (расчёт) 543 записи × 8 Б ≈ 4 кБ на backend memory.stat контейнера pagetables 75 → 675 МБ --memory 512m, 4 сортировки signal 9 → reinitializing 0,9 с Таблицы страниц входят в память контейнера и растут с числом соединений. Лимит cgroup убивает процесс, а не запрос, и postmaster перезапускает всех.
Без huge pages каждый из 300 backend платит своими таблицами страниц за те же 874 МБ общей памяти, и эта плата входит в лимит контейнера. Когда лимит кончается, ядро убивает один процесс, а PostgreSQL перезапускает все остальные.

Стенд

Контейнер с лимитом 8 ГБ, 1 ГБ shared_buffers, 600 допустимых соединений и таблицей, прогретой в буферы. max_parallel_workers_per_gather = 0 нужен потому, что /dev/shm в Docker по умолчанию 64 МБ, и параллельные запросы в 300 сессиях падают с could not resize shared memory segment ... No space left on device.

docker run -d --name dba-os-level-diagnostics-db-host -e POSTGRES_PASSWORD=pg --memory 8g postgres:18 \
  -c shared_buffers=1GB -c huge_pages=off -c max_connections=600 \
  -c max_parallel_workers_per_gather=0 -c max_wal_size=4GB
docker exec -i dba-os-level-diagnostics-db-host psql -U postgres -Atq <<'SQL'
create extension pg_prewarm;
create table big as select g as id, repeat('x', 200) as pad from generate_series(1, 3800000) g;
select pg_size_pretty(pg_relation_size('big')), pg_prewarm('big');
SQL

Вывод: 874 MB|111808 — таблица 874 МБ (111 808 страниц) лежит в shared_buffers целиком. Дальше только чтение /proc, /sys и memory.stat своего контейнера: ни sysctl -w, ни --privileged, ни записи в /proc/sys.

Опыт 1: page tables у 300 backend

Скрипт для pgbench держит 300 сессий, а между select 1 и select count(*) from big даёт снять «до» и «после». VmPTE в /proc/<pid>/status — размер таблиц страниц процесса.

printf "select 1;\nselect pg_sleep(15);\nselect count(*) from big;\nselect pg_sleep(40);\n" > /tmp/s.sql
docker cp /tmp/s.sql dba-os-level-diagnostics-db-host:/tmp/s.sql
docker exec dba-os-level-diagnostics-db-host bash -c '
pgbench -U postgres -n -c 300 -j 4 -T 60 -f /tmp/s.sql postgres >/dev/null &
snap() { awk "/^VmPTE/{s+=\$2;n++} END{print n\" процессов postgres, VmPTE: \"s\" kB\"}" $(for p in $(pgrep -x postgres); do echo /proc/$p/status; done)
  grep -E "^(anon|pagetables|shmem) " /sys/fs/cgroup/memory.stat; }
sleep 10; snap; sleep 22; snap; wait'
309 процессов postgres, VmPTE: 71584 kB
anon 395296768
pagetables 75075584
shmem 992292864
309 процессов postgres, VmPTE: 657184 kB
anon 2892144640
pagetables 674664448
shmem 992292864

Вывод: сумма VmPTE выросла на 585 600 кБ, то есть на 572 МиБ, или на 1 952 кБ на каждое из 300 соединений; pagetables контейнера выросли на 572 МиБ так же. Общая память (shmem) не изменилась ни на байт: все 300 процессов смотрят на одни и те же 992 МБ, но у каждого свои записи таблицы страниц. Оценка по формуле близка к измерению: 874 МиБ данных при 4 КиБ на страницу и 8 байтах на запись дают 1 748 кБ на процесс против измеренных 1 952 кБ.

Таблицы страниц засчитываются в лимит контейнера. Попытка того же опыта с --memory 4g закончилась client backend (PID 185) was terminated by signal 9: Killed, memory.events показал oom 8 и oom_kill 2, docker inspect — OOMKilled=true. Под лимитом 8 ГБ видно, из чего складывается расход: anon вырос на 2,5 ГБ (около 8 МБ на backend) до 2,9 ГБ, pagetables до 0,67 ГБ, плюс 0,99 ГБ общей памяти; в сумме 4,5 ГБ, больше 4 ГБ.

Что дали бы huge pages (расчёт, не измерение)

С 2-мегабайтными страницами нужна одна запись на страницу вместо 512. Сколько их потребуется, PostgreSQL сообщает до запуска сервера (пустой кластер, shared_buffers = 1GB, 100 соединений по умолчанию):

docker exec -u postgres dba-os-level-diagnostics-db-host bash -c 'initdb -D /tmp/d2 >/dev/null 2>&1
  postgres -D /tmp/d2 -c shared_buffers=1GB -C shared_memory_size_in_huge_pages'
543

Вывод: 543 записи по 8 байт, около 4 кБ на процесс, против 1 952 кБ измеренных. Для 300 соединений это порядка 1,2 МиБ вместо 572 МиБ. Число получено вычислением по размеру записи; на своём хосте перемерь VmPTE после включения.

huge_pages = try молча не работает

На хосте HugePages_Total: 0 в /proc/meminfo. Режим try при этом не жалуется:

docker exec -u postgres dba-os-level-diagnostics-db-host bash -c '
  O="-c shared_buffers=1GB -c port=5433 -c unix_socket_directories=/tmp"
  pg_ctl -D /tmp/d2 -o "$O -c huge_pages=try" -l /tmp/d2.log -w start >/dev/null
  psql -h /tmp -p 5433 -Atq -c "show huge_pages" -c "show huge_pages_status" postgres
  pg_ctl -D /tmp/d2 -m fast stop >/dev/null
  postgres -D /tmp/d2 $O -c huge_pages=on 2>&1 | head -2; rm -rf /tmp/d2 /tmp/d2.log'
try
off
FATAL:  could not map anonymous shared memory: Cannot allocate memory
HINT:  This error usually means that PostgreSQL's request for a shared memory segment exceeded available memory, swap space, or huge pages. To reduce the request size (currently 1138753536 bytes), reduce PostgreSQL's shared memory usage, perhaps by reducing "shared_buffers" or "max_connections".

Вывод: с try сервер стартует, а huge_pages_status говорит off; с on не стартует. Причина в CreateAnonymousSegment (REL_18_6): если mmap с MAP_HUGETLB не удался, режим try пишет сообщение уровня DEBUG1, а его в логе при обычных настройках нет:

		if (huge_pages == HUGE_PAGES_TRY && ptr == MAP_FAILED)
			elog(DEBUG1, "mmap(%zu) with MAP_HUGETLB failed, huge pages disabled: %m",
				 allocsize);

Выделение страниц (vm.nr_hugepages) и права (vm.hugetlb_shm_group, ulimit -l) описаны в документации PostgreSQL; здесь они не менялись и не измерялись.

Опыт 2: cgroup OOM и перезапуск после аварии

Контейнер на 512 МБ без свопа, work_mem 300 МБ и четыре одновременные сортировки. Одна занимает 235 МБ (Sort Method: quicksort Memory: 235514kB в EXPLAIN (ANALYZE) для таблицы t в 1 млн строк, 224 МБ), четыре превышают лимит вдвое. Как читать Sort Method, разобрано в статье про work_mem.

docker run -d --name dba-os-level-diagnostics-db-host-oom -e POSTGRES_PASSWORD=pg --memory 512m --memory-swap 512m \
  postgres:18 -c shared_buffers=128MB -c max_parallel_workers_per_gather=0 -c max_connections=50
docker exec dba-os-level-diagnostics-db-host-oom psql -U postgres -Atq -c "create table t as select g as id,
  md5(g::text)||md5((g+1)::text)||md5((g+2)::text)||md5((g+3)::text)||md5((g+4)::text)||md5((g+5)::text) as pad
  from generate_series(1,1000000) g"
printf "set work_mem='300MB';\nselect * from t order by pad offset 1000000;\n" > /tmp/o.sql
docker cp /tmp/o.sql dba-os-level-diagnostics-db-host-oom:/tmp/o.sql
docker exec dba-os-level-diagnostics-db-host-oom pgbench -U postgres -n -c 4 -t 1 -f /tmp/o.sql postgres
docker logs dba-os-level-diagnostics-db-host-oom 2>&1 | grep -E 'signal|terminating|reinitializ|redo|ready'
docker exec dba-os-level-diagnostics-db-host-oom grep oom /sys/fs/cgroup/memory.events
docker inspect dba-os-level-diagnostics-db-host-oom --format 'OOMKilled={{.State.OOMKilled}} Restarts={{.RestartCount}}'
pgbench: error: client 0 aborted in command 1 (SQL) of script 0; perhaps the backend died while processing
WARNING:  terminating connection because of crash of another server process
LOG:  client backend (PID 134) was terminated by signal 9: Killed
LOG:  terminating any other active server processes
LOG:  all server processes terminated; reinitializing
LOG:  redo starts at 0/17619C8
LOG:  redo done at 0/10CB7A00 system usage: CPU: user: 0.32 s, system: 0.26 s, elapsed: 0.62 s
LOG:  database system is ready to accept connections
oom 6
oom_kill 2
OOMKilled=true Restarts=0

Показаны отобранные строки, время в логе убрано. Вывод: убит один backend (PID 134) посреди сортировки, остальные получили terminating connection because of crash of another server process. Контейнер не перезапускался (Restarts=0): postmaster жив, он сам перезапустил дочерние процессы, проиграл около 245 МБ WAL (от 0/17619C8 до 0/10CB7A00) за 0,62 с, и база снова приняла соединения через 0,9 с после убийства. Первое решение принимает HandleChildCrash (REL_18_6): гибель любого дочернего процесса от сигнала объявляет остальным аварию.

	LogChildExit(LOG, procname, pid, exitstatus);
	ereport(LOG,
			(errmsg("terminating any other active server processes")));

Строку all server processes terminated; reinitializing пишет postmaster (postmaster.c, REL_18_6, L3184-L3187), когда погиб последний дочерний процесс; проигрывание WAL после неё описано в статье про crash recovery.

Что меняет vm.overcommit_memory = 2, без изменения хоста

Здесь действует режим по умолчанию (vm.overcommit_memory = 0): нехватка памяти обнаруживается, когда страницу трогают, и тогда приходит OOM killer. Документация PostgreSQL предупреждает, что в этом режиме ядро может убить и сам postmaster: существующие соединения продолжат работать, новые принимаются не будут. Режим 2 не выдаёт памяти больше лимита, и нехватка приходит как отказ выделения, то есть как ошибка внутри одной сессии. Включать режим на общем хосте нельзя, но развилку «ошибка вместо убийства» показывает лимит адресного пространства одного процесса: prlimit на собственный backend пользователя postgres.

cat > /tmp/as.sql <<'SQL'
select pg_backend_pid() as pid \gset
\setenv PID :pid
\! grep VmPeak /proc/$PID/status
\! prlimit --pid $PID --as=420000000
set work_mem='300MB';
select * from t order by pad offset 1000000;
select pg_postmaster_start_time() < now() - interval '1 second' as postmaster_not_restarted;
SQL
docker cp /tmp/as.sql dba-os-level-diagnostics-db-host-oom:/tmp/as.sql
docker exec -u postgres dba-os-level-diagnostics-db-host-oom psql -U postgres -f /tmp/as.sql
VmPeak:	  222820 kB
SET
psql:/tmp/as.sql:6: ERROR:  out of memory
DETAIL:  Failed on request of size 216 in memory context "Caller tuples".
 postmaster_not_restarted 
--------------------------
 t

Вывод: тот же запрос завершился out of memory в одной сессии, postmaster не перезапускался. Это аналогия (предел процесса, а не предел коммита всей системы). Перед включением режима 2 сверь CommitLimit и Committed_AS в /proc/meminfo: на этом хосте первый 41 229 688 кБ, второй 121 977 144 кБ, и по документации ядра в режиме 2 новые выделения получали бы ошибки.

Опыт 3: vmstat и iostat при CHECKPOINT

В контейнер из опыта 1 ставятся пакеты, затем меняются 1,5 млн строк и сбрасываются CHECKPOINT, пока vmstat и iostat пишут по секунде. Устройство nvme1n1 выбрано как то, на которое с загрузки хоста пришлось больше всего записей (iostat -d).

docker exec dba-os-level-diagnostics-db-host bash -c 'apt-get update -qq; apt-get install -y -qq procps sysstat'
docker exec dba-os-level-diagnostics-db-host psql -U postgres -Atq -c "checkpoint"
docker exec dba-os-level-diagnostics-db-host bash -c '
iostat -dx 1 14 nvme1n1 > /tmp/iostat.out & vmstat 1 14 > /tmp/vmstat.out & sleep 2
psql -U postgres -Atq -c "update big set pad = repeat(chr(121), 200) where id <= 1500000" -c "checkpoint"
wait; cat /tmp/vmstat.out /tmp/iostat.out'

Из 14 секунд оставлены первая строка (средние с загрузки хоста) и секунда пика:

procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu-------
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st gu
 2  1 8385684 6054424    560 19398736   24  101  3918  8962 23188   32 11  2 86  0  0  0
 4  0 8385680 4754936    560 20568140    0    0  4672 694404 37907 107055  6  7 85  1  0  0

Device            r/s     rkB/s     w/s      wkB/s  w_await wareq-sz     f/s f_await  aqu-sz  %util
nvme1n1         86.48   3870.92  463.46    8861.45     2.57    19.12   18.36    0.87    1.27   4.43
nvme1n1        292.00   4672.00 6644.00  694404.00     0.51   104.52  127.00    0.28    3.44  28.30

Вывод: в секунду пика bo 694 404 кБ/с, wkB/s те же 694 404, средний размер записи 104,5 кБ против 19,1 кБ в среднем с загрузки, f/s 127 в секунду, %util 28,3 %. В логе CHECKPOINT эта же точка: wrote 69584 buffers (53.1%), write=0.583 s, sync=0.060 s, total=0.721 s; 69 584 страницы по 8 КБ — 544 МБ. Механику записи и fsync разбирают статья про fsync и статья про checkpoint.

Оговорка: vmstat и iostat внутри контейнера показывают счётчики хоста целиком: 20 ядер, все процессы, весь своп (swpd 8 385 684 кБ — это своп хоста, а не контейнера). Если на устройстве работает кто-то ещё, по этим счётчикам его записи от записей контейнера не отличить; для этого нужны io.stat cgroup или pidstat -d на хосте.

Что видно только на хосте

Из контейнера без --privileged доступно только чтение. Значения хоста, прочитанные из /sys и /proc/sys:

/sys/kernel/mm/transparent_hugepage/enabled   always [madvise] never
/proc/sys/vm/dirty_background_bytes           0
/proc/sys/vm/dirty_background_ratio           10
/proc/sys/vm/dirty_ratio                      40
/proc/sys/vm/overcommit_memory                0
/proc/sys/vm/overcommit_ratio                 50
/proc/sys/kernel/perf_event_paranoid          2
  • THP. madvise: прозрачные huge pages получают только процессы, которые попросили. Документация PostgreSQL пишет, что THP у части пользователей на части версий Linux снижал производительность, и не рекомендует его; явные huge_pages из опыта 1 — другой механизм. Режимы ядра: transhuge.
  • vm.dirty_background_bytes. Значение 0 означает, что действует dirty_background_ratio = 10: доля от доступной памяти (расчёт для 64 ГБ: порядка 6 ГиБ грязных страниц до начала фонового сброса). Если сброс приходит залпом, документация ядра предлагает задать порог в байтах через dirty_background_bytes; число подбирай по w_await как в опыте 3. Рекомендуемого значения здесь нет: оно не измерено.
  • perf. Пакет ставится в контейнер, но запуск отказывает:
docker exec -u postgres dba-os-level-diagnostics-db-host bash -c 'perf stat -p $(pgrep -f "postgres: checkpointer") -- sleep 1'
Error:
No permission to enable task-clock event.

Что значение perf_event_paranoid разрешает каждому уровню, перечислено в документации ядра. Рецепт для хоста по вики PostgreSQL: perf top -p <pid backend> на горячем запросе или perf record -g -p <pid> и perf report. Здесь он не запускался.

  • Цена вызова часов. pg_test_timing -d 3 в том же контейнере: Per loop time including overhead: 14.20 ns, 98,6 % вызовов быстрее 1 мкс. Это цена одного чтения часов на этом процессоре; на своём хосте измерь её до включения track_io_timing и TIMING. Описание вывода: pg_test_timing.

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

  • Соединение стоит памяти, даже когда оно простаивает. 300 соединений без huge pages добавили 572 МиБ таблиц страниц; прежде чем поднимать max_connections, умножь 2 МиБ на гигабайт shared_buffers на число соединений.
  • try проверяй, а не доверяй. После каждого перезапуска SHOW huge_pages_status: off при try значит, что у ядра нет страниц, а в логе об этом ничего не будет.
  • Лимит памяти убивает процесс, а не запрос. В контейнере с --memory сумма work_mem по сессиям оборвала всех: четыре сортировки по 235 МБ в 512 МБ. При строгом overcommit (расчёт лимита обязателен) та же ситуация даёт ERROR: out of memory в одной сессии.
  • Счётчикам хоста нужен хозяин. Пик записи привязывай к строке checkpoint complete в логе, а не к одной колонке iostat.

Чек-лист

  1. SHOW huge_pages_status; и postgres -D $PGDATA -C shared_memory_size_in_huge_pages (на остановленном кластере) сравни с HugePages_Total в /proc/meminfo.
  2. Просуммируй VmPTE по процессам postgres: awk '/^VmPTE/{s+=$2} END{print s}' /proc/<pid>/status ... и сравни с числом соединений.
  3. В контейнере смотри memory.events (oom_kill) и docker inspect --format '{{.State.OOMKilled}}', в логе ищи terminated by signal 9.
  4. Перед включением vm.overcommit_memory = 2 сравни CommitLimit и Committed_AS в /proc/meminfo.
  5. Убери контейнеры опыта вместе с томами: docker rm -f -v dba-os-level-diagnostics-db-host dba-os-level-diagnostics-db-host-oom.

Источники

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