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