Оптимізація PostgreSQL-запитів (EXPLAIN ANALYZE, індекси, планувальник)
Повільні запити в PostgreSQL — головний біль будь-якого DevOps-інженера. Запит, який працював півсекунди при 100k рядків, починає висіти по 8 секунд при 2 млн — і всі дивляться на розробника. При цьому сам запит правильний, схема нормальна, залізо не змінилося. Справа в планувальнику PostgreSQL, який обирав один план при маленькій таблиці та переключився на інший при зростанні даних. Або в тому, що ANALYZE не запускався місяць, і статистика застаріла. Або в тому, що індекс є, але планувальник його не використовує через неправильний statistics target. Ми стикалися з цими проблемами десятки разів і розробили системний підхід до оптимізації. Наші інженери з 10+ річним досвідом гарантують зниження часу відповіді бази даних до 50% та зменшення навантаження на сервер. Оцінимо ваш проект: пишіть, отримайте консультацію.
Як читати план EXPLAIN ANALYZE правильно?
EXPLAIN показує план без виконання запиту. EXPLAIN ANALYZE — реально виконує та показує фактичні vs очікувані рядки. Різниця між ними — ключ до діагностики.
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.email, COUNT(o.id)
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.created_at > CURRENT_DATE - INTERVAL '1 year'
GROUP BY u.email;Прапор BUFFERS додає інформацію про потрапляння в shared_buffers та звернення до диска — критично для розуміння, наскільки запит навантажує I/O. Читати план знизу вгору: нижні вузли виконуються першими.
На що дивитися в першу чергу:
Rows estimation mismatch. Якщо планувальник очікував 10 рядків (rows=10), а отримав 50000 (actual rows=50000) — статистика застаріла або statistics_target занадто малий. Це змушує планувальник обирати Nested Loop там, де потрібен Hash Join.
-- подивитися статистику та збільшити деталізацію
SELECT n_distinct, correlation, most_common_vals
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders;Sequential Scan на великій таблиці. Не завжди погано — якщо запит повертає >5-10% рядків таблиці, Seq Scan дешевший за Index Scan. Але якщо returning rows мало, а Seq Scan все одно обирається — швидше за все, індексу немає або він не покриває умову.
Hash Join з великим Batches. Якщо Batches > 1 в Hash Join — хеш-таблиця не влізла в work_mem і спилилася на диск. Рішення: SET work_mem = '64MB' для важких аналітичних запитів (не глобально — це з'їсть всю пам'ять при конкурентних сесіях).
Чому планувальник обирає неправильний план?
PostgreSQL використовує cost-based optimizer. Вартість операцій задається параметрами в postgresql.conf. Найчастіший випадок некоректного плану — застарілі параметри вартості для SSD:
# Для SSD знижуємо random_page_cost (default=4.0)
random_page_cost = 1.1
effective_cache_size = 12GB # ~75% від RAM сервераЗ random_page_cost = 4.0 планувальник вважає випадковий доступ до диска дорогим і надає перевагу Seq Scan навіть там, де Index Scan логічніший. На SSD це призводить до неправильних рішень. > PostgreSQL Documentation: random_page_cost має бути знижений для SSD.
Якщо після збору статистики та виправлення параметрів запит все одно бере поганий план — можна зафіксувати його через pg_hint_plan (розширення) або через параметри сесії:
-- примусово відключити Nested Loop для поточної сесії
SET enable_nestloop = off;
EXPLAIN ANALYZE SELECT ...;
SET enable_nestloop = on;Це інструмент діагностики, не постійне рішення. Постійне відключення оператора планувальника ламає інші запити.
Індекси: які, коли та навіщо
Стандартний B-tree індекс працює для рівності та діапазонів на звичайних типах. Для специфічних завдань потрібні інші:
-- частковий індекс: індексуємо тільки активні замовлення
CREATE INDEX idx_orders_active ON orders(created_at) WHERE status = 'active';
-- складений індекс: порядок колонок важливий
-- працює для WHERE user_id = ? AND status = ?
-- і для WHERE user_id = ? (перша колонка)
-- НЕ працює для WHERE status = ? (тільки друга колонка)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- covering index: включаємо потрібні колонки, уникаючи heap fetch
CREATE INDEX idx_orders_covering ON orders(user_id) INCLUDE (total_amount, created_at);
-- GIN для JSONB
CREATE INDEX idx_events_payload ON events USING GIN(payload jsonb_path_ops);
Covering index (з INCLUDE) особливо корисний для часто виконуваних запитів, які читають 2-3 конкретних поля. Планувальник робить Index Only Scan — дані беруться прямо з індексу, без звернення до heap.
Розпухлі індекси — окрема проблема. Після масових UPDATE/DELETE індекси накопичують мертві сторінки. pgstatindex покаже bloat:
SELECT * FROM pgstatindex('idx_orders_user_status'); -- leaf_fragmentation > 30% → пора REINDEX CONCURRENTLY REINDEX CONCURRENTLY не блокує читання та запис — на відміну від звичайного REINDEX.
Приклад аналізу плану з розбіжністю rows
Допустимо, у нас запит:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'pending'; План показує: Seq Scan on orders (cost=0.00..4567.89 rows=100 actual rows=25000). Розбіжність у 250 разів — статистика не оновлена. Після ANALYZE orders план змінюється на Index Scan з correct rows.
Кейс з нашої практики: оптимізація Laravel-додатку
Наш клієнт — проект на Laravel з PostgreSQL. Запит на вибірку посторінкового списку з фільтрами за статусом і датою:
SELECT * FROM products WHERE category_id = 5 AND is_active = true ORDER BY created_at DESC LIMIT 20 OFFSET 10000; З OFFSET 10000 планувальник проганяє 10020 рядків, відкидає 10000. При індексі на (category_id, is_active, created_at DESC) Index Scan працює, але offset все одно дорогий. Рішення — keyset pagination: WHERE (created_at, id) < ($last_created_at, $last_id) з відповідним складеним індексом. Після впровадження час виконання впав з 2.3 с до 0.05 с.
Інший випадок — запити з LIKE '%pattern%' без pg_trgm. Full scan на кожен запит. Після CREATE INDEX ... USING GIN(name gin_trgm_ops) ті ж запити відпрацьовують по індексу.
Процес роботи
- Збір повільних запитів з
pg_stat_statements(топ заtotal_exec_time) таpg_stat_activity(довгі активні запити) -
EXPLAIN (ANALYZE, BUFFERS)по кожному кандидату - Аналіз mismatched row estimates — оновлення статистики, збільшення
statistics_targetде потрібно - Створення/перебудова індексів через
CREATE INDEX CONCURRENTLY(без блокувань) - Перевірка параметрів планувальника під конкретне залізо
- Тест продуктивності до/після на репрезентативних даних
Що входить в роботу
- Детальний звіт по кожному повільному запиту (план, рекомендації)
- Скрипти для створення/перебудови індексів
- Налаштування параметрів postgresql.conf під ваше обладнання
- Інтеграція pg_stat_statements та налаштування моніторингу
- Навчання вашої команди: як читати EXPLAIN та діагностувати проблеми
- Гарантія на результат: зниження часу виконання топ-10 запитів мінімум на 30% або доопрацювання безкоштовно
Орієнтири за часом
| Тип роботи | Типовий термін |
|---|---|
| Аналіз топ-5 повільних запитів | 4-8 годин |
| Повний аудит + індекси + планувальник | 2-3 дні |
| Оптимізація під високу конкурентність (OLTP) | 3-5 днів |
Вартість розраховується індивідуально залежно від обсягу. Оцінимо ваш проект за один день: зв'яжіться з нами.
| Тип індексу | Коли використовувати | Особливості |
|---|---|---|
| B-tree | Рівність, діапазони, сортування | Стандартний, універсальний |
| GIN | JSONB, масиви, повнотекстовий пошук | Вимагає jsonb_path_ops для оптимальної роботи |
| GiST | Геодані, повнотекстовий пошук | Підтримує типи даних, які не підходять під B-tree |
| BRIN | Великі таблиці з природною впорядкованістю | Економить місце, але менш ефективний для точкових запитів |
Наші інженери мають 10+ років досвіду в DevOps та понад 50 успішних проектів з оптимізації баз даних. Довірте налаштування PostgreSQL професіоналам — отримайте консультацію вже сьогодні.







