PostgreSQL: тюнинг для продакшена

postgresqldatabaseoptimizationlinux

PostgreSQL из коробки настроен консервативно. Дефолтные параметры рассчитаны на то, чтобы база запустилась на любом железе — хоть на Raspberry Pi с 1 ГБ RAM. Если вы деплоите PostgreSQL на продакшен-сервере с 16-64 ГБ памяти и SSD-дисками, а конфиг не трогали — вы используете от силы 10% возможностей.

За последние три года я оптимизировал PostgreSQL на десятке проектов: от небольших API-сервисов до баз с сотнями гигабайт данных. В этой статье собрал практические рекомендации по тюнингу, которые дают реальный результат.

Серверная комната: железо, на котором крутятся базы данных

postgresql.conf: параметры памяти

Начнём с самого важного — распределения памяти. Три ключевых параметра определяют, как PostgreSQL использует RAM.

shared_buffers

Это основной буферный кэш PostgreSQL — область разделяемой памяти, где хранятся страницы данных. Чтение из shared_buffers на порядки быстрее, чем с диска.

Правило: 25% от общего объёма RAM сервера. Больше 40% ставить не имеет смысла — PostgreSQL также полагается на кэш файловой системы (page cache ОС), и забирая всю память под shared_buffers, вы лишаете ОС возможности кэшировать файлы.

# Для сервера с 32 ГБ RAM
shared_buffers = 8GB

# Для сервера с 16 ГБ RAM
shared_buffers = 4GB

# Для VPS с 4 ГБ RAM
shared_buffers = 1GB

После изменения shared_buffers требуется перезапуск PostgreSQL.

work_mem

Память, выделяемая на каждую операцию сортировки, хэширования или объединения в рамках одного запроса. Один сложный запрос с несколькими JOIN и ORDER BY может использовать work_mem многократно.

Формула для расчёта: (Total RAM - shared_buffers) / (max_connections * 2-3)

# Для сервера с 32 ГБ, 200 соединений:
# (32GB - 8GB) / (200 * 3) = ~40MB
work_mem = 40MB

# Для VPS с 4 ГБ, 50 соединений:
# (4GB - 1GB) / (50 * 3) = ~20MB
work_mem = 20MB

Осторожно: если work_mem слишком большой, при всплеске нагрузки (много параллельных сложных запросов) сервер может уйти в OOM. Лучше начать с консервативного значения и увеличивать по необходимости.

Можно задавать work_mem на уровне отдельной сессии для тяжёлых аналитических запросов:

SET work_mem = '256MB';
-- Тяжёлый аналитический запрос
SELECT ... FROM large_table GROUP BY ... ORDER BY ...;
RESET work_mem;

effective_cache_size

Это не выделение памяти — это подсказка планировщику запросов о том, сколько памяти доступно для кэширования (shared_buffers + page cache ОС). Планировщик использует это значение для выбора между index scan и sequential scan.

Правило: 50-75% от общего объёма RAM.

# Для сервера с 32 ГБ
effective_cache_size = 24GB

# Для VPS с 4 ГБ
effective_cache_size = 3GB

Не требует перезапуска, применяется через SELECT pg_reload_conf(); или systemctl reload postgresql.

Остальные важные параметры

# Память для операций обслуживания (VACUUM, CREATE INDEX, ALTER TABLE)
# Можно ставить значительно больше work_mem — эти операции не параллелятся
maintenance_work_mem = 2GB

# WAL (Write-Ahead Log) — буферы журналирования
wal_buffers = 64MB

# Checkpoint: как часто PostgreSQL сбрасывает данные на диск
checkpoint_completion_target = 0.9
max_wal_size = 4GB
min_wal_size = 1GB

# Планировщик: стоимость случайного чтения с диска
# Для SSD значение должно быть ниже, чем дефолтное 4.0
random_page_cost = 1.1  # SSD
# random_page_cost = 4.0  # HDD (по умолчанию)

# Количество параллельных воркеров для запросов
max_parallel_workers_per_gather = 4
max_parallel_workers = 8
max_worker_processes = 8

EXPLAIN ANALYZE: понимаем, что делает запрос

Прежде чем оптимизировать запросы, нужно понять, как PostgreSQL их выполняет. EXPLAIN ANALYZE — ваш главный инструмент.

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at >= '2025-01-01'
GROUP BY u.name
ORDER BY order_count DESC
LIMIT 20;

Пример вывода:

Limit  (cost=1543.21..1543.26 rows=20 width=44) (actual time=245.123..245.130 rows=20 loops=1)
  Buffers: shared hit=892 read=2341
  ->  Sort  (cost=1543.21..1567.32 rows=9644 width=44) (actual time=245.120..245.125 rows=20 loops=1)
        Sort Key: (count(o.id)) DESC
        Sort Method: top-N heapsort  Memory: 27kB
        ->  HashAggregate  (cost=1287.45..1383.89 rows=9644 width=44) (actual time=234.567..240.123 rows=9644 loops=1)
              ->  Hash Right Join  (cost=412.33..1190.22 rows=19456 width=40) (actual time=12.345..189.012 rows=19456 loops=1)
                    Hash Cond: (o.user_id = u.id)
                    ->  Seq Scan on orders o  (cost=0.00..621.56 rows=30156 width=8) (actual time=0.012..67.890 rows=30156 loops=1)
                    ->  Hash  (cost=287.44..287.44 rows=9991 width=36) (actual time=12.234..12.234 rows=9644 loops=1)
                          ->  Seq Scan on users u  (cost=0.00..287.44 rows=9991 width=36) (actual time=0.023..8.456 rows=9644 loops=1)
                                Filter: (created_at >= '2025-01-01')
                                Rows Removed by Filter: 15356
Planning Time: 0.234 ms
Execution Time: 245.456 ms

На что обращать внимание:

  • Seq Scan на большой таблице с фильтрацией — кандидат на индекс.
  • shared read в Buffers — данные читались с диска, а не из кэша. Если значение большое, возможно, не хватает shared_buffers или кэша ОС.
  • actual time значительно больше cost — планировщик ошибается в оценке. Обновите статистику: ANALYZE table_name.
  • Rows Removed by Filter — если удаляется большая доля строк, нужен индекс.

Индексы: B-tree, GIN, GiST

Правильные индексы — это основа производительности. Но лишние индексы замедляют запись и занимают место. Каждый индекс должен быть обоснован.

B-tree (по умолчанию)

Подходит для операций сравнения: =, !=, больше, меньше, BETWEEN, IN, IS NULL. Это 90% случаев.

-- Индекс для фильтрации по дате
CREATE INDEX idx_orders_created_at ON orders (created_at);

-- Составной индекс: порядок колонок ВАЖЕН
-- Этот индекс работает для WHERE user_id = X AND status = Y
-- и для WHERE user_id = X (без status)
-- но НЕ работает для WHERE status = Y (без user_id)
CREATE INDEX idx_orders_user_status ON orders (user_id, status);

-- Частичный индекс: только для активных заказов
-- Занимает меньше места, работает быстрее
CREATE INDEX idx_orders_active ON orders (user_id, created_at)
WHERE status = 'active';

-- Covering index: включаем данные прямо в индекс
-- PostgreSQL может ответить на запрос, не обращаясь к таблице (Index Only Scan)
CREATE INDEX idx_orders_covering ON orders (user_id) INCLUDE (total_amount, status);

GIN (Generalized Inverted Index)

Для полнотекстового поиска, массивов и JSONB:

-- Полнотекстовый поиск
CREATE INDEX idx_articles_fts ON articles
USING GIN (to_tsvector('russian', title || ' ' || body));

-- Поиск по JSONB
CREATE INDEX idx_events_data ON events USING GIN (data);

-- Поиск по jsonb с определёнными операциями
CREATE INDEX idx_events_data_path ON events USING GIN (data jsonb_path_ops);

GIN-индексы медленнее обновляются, но быстрее ищут. Подходят для колонок, которые чаще читаются, чем пишутся.

GiST (Generalized Search Tree)

Для геоданных (PostGIS), диапазонов и нечёткого поиска:

-- Геопоиск (PostGIS)
CREATE INDEX idx_locations_geo ON locations USING GiST (coordinates);

-- Поиск по диапазонам дат
CREATE INDEX idx_bookings_period ON bookings USING GiST (daterange(check_in, check_out));

-- Поиск похожих строк (pg_trgm)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_products_name_trgm ON products USING GiST (name gist_trgm_ops);

Поиск неиспользуемых индексов

Индексы, которые не используются, нужно удалять — они замедляют INSERT/UPDATE/DELETE и занимают место:

SELECT
    schemaname,
    tablename,
    indexname,
    idx_scan,
    pg_size_pretty(pg_relation_size(indexrelid)) as index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexrelid NOT IN (
      SELECT conindid FROM pg_constraint
      WHERE contype IN ('p', 'u')  -- исключаем PK и UNIQUE constraints
  )
ORDER BY pg_relation_size(indexrelid) DESC;

Терминал с SQL-запросами: анализ и оптимизация PostgreSQL

VACUUM и autovacuum

PostgreSQL использует MVCC (Multi-Version Concurrency Control): при обновлении строки старая версия не удаляется, а помечается как «мёртвая». VACUUM очищает эти мёртвые строки и возвращает пространство для переиспользования.

Почему VACUUM критически важен

Без VACUUM:

  • Таблица и индексы растут бесконтрольно (table bloat).
  • Запросы замедляются — приходится сканировать мёртвые строки.
  • Transaction ID wraparound — при исчерпании 32-битного счётчика транзакций PostgreSQL принудительно остановится. Это не теоретическая проблема — я видел это в продакшене.

Настройка autovacuum

Autovacuum работает по умолчанию, но его параметры часто нужно тюнить:

# Как часто autovacuum проверяет таблицы (мс)
autovacuum_naptime = 30s  # дефолт: 1min

# Порог запуска: мёртвых строк больше, чем
# autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * кол-во строк
autovacuum_vacuum_threshold = 50        # дефолт: 50
autovacuum_vacuum_scale_factor = 0.05   # дефолт: 0.2 (20%)

# Для таблицы в 1 млн строк:
# Дефолт: vacuum запустится после 200 050 мёртвых строк (20%)
# Тюнинг: vacuum запустится после 50 050 мёртвых строк (5%)

# Количество параллельных воркеров autovacuum
autovacuum_max_workers = 4  # дефолт: 3

# Ограничение нагрузки autovacuum на I/O
autovacuum_vacuum_cost_limit = 1000  # дефолт: -1 (используется vacuum_cost_limit = 200)

Для «горячих» таблиц с интенсивной записью можно задать параметры индивидуально:

ALTER TABLE hot_events SET (
    autovacuum_vacuum_scale_factor = 0.01,
    autovacuum_vacuum_threshold = 100,
    autovacuum_analyze_scale_factor = 0.005
);

Мониторинг VACUUM

-- Последний запуск VACUUM и ANALYZE по таблицам
SELECT
    schemaname,
    relname,
    last_vacuum,
    last_autovacuum,
    last_analyze,
    last_autoanalyze,
    n_dead_tup,
    n_live_tup,
    CASE WHEN n_live_tup > 0
         THEN round(100.0 * n_dead_tup / n_live_tup, 1)
         ELSE 0
    END as dead_ratio_pct
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;

Если dead_ratio_pct для таблицы стабильно больше 10-20% — autovacuum не справляется. Увеличьте autovacuum_vacuum_cost_limit или уменьшите autovacuum_vacuum_scale_factor.

pg_stat_statements: мониторинг запросов

Расширение pg_stat_statements — обязательный инструмент для любого продакшена. Оно собирает статистику по всем выполненным запросам: время, количество вызовов, блокировки, I/O.

-- Включение расширения
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

В postgresql.conf:

shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 10000
pg_stat_statements.track = all

После перезапуска PostgreSQL:

-- Топ-20 самых медленных запросов (по суммарному времени)
SELECT
    calls,
    round(total_exec_time::numeric, 2) as total_time_ms,
    round(mean_exec_time::numeric, 2) as avg_time_ms,
    round(stddev_exec_time::numeric, 2) as stddev_ms,
    rows,
    query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

-- Запросы с наибольшим количеством чтений с диска
SELECT
    calls,
    shared_blks_hit,
    shared_blks_read,
    round(100.0 * shared_blks_hit / NULLIF(shared_blks_hit + shared_blks_read, 0), 2) as cache_hit_pct,
    query
FROM pg_stat_statements
WHERE shared_blks_read > 0
ORDER BY shared_blks_read DESC
LIMIT 20;

Целевой показатель: cache hit ratio больше 99% для OLTP-нагрузки. Если меньше — не хватает памяти или неоптимальные запросы сканируют слишком много данных.

-- Общий cache hit ratio по базе
SELECT
    sum(blks_hit) as cache_hits,
    sum(blks_read) as disk_reads,
    round(100.0 * sum(blks_hit) / NULLIF(sum(blks_hit) + sum(blks_read), 0), 2) as cache_hit_ratio
FROM pg_stat_database
WHERE datname = current_database();

Периодически сбрасывайте статистику, чтобы видеть картину за определённый период:

SELECT pg_stat_statements_reset();

Connection pooling с PgBouncer

PostgreSQL создаёт отдельный процесс (fork) на каждое соединение. При 500+ соединениях это становится проблемой: каждый процесс потребляет ~5-10 МБ RAM, context switching нагружает CPU.

PgBouncer решает эту проблему, работая как прокси между приложением и PostgreSQL:

; /etc/pgbouncer/pgbouncer.ini

[databases]
myapp = host=127.0.0.1 port=5432 dbname=myapp

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

; Режим пулинга
pool_mode = transaction

; Лимиты соединений
max_client_conn = 1000     ; максимум клиентских соединений к PgBouncer
default_pool_size = 25     ; соединений к PostgreSQL на базу/пользователя
min_pool_size = 5          ; минимум соединений в пуле
reserve_pool_size = 5      ; резерв для пиковой нагрузки
reserve_pool_timeout = 3   ; через сколько секунд использовать резерв

; Таймауты
server_idle_timeout = 600
client_idle_timeout = 0
query_timeout = 30

; Логирование
log_connections = 0
log_disconnections = 0
stats_period = 60

Файл аутентификации /etc/pgbouncer/userlist.txt:

"myapp_user" "md5hash_of_password"

Хэш можно получить так:

SELECT 'md5' || md5('password' || 'myapp_user');

Три режима пулинга:

  • session — соединение привязано к клиенту до отключения. Безопасно, но экономит мало ресурсов.
  • transaction — соединение возвращается в пул после завершения транзакции. Лучший баланс для большинства приложений.
  • statement — соединение возвращается после каждого запроса. Максимальная эффективность, но не работает с multi-statement транзакциями и prepared statements.

Запуск и мониторинг:

# Запуск PgBouncer
sudo systemctl start pgbouncer
sudo systemctl enable pgbouncer

# Подключение к консоли администрирования
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer

# В консоли PgBouncer:
SHOW STATS;
SHOW POOLS;
SHOW CLIENTS;
SHOW SERVERS;

После настройки PgBouncer приложение подключается к порту 6432 вместо 5432. PostgreSQL max_connections можно снизить до 50-100 (вместо дефолтных 100), а PgBouncer обработает тысячи клиентских соединений.

Бэкапы: pg_dump и pg_basebackup

Без бэкапов всё остальное не имеет смысла. Два основных инструмента:

pg_dump — логический бэкап

Создаёт SQL-дамп или архив базы. Подходит для баз до нескольких сотен гигабайт.

# Дамп в custom format (сжатый, поддерживает параллельный restore)
pg_dump -Fc -Z 6 -j 4 -f /backups/myapp_$(date +%Y%m%d_%H%M%S).dump myapp

# -Fc: custom format
# -Z 6: уровень сжатия (0-9)
# -j 4: параллельный дамп (4 потока)

# Восстановление
pg_restore -d myapp -j 4 --clean --if-exists /backups/myapp_20250604.dump

Скрипт для автоматических бэкапов через cron:

#!/bin/bash
# /opt/scripts/pg_backup.sh

BACKUP_DIR="/backups/postgresql"
RETENTION_DAYS=14
DB_NAME="myapp"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

mkdir -p "$BACKUP_DIR"

# Создаём бэкап
pg_dump -Fc -Z 6 -j 4 -f "$BACKUP_DIR/${DB_NAME}_${TIMESTAMP}.dump" "$DB_NAME" 2>&1

if [ $? -eq 0 ]; then
    echo "Backup completed: ${DB_NAME}_${TIMESTAMP}.dump"
    # Удаляем старые бэкапы
    find "$BACKUP_DIR" -name "${DB_NAME}_*.dump" -mtime +$RETENTION_DAYS -delete
else
    echo "ERROR: Backup failed!" >&2
    exit 1
fi
# Crontab: ежедневно в 3:00
0 3 * * * /opt/scripts/pg_backup.sh >> /var/log/pg_backup.log 2>&1

pg_basebackup — физический бэкап

Копирует весь кластер PostgreSQL на уровне файлов. Быстрее для больших баз, необходим для настройки репликации и Point-in-Time Recovery (PITR).

# Полный бэкап кластера
pg_basebackup -D /backups/base_$(date +%Y%m%d) 
    -Ft -z -Xs 
    -P -v 
    -h localhost -U replicator

# -Ft: формат tar
# -z: gzip-сжатие
# -Xs: стриминг WAL во время бэкапа (консистентность без архивирования WAL)
# -P: прогресс-бар

Для PITR (восстановление на произвольный момент времени) нужно настроить архивирование WAL:

# postgresql.conf
archive_mode = on
archive_command = 'cp %p /backups/wal_archive/%f'

Восстановление на конкретный момент:

# В recovery.conf (PostgreSQL до 12) или postgresql.conf (12+)
restore_command = 'cp /backups/wal_archive/%f %p'
recovery_target_time = '2025-06-04 14:30:00'
recovery_target_action = 'promote'

Чек-лист для продакшена

Сводка параметров для сервера с 32 ГБ RAM и SSD (адаптируйте под своё железо):

# Память
shared_buffers = 8GB
work_mem = 40MB
maintenance_work_mem = 2GB
effective_cache_size = 24GB
wal_buffers = 64MB

# Диск и WAL
random_page_cost = 1.1
effective_io_concurrency = 200
max_wal_size = 4GB
min_wal_size = 1GB
checkpoint_completion_target = 0.9

# Параллелизм
max_parallel_workers_per_gather = 4
max_parallel_workers = 8
max_worker_processes = 8

# Autovacuum
autovacuum_vacuum_scale_factor = 0.05
autovacuum_analyze_scale_factor = 0.025
autovacuum_vacuum_cost_limit = 1000
autovacuum_max_workers = 4

# Мониторинг
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 10000
pg_stat_statements.track = all

# Логирование медленных запросов
log_min_duration_statement = 500
log_checkpoints = on
log_lock_waits = on
log_temp_files = 0

Итог

Оптимизация PostgreSQL — это не одноразовая настройка, а непрерывный процесс. Начните с правильных параметров памяти и дисковой подсистемы, включите pg_stat_statements для мониторинга, настройте autovacuum под вашу нагрузку. Используйте EXPLAIN ANALYZE для каждого медленного запроса. Поставьте PgBouncer, если соединений больше ста. И обязательно — обязательно — настройте бэкапы до того, как они понадобятся. PostgreSQL отблагодарит вас стабильной работой и предсказуемой производительностью.

© 2026 Terminal Notes. Built with SvelteKit.