1. Профилирование производительности: не догадки, а данные
Перед тем как менять что-либо, нужно понять, где именно «горит».
- EXPLAIN (ANALYZE, BUFFERS)
- Цель: получить детальное расписание выполнения запроса с учётом фактического времени и использования буферов.
- Пример:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT u.id, u.name, o.total_amount FROM users u JOIN orders o ON o.user_id = u.id WHERE u.status = 'active' AND o.created_at > '2024-01-01' ORDER BY o.created_at DESC LIMIT 100;
- На что смотреть:
- Planning Time / Execution Time — если время планирования существенно, то причина может быть в сложном оптимизаторе (например, много статистических леген, триггеров).
- Buffers: shared hit / read / dirtied — сколько блоков из shared_buffers, сколько с диска. Большое число read может означать отсутствие индекса или неправильный тип сканирования.
- pg_stat_statements
- Убедитесь, что расширение включено (
shared_preload_libraries = 'pg_stat_statements'
), и настроена адекватнаяpg_stat_statements.max
иpg_stat_statements.track
. - Основная идея: агрегировать информацию о наиболее «тяжёлых» запросах.
- Пример выборки «топ-10» по времени:
SELECT query, calls, total_time, mean_time, rows FROM pg_stat_statements ORDER BY total_time DESC LIMIT 10;
- Совет: обратите внимание на высокую
calls
+ высокийmean_time
— такие запросы можно оптимизировать, кешируя результаты или перерабатывая логику приложения.
- Убедитесь, что расширение включено (
- auto_explain
- Если нужно профилировать запросы внутри транзакций или в критичных секциях приложения, можно включить расширение
auto_explain
в конфигурации. - В
postgresql.conf
:shared_preload_libraries = 'auto_explain' auto_explain.log_min_duration = '500ms' # логировать планы запросов дольше 500 мс auto_explain.log_analyze = on # добавить фактическую статистику auto_explain.log_buffers = on auto_explain.log_verbose = on
- После перезапуска PostgreSQL все «тяжёлые» запросы будут попадать в лог, что упрощает поиск «узких мест» без вмешательства в код.
- Если нужно профилировать запросы внутри транзакций или в критичных секциях приложения, можно включить расширение
2. План выполнения: глубокий анализ и вмешательство
Как только вы получили план, нужно уметь его «читать» и исправлять:
- Идентификация узких мест
- Seq Scan vs Index Scan
- Если план показывает последовательное сканирование (
Seq Scan
) на большой таблице, а вы ожидаете индексный, значит:- Либо отсутствует подходящий индекс.
- Либо статистика показывает низкую селективность.
- Либо параметр
effective_cache_size
илиrandom_page_cost
настроен некорректно.
- Если план показывает последовательное сканирование (
- Nested Loop vs Hash Join vs Merge Join
- Nested Loop эффективен, если одна из таблиц очень маленькая. Если обе большие — меняем стратегию.
- Hash Join хорош при больших таблицах с равномощными соединениями, но требует выделения памяти для построения хеша (
work_mem
). - Merge Join работает, когда обе таблицы уже отсортированы по ключу (или имеют подходящие индексы).
- Seq Scan vs Index Scan
- Коррекция ключевых параметров конфигурации
- work_mem
- Определяет размер памяти для сортировок и хеш-таблиц.
- Если
Hash Join
переключается наDisk
, т.е. «похоронный» Spill, увеличитьwork_mem
:work_mem = '64MB'
- Для OLAP-нагрузок делают
work_mem = 128MB
–256MB
, но внимательно следят, чтобы не исчерпать всю RAM при параллельных соединениях.
- maintenance_work_mem
- Влияет на скорость построения индексов и
VACUUM
. Увеличьте дляCREATE INDEX CONCURRENTLY
на больших таблицах:maintenance_work_mem = '512MB'
- Влияет на скорость построения индексов и
- effective_cache_size
- Оценка того, сколько памяти ОС и база кешируют. Неверная настройка (слишком маленькая) приведёт к тому, что оптимизатор будет «боязливым» к индексным сканам, думая, что надо читать с диска:
effective_cache_size = '24GB' # если у вас 32GB RAM
- Оценка того, сколько памяти ОС и база кешируют. Неверная настройка (слишком маленькая) приведёт к тому, что оптимизатор будет «боязливым» к индексным сканам, думая, что надо читать с диска:
- work_mem
- Параллелизм
- max_parallel_workers_per_gather
- Определяет, сколько воркеров можно задействовать для одного запроса.
- Если план показывает
Gather
с 0 воркеров, повысьте до, например,4
:max_parallel_workers_per_gather = 4
- parallel_tuple_cost / parallel_setup_cost
- Регулируют пороги, когда PostgreSQL решает использовать параллельный воркер. Если ваши запросы «стесняются» и не распараллеливаются, понизьте эти параметры:
parallel_setup_cost = 1000 parallel_tuple_cost = 0.1
- Регулируют пороги, когда PostgreSQL решает использовать параллельный воркер. Если ваши запросы «стесняются» и не распараллеливаются, понизьте эти параметры:
- Совет: для аналитических запросов (большие агрегации) параллелизм часто критичен. Но учтите, что слишком большое число параллельных воркеров для нескольких запросов одновременно может перегрузить I/O подсистему.
- max_parallel_workers_per_gather
3. Индексы: beyond B-tree
Профи понимают, что стандартных B-tree не всегда достаточно.
- BRIN (Block Range Indexes)
- Идеально для «упорядоченных»» данных, таких как дата/время.
- Пример: время регистрации рядов, журналы (логирование).
- Создание индекса:
CREATE INDEX idx_events_ts_brin ON events USING BRIN(created_at);
- Когда применять:
- Таблицы с миллиардами строк, где данные поступают в хронологическом порядке.
- Запросы по диапазону дат покрываются BRIN намного быстрее и экономичнее по объёму, чем B-tree.
- GIN с Bloom-фильтрами для JSONB
- Для JSONB-полей можно добавлять GIN-индексы с Bloom-приёмом, чтобы обходить ограничение стандартных GIN, когда структура документов разнится.
- Пример создания:
CREATE EXTENSION IF NOT EXISTS bloom; CREATE INDEX idx_payload_bloom ON audit_logs USING GIN (to_tsvector('simple', log_payload::text) bloom_ops);
- Зачем:
- Если у вас JSONB с произвольными ключами, а вы хотите искать ключи и значения без создания GIN на каждом пути.
- Partial Index (частичные индексы)
- Позволяют создавать индекс только на подмножестве строк, например, статус
active
или часто используемые категории. - Пример:
CREATE INDEX idx_orders_recent_active ON orders(created_at) WHERE status = 'active' AND created_at > now() - interval '30 days';
- Преимущество: индекс меньше, обновляется быстрее, и идеально подходит для OLTP-сценариев с большой долей «старых» записей, которые редко запрашиваются.
- Позволяют создавать индекс только на подмножестве строк, например, статус
- Covering Index (INCLUDE)
- Чтобы полностью избежать обращения к «хипу» (heap), добавляете в индекс все столбцы, необходимые для запроса:
CREATE INDEX idx_users_order_on_total ON orders(user_id, created_at) INCLUDE (total_amount, order_status);
- Важно: для OLTP-систем стоит очень внимательно отбирать, какие столбцы включать: чем больше INCLUDE, тем больше размер индекса.
- Чтобы полностью избежать обращения к «хипу» (heap), добавляете в индекс все столбцы, необходимые для запроса:
4. Умные техники партиционирования и кластеризации
Профессионалы знают, что на уровне большого объёма данных важны архитектурные подходы:
- Партиционирование (Partitioning)
- Range Partitioning по дате: стандарт для временных рядов.
CREATE TABLE measurements ( id BIGSERIAL PRIMARY KEY, device_id INT NOT NULL, ts TIMESTAMP NOT NULL, reading DOUBLE PRECISION ) PARTITION BY RANGE (ts); CREATE TABLE measurements_2025_01 PARTITION OF measurements FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); CREATE TABLE measurements_2025_02 PARTITION OF measurements FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
- Hash Partitioning для равномерного распределения по ключу, например,
user_id
:CREATE TABLE logs ( id BIGSERIAL PRIMARY KEY, user_id INT NOT NULL, action TEXT, ts TIMESTAMP NOT NULL ) PARTITION BY HASH (user_id); -- создаём 8 партиций CREATE TABLE logs_p0 PARTITION OF logs FOR VALUES WITH (MODULUS 8, REMAINDER 0); CREATE TABLE logs_p1 PARTITION OF logs FOR VALUES WITH (MODULUS 8, REMAINDER 1); -- … и так далее до REMAINDER 7
- Практический совет:
- Выбирайте стратегию партиционирования в зависимости от шаблона запросов: если чаще фильтруют по дате — range; по ID или UUID — hash.
- Следите за тем, чтобы новые партиции создавались своевременно (например, с помощью
pg_cron
или другого шедулера).
- Range Partitioning по дате: стандарт для временных рядов.
- Кластеризация (CLUSTER)
- Операция переупорядочивает физический порядок строк в таблице по указанному индексу, что может радикально ускорить диапазонные сканирования:
CLUSTER orders USING idx_orders_created_at;
- Минусы:
- Операция блокирующая — не делать на «онлайн» таблицах без периодического окна обслуживания.
- Со временем данные рассредоточиваются, и нужно периодически повторять
CLUSTER
илиREINDEX
.
- Операция переупорядочивает физический порядок строк в таблице по указанному индексу, что может радикально ускорить диапазонные сканирования:
- VACUUM и AUTOVACUUM
- Даже профи часто забывают про оптимальные настройки автовакаума:
- autovacuum_vacuum_threshold / autovacuum_vacuum_scale_factor — для больших таблиц понизьте
autovacuum_vacuum_scale_factor
(например, 0.01 вместо 0.2), чтобы вакуум запускался чаще. - autovacuum_max_workers — при большом количестве критичных таблиц увеличьте число воркеров.
- maintenance_work_mem для VACUUM при большом объёме ускорит работу.
- autovacuum_vacuum_threshold / autovacuum_vacuum_scale_factor — для больших таблиц понизьте
- Примечание: если у вас таблицы с FIFO-логами, можно настроить
autovacuum_freeze_max_age
ниже, чтобы избежать wraparound.
- Даже профи часто забывают про оптимальные настройки автовакаума:
5. Расширенные приёмы оптимизации на уровне SQL
- Common Table Expressions (CTE) vs Inline Views
- До PostgreSQL 12 CTE рассматривались как «optimization fence» (барьер оптимизации). Теперь их можно «INLINE» при использовании
MATERIALIZED
илиNOT MATERIALIZED
. - Совет: если CTE выступает «барьером» и мешает фьюзингу подзапросов, используйте синтаксис:
WITH t AS MATERIALIZED ( SELECT … ) SELECT … FROM t JOIN …;
- Для сложных агрегатов часто выгоднее сразу писать подзапрос в
FROM
, чем отдельно CTE.
- До PostgreSQL 12 CTE рассматривались как «optimization fence» (барьер оптимизации). Теперь их можно «INLINE» при использовании
- Window Functions
- Иногда можно заменить «тяжёлые» самосоединения. Например, считать ранги по дате:
SELECT user_id, ts, reading, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) AS rn FROM measurements WHERE ts > now() - interval '7 days';
- Совет: после применения оконных функций, если нужно выбрать только первую строчку на партиционированный ключ, используйте фильтр
WHERE rn = 1
в подзапросе, а не оборачивать весь результат в ещё один SELECT.
- Иногда можно заменить «тяжёлые» самосоединения. Например, считать ранги по дате:
- Upsert с ON CONFLICT (DO UPDATE)
- Для высоконагруженных OLTP-систем часто встречаются классы «конкурентных вставок». Правильное использование
ON CONFLICT
может избавить от блокировок:INSERT INTO inventory (product_id, warehouse_id, quantity) VALUES (123, 456, 10) ON CONFLICT (product_id, warehouse_id) DO UPDATE SET quantity = inventory.quantity + EXCLUDED.quantity;
- Трюк: если нужно вернуть различные столбцы при обновлении или вставке, используйте
RETURNING
:INSERT INTO users (id, email, last_seen) VALUES (42, '[email protected]', now()) ON CONFLICT (id) DO UPDATE SET last_seen = now() RETURNING id, (xmax = 0) AS inserted; -- inserted = true, если именно вставка
- Для высоконагруженных OLTP-систем часто встречаются классы «конкурентных вставок». Правильное использование
- Динамическое SQL на уровне PL/pgSQL
- Для партиционированных таблиц с большим числом партиций можно генерить запросы динамически, чтобы сразу «нацелиться» на нужную партицию:
CREATE OR REPLACE FUNCTION insert_measurement(p_device INT, p_ts TIMESTAMP, p_reading FLOAT) RETURNS VOID AS $$ DECLARE partition_name TEXT := format('measurements_%s_%s', to_char(p_ts, 'YYYY'), to_char(p_ts, 'MM')); BEGIN EXECUTE format('INSERT INTO %I (device_id, ts, reading) VALUES ($1, $2, $3)', partition_name) USING p_device, p_ts, p_reading; END; $$ LANGUAGE plpgsql;
- Совет: держите маппинг партиций в отдельной справочной таблице, а не в
IF
-цепочке, чтобы легко добавлять новые партиции без правки кода.
- Для партиционированных таблиц с большим числом партиций можно генерить запросы динамически, чтобы сразу «нацелиться» на нужную партицию:
6. Архитектурные паттерны и практический пример
Сценарий: микросервис «Пользовательская активность»
- Хранит логи действий пользователей:
activity_logs(user_id, action, ts, metadata JSONB)
. - Нагрузка: ~50 млн записей в месяц, ежедневный отчёт по уникальным действиям и топ-10 пользователях по активности.
Проблемы “по умолчанию”:
- Все данные в одну таблицу, и запросы за последние 7 дней тормозят из-за огромного объёма.
- Индекс
IX_activity_logs_ts
наts
растёт до нескольких десятков гигабайт, а при вставках часто ломаетshared_buffers
.
Решения “для профи”:
- Партиционирование по диапазону дат
- Создать таблицу-шаблон
activity_logs_parent PARTITION BY RANGE (ts)
. - Автоматизировать создание партиций раз в месяц через
pg_cron
:-- Функция для создания партиции CREATE OR REPLACE FUNCTION create_monthly_partition() RETURNS VOID AS $$ DECLARE start_date DATE := date_trunc('month', now()); end_date DATE := (start_date + INTERVAL '1 month'); partition_name TEXT := format('activity_logs_%s', to_char(start_date, 'YYYY_MM')); BEGIN EXECUTE format( 'CREATE TABLE IF NOT EXISTS %I PARTITION OF activity_logs_parent FOR VALUES FROM (''%s'') TO (''%s'')', partition_name, start_date, end_date ); END; $$ LANGUAGE plpgsql; -- Настроить ежемесячный запуск SELECT cron.schedule('0 0 1 * *', $$SELECT create_monthly_partition();$$);
- Создать таблицу-шаблон
- Частичный индекс на «горячий» диапазон
- Для ежедневных отчётов создаём частичный индекс только на последние 7 дней:
CREATE INDEX idx_activity_recent ON activity_logs_parent (user_id, action) WHERE ts > now() - INTERVAL '7 days';
- Это позволяет ускорить запросы типа:
SELECT user_id, COUNT(*) AS cnt FROM activity_logs_parent WHERE ts BETWEEN now() - INTERVAL '7 days' AND now() GROUP BY user_id ORDER BY cnt DESC LIMIT 10;
- Для ежедневных отчётов создаём частичный индекс только на последние 7 дней:
- Материализованный вид для агрегаций
- Создаём MV для кумулятивных агрегатов раз в час:
CREATE MATERIALIZED VIEW mv_daily_activity AS SELECT date_trunc('day', ts) AS day, user_id, COUNT(*) AS actions_count FROM activity_logs_parent GROUP BY 1, 2;
- Настраиваем
REFRESH MATERIALIZED VIEW CONCURRENTLY
с помощьюcron
каждые 60 минут:SELECT cron.schedule('0 * * * *', $$REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_activity;$$);
- Результат: экспресс-доступ к агрегированным данным без нагрузки на «сырой» лог.
- Создаём MV для кумулятивных агрегатов раз в час:
- Оптимизация JSONB-операций
- Вместо операций
WHERE metadata->>'key' = 'value'
используем GIN-индексы:CREATE INDEX idx_activity_metadata_gin ON activity_logs_parent USING GIN (metadata jsonb_path_ops);
- Для сложных фильтров по JSON-документам можно выносить часто используемые поля в отдельные столбцы (с дублированием данных) и индексировать их B-tree. Это ускоряет сравнения и
JOIN
по ним.
- Вместо операций
7. Мониторинг и поддержка на «зрелом» этапе
- pg_stat_user_tables / pg_stat_user_indexes
- Регулярно проверяйте
pg_stat_user_indexes.idx_scan
vsidx_tup_read
vsidx_tup_fetch
. Еслиidx_scan
≫ 0, ноidx_tup_fetch / idx_tup_read
близко к 1, возможно, индекс «читается» много, но попаданий мало — бесполезные запросы.
- Регулярно проверяйте
- pg_stat_activity и pg_locks
- Мониторьте долгие транзакции, которые могут приводить к «bloat» (раздуванию) таблиц.
- Пример выборки «долгих» транзакций (более 5 минут):
SELECT pid, usename, query, now() - query_start AS duration FROM pg_stat_activity WHERE state = 'active' AND now() - query_start > interval '5 minutes';
- Если находите внезапно возросшее число блокировок на
pg_locks
, ищите причину в «схлопнутых» OLTP-запросах или в DDL-операциях, которые не были завершены.
- Автоматизация наблюдения
- Используйте PgWatch2, Check_pgactivity, или Zabbix/Prometheus + pg_exporter. Важно строить SLA-ориентированный мониторинг:
- Латентность 99-го процентиля запросов (p99 latency).
- Количество бэквенд-воркеров, ожидающих блокировки (
waiting = true
). - Показатели автовакаума:
num_dead_tuples / n_live_tuples
.
- Используйте PgWatch2, Check_pgactivity, или Zabbix/Prometheus + pg_exporter. Важно строить SLA-ориентированный мониторинг:
- Периодическая реорганизация
- VACUUM FULL или CLUSTER лучше проводить в «период окна» (maintenance window). Для минимального воздействия:
- Выгрузить данные в новую таблицу с нужной сортировкой, затем переключить
ALTER TABLE … RENAME
. - Для больших таблиц можно использовать
pg_repack
(он работает онлайн), чтобы избежать долгих блокировок.
- Выгрузить данные в новую таблицу с нужной сортировкой, затем переключить
- VACUUM FULL или CLUSTER лучше проводить в «период окна» (maintenance window). Для минимального воздействия:
Вывод
Этот материал рассчитан на профессионалов, готовых погружаться в тонкости профилирования и тюнинга PostgreSQL. Не полагайтесь только на «волшебные» параметры в postgresql.conf
— сначала соберите данные, оцените планы, затем применяйте оптимизации.