Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
19.06.2026
Обновлено:
09.07.2026

PostgreSQL тормозит: 30 реальных причин и как их устранить

Данил Мануйлов

Запросы уходят на 5 секунд, CPU простаивает, а веб-сервер возвращает 504 - знакомо? В 90% случаев виноват не сам PostgreSQL, а то, как его используют и настраивают. Разберём 30 причин деградации производительности и покажем, как каждую из них найти и исправить.

Как диагностировать медленный PostgreSQL

Прежде чем оптимизировать, нужно понять, что именно тормозит. PostgreSQL даёт три базовых инструмента:

  • EXPLAIN ANALYZE - план выполнения конкретного запроса с реальным временем.
  • pg_stat_statements - агрегированная статистика по всем запросам.
  • pg_stat_activity - текущие сессии, состояние, ожидания.

Без этих данных любая «оптимизация» - гадание на кофейной гуще.

EXPLAIN ANALYZE: читаем план за 5 минут

Запускайте команду так:

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, name FROM users WHERE email = 'user@example.com';

Что ищем в выводе:

Сигнал Что значит Что делать
Seq Scan на большой таблице Нет индекса или он не применяется Добавить индекс, проверить статистику
rows=1 в плане, rows=50000 по факту Устаревшая статистика ANALYZE <таблица>
Nested Loop с тысячами loops N+1 или нет индекса по FK Переписать запрос, добавить индекс
Hash Batches > 1 work_mem мал для хеш-соединения Увеличить work_mem
Время планирования > времени выполнения Сложный запрос с большим числом JOIN Упростить или использовать CTE

Параметр BUFFERS показывает, сколько страниц взято из кэша (shared hit) и считано с диска (read). Большое значение read - сигнал нехватки shared_buffers.

pg_stat_statements и log_min_duration_statement

Включите расширение один раз:

-- В postgresql.conf:
shared_preload_libraries = 'pg_stat_statements'

-- После рестарта в нужной БД:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

Топ-10 самых дорогих запросов:

SELECT query,
       calls,
       round(total_exec_time::numeric, 2) AS total_ms,
       round((total_exec_time / calls)::numeric, 2) AS avg_ms,
       rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

Авторская ремарка. Лично я всегда сначала смотрю на avg_ms * calls - это реальная «боль» конкретного запроса. Запрос за 100 мс, вызванный 100 000 раз в сутки, опаснее разового запроса за 10 секунд.

Параметр log_min_duration_statement = 1000 (в мс) запишет в лог всё, что выполняется дольше секунды - без расширений, прямо в системный лог PostgreSQL.

Причины 1–10: запросы и схема данных

Большинство проблем с производительностью живут здесь - и здесь же самые быстрые победы.

Причина 1. Нет индекса на колонку в WHERE

PostgreSQL читает всю таблицу (Seq Scan), если нет индекса по условию фильтра. На таблице в 100 000 строк cost Seq Scan ≈ 1750, cost Index Scan ≈ 8 - разница в 200 раз.

-- Проверить: нет ли Seq Scan на большой таблице
EXPLAIN SELECT * FROM orders WHERE status = 'pending';

-- Решение
CREATE INDEX CONCURRENTLY idx_orders_status ON orders(status);

CONCURRENTLY строит индекс без блокировки таблицы - используйте на проде.

Причина 2. Индекс есть, но не используется

PostgreSQL игнорирует индекс, если:

  • кардинальность колонки низкая (например, status с двумя значениями на 95% строк);
  • запрос применяет функцию к индексированной колонке: WHERE LOWER(email) = ...;
  • статистика устарела и планировщик ошибается в оценке selectivity.

Решение - создать индекс по выражению:

CREATE INDEX idx_users_email_lower ON users(LOWER(email));

Причина 3. SELECT * тянет лишние данные

SELECT * читает все колонки, включая TOAST-поля (JSON, TEXT, bytea). Это перегружает I/O и shared_buffers. Запрашивайте только нужные поля.

Причина 4. N+1 запросов из ORM

ORM загружает список объектов, затем для каждого делает отдельный SELECT. Вместо одного запроса - тысячи. Признак в pg_stat_statements: один шаблон запроса с calls в десятки тысяч.

Решение: JOIN или prefetch_related (Django), eager loading (другие ORM). Лучше - написать SQL вручную.

Причина 5. Широкие таблицы (100+ колонок)

Одна страница PostgreSQL - 8 КБ. Чем шире строка, тем меньше строк в странице, тем больше страниц читает каждый запрос. Таблицу с 300 колонками разделите на основную (частые поля) и ext (редкие), связав через первичный ключ.

Причина 6. Устаревшая статистика

Планировщик опирается на статистику из pg_statistic. После массовой загрузки данных или смены распределения значений она устаревает. Результат - неверный план, Seq Scan вместо Index Scan.

-- Обновить статистику по таблице
ANALYZE orders;

-- Проверить дату последнего анализа
SELECT relname, last_analyze, n_live_tup
FROM pg_stat_user_tables
ORDER BY last_analyze NULLS FIRST;

Причина 7. Нет частичного или покрывающего индекса

Частичный индекс покрывает только нужное подмножество строк:

-- Индексируем только активные заказы
CREATE INDEX idx_orders_active ON orders(created_at)
WHERE status = 'active';

Покрывающий индекс (INCLUDE) позволяет обойтись Index Only Scan - без обращения к heap:

CREATE INDEX idx_users_email_name ON users(email) INCLUDE (name);

Причина 8. CTE как fence для оптимизатора

До PostgreSQL 12 каждый CTE (WITH ... AS (...)) выполнялся независимо как «optimization fence» - планировщик не мог перенести предикаты внутрь. Начиная с PG 12, CTE по умолчанию инлайнятся. Если у вас старая версия или явный MATERIALIZED - проверьте, не изолирует ли CTE оптимизацию.

Причина 9. Неоптимальный тип JOIN

PostgreSQL выбирает между Hash Join, Merge Join и Nested Loop. На маленьких выборках Nested Loop быстр; на больших - Hash Join. Если планировщик ошибается, причина обычно в устаревшей статистике или нехватке work_mem для хеш-таблицы.

Причина 10. Нет индекса по внешнему ключу

PostgreSQL не создаёт индекс по FK автоматически. При JOIN по FK без индекса на дочерней таблице - Seq Scan. Найти незаиндексированные FK:

SELECT c.conname, c.conrelid::regclass, a.attname
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid
    AND a.attnum = ANY(c.conkey)
WHERE c.contype = 'f'
    AND NOT EXISTS (
        SELECT 1 FROM pg_index i
        WHERE i.indrelid = c.conrelid
        AND a.attnum = ANY(i.indkey)
    );

Причины 11–20: autovacuum и bloat

Bloat (раздутие) - один из самых коварных источников деградации. Он не виден без специальных запросов, но стабильно замедляет всё: и SELECT, и INSERT.

Причины 11–12. Как работает MVCC и почему растёт bloat

PostgreSQL использует MVCC: при UPDATE старая версия строки не удаляется физически, а помечается как «мёртвая» (dead tuple). Живут мёртвые кортежи до тех пор, пока VACUUM их не уберёт. Накапливаются они быстро при интенсивных UPDATE/DELETE.

Оценить раздутие таблиц можно через расширение pgstattuple или просто через pg_stat_user_tables:

SELECT schemaname, tablename,
       n_dead_tup, n_live_tup,
       round(n_dead_tup::numeric / nullif(n_live_tup,0) * 100, 1) AS dead_ratio
FROM pg_stat_user_tables
ORDER BY dead_ratio DESC NULLS LAST
LIMIT 10;

Если dead_ratio стабильно выше 10–20% - autovacuum не справляется.

Причина 13. Autovacuum настроен с дефолтами «для бедных»

По умолчанию autovacuum_vacuum_scale_factor = 0.2: autovacuum запускается, когда мёртвых строк стало 20% от таблицы. Для таблицы в 100 млн строк это 20 млн мёртвых кортежей до первого вакуума.

Решение - снизить порог для активных таблиц:

ALTER TABLE orders
SET (autovacuum_vacuum_scale_factor = 0.01,
     autovacuum_analyze_scale_factor = 0.005);

Причина 14. autovacuum_vacuum_cost_delay слишком высок

autovacuum_vacuum_cost_delay = 20ms (дефолт до PG 13) означает паузу 20 мс между обработкой каждой «порции» страниц. Autovacuum работает медленно, чтобы не мешать запросам, но на высоконагруженной таблице это превращается в хроническое отставание.

В PostgreSQL 13+ дефолт снижен до 2 мс. Если у вас PG 12 и ниже - понизьте вручную.

Причина 15. Bloat индексов

Индексы раздуваются так же, как таблицы. Обычный VACUUM убирает мёртвые строки из heap, но не перестраивает индексы. Для индексов нужен REINDEX CONCURRENTLY или pg_repack.

-- Перестроить индекс без блокировки (PG 12+)
REINDEX INDEX CONCURRENTLY idx_orders_status;

Причина 16. Transaction ID wraparound (исчерпание XID)

PostgreSQL использует 32-битный счётчик транзакций. Когда он приближается к пределу (~2 млрд), СУБД переходит в режим только-чтение, чтобы не потерять данные. Именно для борьбы с XID wraparound autovacuum запускает «аварийный» anti-wraparound vacuum - он агрессивен и ломает производительность.

Следите за счётчиком:

SELECT datname,
       age(datfrozenxid) AS xid_age,
       2000000000 - age(datfrozenxid) AS xid_remaining
FROM pg_database
ORDER BY xid_age DESC;

Если xid_age > 1,5 млрд - пора срочно разобраться с autovacuum.

Авторская ремарка. Я видел продакшн, где аварийный anti-wraparound vacuum выключал базу в буквальном смысле. Это предотвратимо: мониторинг xid_age должен быть в каждом алёрте.

Причина 17. Нет fillfactor - HOT update не работает

fillfactor задаёт, сколько страницы занимают данные при вставке. По умолчанию 100% - свободного места нет. Из-за этого UPDATE не может сделать HOT (Heap Only Tuple) update на той же странице и создаёт новую версию строки в другом месте, что ведёт к фрагментации.

-- Оставить 20% страницы свободной для HOT update
ALTER TABLE orders SET (fillfactor = 80);

Причина 18. VACUUM FULL на проде без планирования

VACUUM FULL перестраивает таблицу целиком и держит ExclusiveLock на всё время. На таблице в сотни ГБ - это часы простоя. Альтернатива - pg_repack, который делает то же самое в фоне.

Причины 19–20. Bloat из-за долгих транзакций и репликации

Долгоживущая транзакция не даёт VACUUM убрать строки, которые она «видит». Даже одна незакрытая транзакция на 10 минут может заблокировать autovacuum на всей БД.

Аналогично - отставание слота репликации (replication slot lag) не даёт VACUUM продвигаться вперёд по WAL.

-- Найти долгие транзакции
SELECT pid, now() - xact_start AS duration, query
FROM pg_stat_activity
WHERE state != 'idle'
    AND xact_start IS NOT NULL
ORDER BY duration DESC;

Причины 21–27: конфигурация сервера

Дефолтные настройки PostgreSQL рассчитаны на сервер с 256 МБ RAM. Их надо менять под каждое конкретное железо.

Причина 21. shared_buffers слишком мал

shared_buffers - буфер страниц в RAM. Дефолт - 128 МБ. Типичная рекомендация: 25% RAM сервера. На сервере с 32 ГБ RAM ставьте 8 ГБ. Если в EXPLAIN BUFFERS много read (страниц с диска) и мало hit (из кэша) - shared_buffers мал.

Причина 22. work_mem слишком мал - сортировки уходят на диск

work_mem - память на одну операцию сортировки или хеша. Дефолт - 4 МБ. При нехватке PostgreSQL пишет временные файлы на диск. Признак в EXPLAIN: Sort Method: external merge Disk.

Поднимайте осторожно: при 100 соединениях и 10 операциях сортировки каждое потребление = 100 × 10 × work_mem. Лучше ставить per-session для тяжёлых запросов:

SET work_mem = '256MB';
SELECT ...;

Причина 23. effective_cache_size занижен

effective_cache_size - подсказка планировщику, сколько данных поместится в кэш ОС + PostgreSQL. Не выделяет реальную память. Если занижен, планировщик думает, что данных в кэше мало, и предпочитает Seq Scan индексным. Типичное значение: 75% RAM.

Причина 24. random_page_cost не скорректирован под SSD

Дефолт random_page_cost = 4.0 рассчитан на HDD. На SSD случайное чтение стоит почти столько же, сколько последовательное. Установите random_page_cost = 1.1 - и планировщик начнёт охотнее выбирать Index Scan.

-- В postgresql.conf
random_page_cost = 1.1  -- для SSD
seq_page_cost = 1.0

Причина 25. Частые checkpoint грузят I/O

Checkpoint сбрасывает грязные страницы из буфера на диск. Слишком частые checkpoint штурмуют I/O. checkpoint_completion_target = 0.9 позволяет растянуть запись на 90% интервала между checkpoint - I/O распределяется равномерно.

Причина 26. wal_buffers по умолчанию

Дефолт wal_buffers = -1 (автоматически 1/32 от shared_buffers). При высоком темпе записи - поднимите до 64 МБ вручную.

Причина 27. Параллельные запросы отключены

PostgreSQL 10+ умеет выполнять SELECT параллельно. Если max_parallel_workers_per_gather = 0 - параллелизм выключен. Включите и настройте:

max_parallel_workers_per_gather = 4
max_parallel_workers = 8

Проверьте в EXPLAIN: должны появиться узлы Parallel Seq Scan или Parallel Hash Join.

Причины 28–30: соединения и блокировки

Причина 28. Слишком много соединений без пула

Каждое соединение в PostgreSQL - отдельный процесс с ~5–10 МБ RSS. При max_connections = 200 и work_mem = 64 МБ пиковое потребление RAM - более 10 ГБ только на соединения. PgBouncer в режиме transaction pooling держит 10–30 реальных соединений при тысячах клиентских запросов.

# pgbouncer.ini
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20

Причина 29. Долгие блокировки и deadlock

Длинные транзакции удерживают row-level и table-level локи. Найти:

SELECT pid, wait_event_type, wait_event, query, now() - query_start AS duration
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY duration DESC;

Для предотвращения deadlock - соблюдать единый порядок доступа к таблицам, ставить lock_timeout = '5s' на критичные транзакции.

Причина 30. PostgreSQL на том же сервере, что приложение

PostgreSQL конкурирует за CPU, RAM и I/O с веб-сервером, Redis, Celery и агентами мониторинга. Вынос СУБД на отдельный сервер или переход на DBaaS - самая простая «оптимизация» без единой строки SQL.

Альтернативный взгляд

Некоторые DBA считают, что главная причина тормозов - не конфиг и не bloat, а плохая архитектура приложения: разработчики проектируют схему «как получилось», не думая о характере запросов. С этой точки зрения оптимизация PostgreSQL - лечение симптомов, а не болезни. Лечить нужно схему: денормализация для OLAP, партиционирование для time-series, ClickHouse для аналитики - всё это убирает нагрузку с PostgreSQL эффективнее любого тюнинга конфига.

Нетривиальный факт

PostgreSQL не обновляет строку на месте - он создаёт новую физическую копию и помечает старую как мёртвую. Это означает, что даже «простой» UPDATE генерирует нагрузку на вакуум. Именно поэтому на таблицах с частыми обновлениями fillfactor = 80 и грамотный autovacuum дают ощутимый прирост производительности без единого изменения SQL.

FAQ

Как быстро найти самые медленные запросы в PostgreSQL?

Включите pg_stat_statements и выполните SELECT query, calls, round(total_exec_time/calls,2) AS avg_ms FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10; Сортировка по total_exec_time покажет запросы, которые суммарно отнимают больше всего времени за период.

Почему PostgreSQL делает Seq Scan вместо Index Scan?

Пять причин: нет индекса; статистика устарела (ANALYZE); кардинальность колонки низкая (планировщик считает дешевле читать всю таблицу); функция над индексированным полем ломает индекс; effective_cache_size или random_page_cost настроены неверно.

Что такое bloat и как его измерить?

Bloat - накопление мёртвых кортежей из-за MVCC. Измерьте через pg_stat_user_tables: колонка n_dead_tup / n_live_tup. Более 10–20% на активной таблице - сигнал к проверке autovacuum.

Как настроить autovacuum для высоконагруженных таблиц?

Снизьте пороги на уровне таблицы: ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_analyze_scale_factor = 0.005); Также уменьшите autovacuum_vacuum_cost_delay до 2–5 мс на PG 12 и ниже.

Как сделать VACUUM FULL без простоя?

Используйте pg_repack: перестраивает таблицу и индексы в фоне без ExclusiveLock. Установка: CREATE EXTENSION pg_repack;, затем pg_repack -d mydb -t orders.

Какие параметры postgresql.conf критичны для производительности?

shared_buffers (25% RAM), work_mem (осторожно - умножается на число соединений × операций), effective_cache_size (75% RAM), random_page_cost = 1.1 для SSD, checkpoint_completion_target = 0.9.

Нужен ли PgBouncer при 50+ соединениях?

Да. Каждое соединение - отдельный процесс 5–10 МБ RAM. PgBouncer в режиме transaction держит 10–20 реальных соединений к PostgreSQL при тысячах клиентских - снижает overhead и конкуренцию за ресурсы.

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также