Продвинутый ~55 мин чтения

Настройка производительности и мониторинг PostgreSQL

Урок 10 из 10 в курсе PostgreSQL для backend-разработчика

Настройка производительности и мониторинг PostgreSQL

PostgreSQL с дефолтными настройками оптимизирован для минимального потребления ресурсов (развёртывание на слабом железе), а не для максимальной производительности на продакшн-сервере. Правильная конфигурация даёт 2-10x прироста производительности без изменения кода. Ключевые области: память (shared_buffers, work_mem, effective_cache_size), I/O (random_page_cost для SSD, checkpoint, WAL), autovacuum (предотвращение bloat), connection pooling (PgBouncer), мониторинг (pg_stat_statements, pg_stat_activity, cache hit ratio, n_dead_tup). Цикл оптимизации: pg_stat_statements находит медленные запросы → EXPLAIN ANALYZE показывает план → индекс или конфигурация решает проблему.

Почему это важно: Большинство проблем производительности PostgreSQL в продакшне имеют одну из трёх причин: неправильные настройки памяти (планировщик выбирает Seq Scan вместо Index Scan из-за неверной оценки стоимости), накопившийся table bloat (autovacuum не успевает, dead tuples занимают 80% таблицы), или перегрузка соединениями (1000 соединений × 5MB каждый = 5GB только на стеки процессов). Каждая из этих проблем решается конфигурацией, а не переписыванием кода. Разработчик, умеющий читать pg_stat_statements и настраивать ключевые параметры, может сократить latency API в 5-10 раз без изменения единой строки бизнес-логики.

Главная идея

Производительность PostgreSQL определяется четырьмя факторами. Память: shared_buffers = кеш страниц данных (25% RAM), work_mem = память для Sort/Hash Join операций (4-64MB глобально), effective_cache_size = подсказка планировщику о доступном кеше (75% RAM). I/O: random_page_cost = стоимость случайного чтения страницы (1.1 для SSD, 4.0 для HDD по умолчанию — критически важно для выбора Index Scan). WAL: max_wal_size, checkpoint_completion_target влияют на I/O spike. Autovacuum: предотвращает bloat, но требует правильного tuning для активных таблиц. Connections: каждое соединение = OS-процесс + 5-10MB памяти; PgBouncer в transaction mode мультиплексирует 1000 клиентских соединений в 20 реальных. Мониторинг: pg_stat_statements (топ медленных запросов), pg_stat_user_tables (n_dead_tup, bloat), cache hit ratio (shared_blks_hit / (shared_blks_hit + shared_blks_read)).

Как это выглядит на практике

  1. Новый продакшн-сервер: 32GB RAM, SSD, PostgreSQL только что установлен. Дефолтные настройки: shared_buffers = 128MB (0.4% RAM). Первый же нагрузочный тест показывает высокую latency.
  2. Запускаешь pg_stat_statements: SELECT ... ORDER BY total_exec_time DESC. Видишь что 5 запросов занимают 80% времени БД. Один из них — простой SELECT с WHERE по indexed колонке, но планировщик выбирает Seq Scan.
  3. EXPLAIN (ANALYZE, BUFFERS): планировщик считает Index Scan слишком дорогим. Причина: random_page_cost = 4.0 (дефолт для HDD). Устанавливаешь random_page_cost = 1.1 для SSD. Планировщик переключается на Index Scan, latency падает с 200ms до 12ms.
  4. Устанавливаешь shared_buffers = 8GB (25% RAM), effective_cache_size = 24GB. Cache hit ratio поднимается с 85% до 99%. Многие запросы теперь читают данные из памяти, не с диска.
  5. Аналитические запросы с GROUP BY + ORDER BY падают с Memory exceeded errors. work_mem = 4MB (дефолт) — PostgreSQL сбрасывает Sort на диск. SET work_mem = '128MB' для аналитических запросов. Запросы ускоряются в 10 раз.
  6. Мониторинг pg_stat_user_tables: таблица events с n_dead_tup = 50M. Autovacuum запускается слишком редко из-за дефолтного scale_factor = 0.2 (20%). Устанавливаешь для этой таблицы autovacuum_vacuum_scale_factor = 0.01 — autovacuum теперь запускается при 1% dead tuples.
  7. Приложение открывает 500 соединений при 100 одновременных пользователях. PostgreSQL тратит 2.5GB только на стеки backend-процессов. Устанавливаешь PgBouncer в transaction mode: 500 клиентских соединений → 20 реальных соединений к PostgreSQL. Memory footprint снижается на 2GB.
  8. Долгая аналитическая транзакция держит xmin snapshot — VACUUM не может убирать dead tuples. n_dead_tup растёт. Добавляешь statement_timeout = '10min' и idle_in_transaction_session_timeout = '30s' — защита от долгих транзакций.
  9. VACUUM FULL для дефрагментации большой таблицы — кто-то запустил в продакшне. Таблица заблокирована на 20 минут. Объясняешь команде: для дефрагментации без блокировки — pg_repack. Добавляешь правило: VACUUM FULL только на staging, никогда в продакшне.
  10. Добавляешь log_min_duration_statement = 1000 — PostgreSQL логирует все запросы медленнее 1 секунды. Через день анализируешь логи: находишь 3 новых медленных запроса (N+1 из ORM, missing index, cartesian join).
  11. Обнаруживаешь индекс на таблице заказов: idx_scan = 0 за последние 30 дней, занимает 8GB. Это индекс на updated_at, добавленный год назад для несуществующего запроса. Удаляешь: DROP INDEX CONCURRENTLY. INSERT/UPDATE ускоряются на 15%.
  12. Составляешь продакшн-чеклист: shared_buffers, random_page_cost, pg_stat_statements, PgBouncer, autovacuum tuning для активных таблиц, мониторинг cache_hit_ratio и n_dead_tup, log_min_duration_statement. Документируешь в runbook команды.

Примеры кода

Ключевые параметры postgresql.conf

-- Файл: /etc/postgresql/16/main/postgresql.conf
-- Или: ALTER SYSTEM SET parameter = value;  (запись в postgresql.auto.conf)

-- ═══ ПАМЯТЬ ═══
-- Сервер: 32GB RAM
shared_buffers = '8GB'          -- 25% RAM; требует перезапуска
effective_cache_size = '24GB'   -- 75% RAM; только подсказка планировщику
work_mem = '32MB'               -- на Sort/Hash операцию; умножается на connections × operations
maintenance_work_mem = '1GB'    -- для VACUUM, CREATE INDEX, REINDEX

-- ═══ I/O ═══
random_page_cost = 1.1          -- SSD: 1.1 (дефолт 4.0 для HDD)
                                -- КРИТИЧНО: без этого планировщик избегает Index Scan!
effective_io_concurrency = 200  -- SSD: 200+; HDD: 2

-- ═══ WAL / CHECKPOINT ═══
max_wal_size = '4GB'            -- реже checkpoint = меньше I/O spike (но дольше recovery)
checkpoint_completion_target = 0.9  -- растянуть checkpoint на 90% интервала
wal_compression = on            -- сжатие WAL (CPU vs I/O trade-off)

-- ═══ ПАРАЛЛЕЛИЗМ ═══
max_worker_processes = 8        -- число фоновых воркеров
max_parallel_workers = 8        -- параллельные запросы
max_parallel_workers_per_gather = 4  -- воркеры на один запрос

-- ═══ CONNECTIONS ═══
max_connections = 100           -- с PgBouncer достаточно 50-100
                                -- без PgBouncer: 200-300 максимум

-- ═══ AUTOVACUUM ═══
autovacuum_max_workers = 5      -- больше воркеров для активных БД
autovacuum_vacuum_scale_factor = 0.05    -- триггер при 5% dead tuples (дефолт 20%)
autovacuum_analyze_scale_factor = 0.02   -- ANALYZE при 2% изменений

-- ═══ ЛОГИРОВАНИЕ ═══
log_min_duration_statement = 1000   -- логировать запросы > 1 секунды
log_checkpoints = on                -- логировать checkpoint события

-- Применить hot-reload параметры без перезапуска:
SELECT pg_reload_conf();

-- Проверить текущие значения:
SELECT name, setting, unit, source
FROM pg_settings
WHERE name IN ('shared_buffers', 'work_mem', 'random_page_cost', 'effective_cache_size');

random_page_cost = 1.1 — самая важная настройка для SSD. Дефолтное значение 4.0 заставляет планировщик предпочитать Seq Scan над Index Scan, даже когда Index Scan быстрее. На SSD-сервере эта настройка одна может снизить latency в 5-10x. work_mem умножается: 100 соединений × 5 операций × 32MB = 16GB потенциально. Устанавливай глобально консервативно, для тяжёлых запросов — SET work_mem = '256MB' в сессии.

pg_stat_statements: находим медленные запросы

-- Активация (один раз в postgresql.conf):
-- shared_preload_libraries = 'pg_stat_statements'
-- Затем:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Топ-15 запросов по суммарному времени (самые дорогие по CPU)
SELECT
  round(total_exec_time::numeric / calls, 2)    AS avg_ms,
  calls,
  round(total_exec_time::numeric / 1000, 2)     AS total_sec,
  round(rows::numeric / calls, 0)               AS avg_rows,
  round(100 * total_exec_time /
    sum(total_exec_time) OVER (), 2)            AS pct_of_total,
  left(query, 100)                              AS query
FROM pg_stat_statements
WHERE calls > 100
  AND query NOT LIKE '%pg_%'   -- исключаем системные запросы
ORDER BY total_exec_time DESC
LIMIT 15;

-- Запросы с высоким I/O (много страниц читается с диска)
SELECT
  round(total_exec_time::numeric / calls, 2)  AS avg_ms,
  calls,
  round((shared_blks_read + shared_blks_dirtied)::numeric / calls, 0) AS blks_per_call,
  left(query, 100) AS query
FROM pg_stat_statements
WHERE calls > 50
  AND (shared_blks_read + shared_blks_dirtied) / calls > 1000
ORDER BY (shared_blks_read + shared_blks_dirtied) DESC
LIMIT 10;

-- Cache hit ratio для отдельных запросов
SELECT
  left(query, 80) AS query,
  round(shared_blks_hit * 100.0 /
    NULLIF(shared_blks_hit + shared_blks_read, 0), 1) AS cache_hit_pct,
  calls,
  round(total_exec_time / calls, 1) AS avg_ms
FROM pg_stat_statements
WHERE calls > 100
  AND (shared_blks_hit + shared_blks_read) > 0
ORDER BY cache_hit_pct ASC   -- самые cache-unfriendly запросы
LIMIT 10;

-- Сброс статистики (в начале нагрузочного теста)
SELECT pg_stat_statements_reset();

pct_of_total — доля времени этого запроса от суммарного времени всех запросов. Начинай оптимизацию с запросов с наибольшим pct_of_total — максимальный эффект. Запросы с низким cache_hit_pct читают данные с диска — кандидаты для оптимизации индексами или увеличения shared_buffers. blks_per_call > 1000 означает чтение 8MB+ данных на каждый вызов — вероятно нужен индекс.

Мониторинг bloat, autovacuum и cache hit

-- Глобальный cache hit ratio (должен быть > 99% в продакшне)
SELECT
  sum(blks_hit)  AS total_hits,
  sum(blks_read) AS total_reads,
  round(sum(blks_hit) * 100.0 / NULLIF(sum(blks_hit) + sum(blks_read), 0), 2) AS cache_hit_pct
FROM pg_stat_database
WHERE datname = current_database();

-- Таблицы с bloat: n_dead_tup / n_live_tup > 10% — проблема
SELECT
  schemaname,
  relname AS table_name,
  n_live_tup,
  n_dead_tup,
  round(n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
  pg_size_pretty(pg_total_relation_size(schemaname||'.'||relname)) AS total_size,
  last_autovacuum,
  last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
  OR n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0) > 10
ORDER BY n_dead_tup DESC
LIMIT 20;

-- Настройка autovacuum для конкретной таблицы (активная таблица)
ALTER TABLE orders SET (
  autovacuum_vacuum_scale_factor = 0.01,   -- 1% вместо 20%
  autovacuum_analyze_scale_factor = 0.005, -- 0.5% вместо 10%
  autovacuum_vacuum_cost_delay = 2         -- ms (менее агрессивное throttling)
);

-- Ручной VACUUM если autovacuum не справляется
VACUUM ANALYZE orders;              -- без блокировки, убирает dead tuples
-- VACUUM FULL orders;             -- НЕ ИСПОЛЬЗУЙ в продакшне! Полная блокировка
-- Используй pg_repack для дефрагментации без блокировки:
-- pg_repack -t orders -d mydb

-- Unused indexes: индексы, которые не используются (занимают место и замедляют INSERT)
SELECT
  schemaname,
  tablename,
  indexname,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
  idx_scan AS times_used
FROM pg_stat_user_indexes
JOIN pg_index ON indexrelid = pg_stat_user_indexes.indexrelid
WHERE idx_scan = 0
  AND NOT indisprimary
  AND NOT indisunique
ORDER BY pg_relation_size(indexrelid) DESC;

cache_hit_pct < 95% означает что PostgreSQL часто читает данные с диска — увеличь shared_buffers. dead_pct > 10% — autovacuum не успевает, настрой scale_factor для этой таблицы. Unused indexes с idx_scan = 0 — занимают место и замедляют INSERT/UPDATE. Удаляй их после проверки что они не нужны (убедись что прошло достаточно времени с последнего pg_stat_reset).

Что происходит под капотом

  • Shared buffers работает как LRU кеш страниц данных. При чтении: если страница в shared_buffers (shared_blks_hit) — быстро. Если нет (shared_blks_read) — читается с диска в shared_buffers. 25% RAM оптимально: OS page cache тоже кеширует, двойное кеширование выше ~40% RAM контрпродуктивно.
  • random_page_cost vs seq_page_cost: планировщик использует эти значения для оценки стоимости Index Scan (много случайных чтений) vs Seq Scan (последовательные чтения). На HDD: случайное чтение в 50-100x медленнее последовательного → random_page_cost = 4.0. На SSD: в 2-4x → 1.1. Неправильное значение → планировщик выбирает Seq Scan вместо Index Scan.
  • work_mem используется для in-memory Sort и Hash Join. Если данных больше чем work_mem — PostgreSQL создаёт temporary files на диске (Sort Disk в EXPLAIN). Один сложный запрос может использовать несколько work_mem (несколько Sort/Hash узлов). При 200 соединениях и work_mem=256MB теоретически 51GB — поэтому глобально ставь консервативно.
  • Autovacuum threshold: запуск VACUUM при: n_dead_tup > autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × n_live_tup. С дефолтными настройками (scale_factor = 0.2) для таблицы с 10M строк — VACUUM запускается только при 2M dead tuples. Для активных таблиц снижай scale_factor до 0.01-0.05.
  • PgBouncer pooling modes: session mode — соединение закреплено за клиентом до disconnect (как без пулера). Transaction mode — соединение возвращается в пул после каждой транзакции. Statement mode — после каждого statement (несовместимо с транзакциями). Transaction mode: несовместим с LISTEN/NOTIFY, SET в транзакции, временными таблицами — учитывай при использовании с Rails/ActiveRecord.
  • pg_stat_statements нормализует запросы: WHERE id = 1 и WHERE id = 2 → WHERE id = $1. Это позволяет агрегировать статистику по всем вызовам одного запроса. calls, total_exec_time, rows, shared_blks_hit/read/written — основные метрики.
  • Checkpoint I/O spike: при достижении max_wal_size PostgreSQL выполняет checkpoint — сбрасывает все dirty pages на диск. Это может вызвать I/O spike. checkpoint_completion_target = 0.9 растягивает запись на 90% checkpoint interval — сглаживает нагрузку. max_wal_size больше = реже checkpoint, но дольше восстановление после crash.
  • XID wraparound monitoring: SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY age DESC. Если age > 1.5 миллиарда — опасная зона, PostgreSQL запустит aggressive autovacuum. Если age > 2 миллиарда — PostgreSQL перейдёт в read-only режим для защиты данных. Мониторь этот показатель в production.

Типичные ошибки и заблуждения

  • Ошибка: shared_buffers чем больше тем лучше. OS page cache тоже кеширует данные PostgreSQL. При shared_buffers > 40% RAM PostgreSQL и OS дублируют кеш одних данных. Оптимум — 25-30% RAM; выше контрпродуктивно для большинства workloads.
  • Ошибка: VACUUM FULL решает проблему bloat в продакшне. VACUUM FULL полностью блокирует таблицу на всё время выполнения (минуты или часы). Для дефрагментации без блокировки — используй pg_repack или pg_squeeze. VACUUM FULL только для dev/staging или в окно обслуживания с даунтаймом.
  • Ошибка: все параметры применяются через reload без перезапуска. shared_buffers и max_connections требуют перезапуска PostgreSQL. work_mem, effective_cache_size, log_min_duration_statement применяются через SELECT pg_reload_conf() без перезапуска. Проверяй column pending_restart в pg_settings.
  • Ошибка: мониторинга CPU и памяти сервера достаточно. PostgreSQL-специфичные метрики критически важны: cache hit ratio (если < 95% — проблема), n_dead_tup (если > 20% от live — bloat), lock waits (если есть — contention), replication lag (если replica используется для чтения).
  • Ошибка: увеличение max_connections решает проблему с соединениями. Каждое соединение = fork'd OS process + 5-10MB. 500 соединений = 2.5-5GB только на overhead. Правильное решение: PgBouncer в transaction mode с pool размером 10-50 соединений к PostgreSQL.
  • Ошибка: pg_stat_statements показывает запросы в реальном времени. pg_stat_statements — агрегированная статистика с момента последнего pg_stat_statements_reset(). Для real-time мониторинга используй pg_stat_activity. Для исторических данных — pg_stat_statements.

Ключевые выводы

  • random_page_cost = 1.1 для SSD — важнейшая настройка; без неё планировщик избегает Index Scan.
  • shared_buffers = 25% RAM, effective_cache_size = 75% RAM — база для любого продакшн-сервера.
  • pg_stat_statements → EXPLAIN ANALYZE → индекс или tuning — стандартный цикл оптимизации.
  • PgBouncer transaction mode — обязателен при > 30-50 клиентских соединений.
  • n_dead_tup > 20% таблицы → настрой autovacuum_vacuum_scale_factor для этой таблицы.

Термины урока

shared_buffers: общий кеш страниц данных PostgreSQL в RAM; 25% RAM оптимально.
work_mem: память для Sort и Hash Join операций одного запроса (умножается на операции × соединения).
random_page_cost: стоимость случайного чтения страницы для планировщика (1.1 для SSD).
effective_cache_size: подсказка планировщику о доступном OS кеше; не выделяет реальную память.
Checkpoint: сброс dirty pages из shared_buffers на диск; регулируется max_wal_size.
PgBouncer: connection pooler; transaction mode мультиплексирует клиентские соединения.
Cache hit ratio: shared_blks_hit / (hit + read) — доля данных из кеша; цель > 99%.
pg_repack: расширение для VACUUM FULL без блокировки таблицы.
autovacuum_vacuum_scale_factor: доля dead tuples (от live) для запуска autovacuum.

Связь с работой backend-разработчика

Минимальный продакшн-чеклист PostgreSQL: (1) shared_buffers = 25% RAM; (2) random_page_cost = 1.1 (SSD); (3) effective_cache_size = 75% RAM; (4) CREATE EXTENSION pg_stat_statements + мониторинг ежедневно; (5) PgBouncer в transaction mode; (6) autovacuum_vacuum_scale_factor = 0.01-0.05 для активных таблиц; (7) log_min_duration_statement = 1000; (8) мониторинг cache_hit_ratio и n_dead_tup. Эти 8 пунктов дают 80% выигрыша от tuning.

Мини-разбор реальной ситуации

API сервис: 32GB RAM, SSD, 200 соединений, PostgreSQL с дефолтными настройками. Симптомы: p99 latency 800ms, CPU 30%, высокое disk I/O. Диагностика: shared_buffers = 128MB (0.4% RAM, cache_hit = 78%), random_page_cost = 4.0 (планировщик выбирает Seq Scan), work_mem = 4MB (Sort Disk на каждом запросе). Исправления: shared_buffers=8GB, random_page_cost=1.1, work_mem=64MB, PgBouncer (200→20 реальных соединений). Результат: cache_hit = 99%, p99 latency 80ms (10x улучшение), disk I/O снизился на 90%, memory footprint уменьшился на 1.5GB. Ни одной строки кода не изменено.

Что запомнить

  • random_page_cost=1.1 для SSD — первое что меняй на новом сервере. Без этого планировщик слеп.
  • work_mem × операции × соединения — не ставь выше 64MB глобально. SET локально для тяжёлых запросов.
  • pg_stat_statements_reset() перед нагрузочным тестом — чистая статистика только нового кода.

Итог

Настройка PostgreSQL — это понимание трейдоффов: больше shared_buffers = меньше page fault, но OS cache менее эффективен. Больше work_mem = меньше temp files, но риск OOM при многих соединениях. Правильная конфигурация для SSD-сервера с 32GB RAM даёт 5-10x прироста производительности без изменения кода. Регулярный мониторинг pg_stat_statements + cache hit ratio + n_dead_tup позволяет находить проблемы до того как они становятся инцидентами.