Оптимізація повільних SQL-запитів PostgreSQL
Ви чекаєте 20 секунд на завантаження звіту. Користувачі йдуть, база даних — вузьке горло. Типова ситуація: один запит до PostgreSQL виконується 5 секунд, а таких запитів сотні на хвилину. Ми розбираємося, чому це відбувається, і усуваємо проблему: знижуємо час відповіді БД у 3–5 разів на навантаженні. Діагностика триває день, оптимізація — ще пару днів. Всі зміни документуються, виміри до та після обов'язкові.
Повільні запити — основна причина поганого UX. 95% проблем продуктивності БД вирішуються одним із чотирьох методів: додаванням індексу, переписуванням запиту, денормалізацією або кешуванням. Досвід показує, що грамотна оптимізація 10–15 найважчих запитів може вивільнити до 40% ресурсів сервера. Розглянемо, як діагностувати та виправляти повільні запити в PostgreSQL.
Діагностика повільних запитів
За даними документації PostgreSQL, pg_stat_statements є стандартним інструментом для моніторингу. pg_stat_statements — перше розширення, яке потрібно ввімкнути на продакшні. Воно збирає статистику по кожному запиту: сумарний та середній час, кількість викликів, стандартне відхилення. Коефіцієнт варіації (coeff_var) допомагає виявити запити з нестабільним планом.
Процес діагностики за п'ять кроків:
- Увімкніть розширення pg_stat_statements (якщо вимкнено) та зберіть статистику за кілька годин.
- Виконайте запит топ-20 за total_exec_time.
- Для кожного підозрілого запиту отримайте план через
EXPLAIN (ANALYZE, BUFFERS). - Визначте вузли плану: Seq Scan, Nested Loop, Hash Join з Batches > 1.
- Застосуйте відповідну оптимізацію: додайте індекс, перепишіть запит, налаштуйте work_mem.
-- Ввімкнення pg_stat_statements
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 10000
pg_stat_statements.track = all
-- Топ-20 за сумарним часом
SELECT round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
calls,
round((stddev_exec_time / mean_exec_time * 100)::numeric, 1) AS coeff_var_pct,
left(query, 120) AS query
FROM pg_stat_statements
WHERE calls > 100
ORDER BY total_exec_time DESC
LIMIT 20;
coeff_var_pct — коефіцієнт варіації: високий відсоток свідчить про нестабільний план (різні параметри дають кардинально різний час). Далі кожен підозрілий запит проганяємо через EXPLAIN ANALYZE:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT p.*, c.name AS category_name
FROM products p
JOIN categories c ON c.id = p.category_id
WHERE p.status = 'published'
AND p.created_at > NOW() - INTERVAL '30 days'
ORDER BY p.created_at DESC
LIMIT 50;
Ключові вузли в плані:
-
Seq Scanна великій таблиці — немає індексу або планувальник вирішив, що індекс не вигідний. -
Nested Loopз великою кількістю ітерацій — N+1 на рівні SQL. -
Hash JoinзBatches > 1— не вистачаєwork_mem. -
SortбезIndex Scanна ORDER BY колонці — немає відповідного індексу.
Чому індекси не використовуються?
Навіть за наявності індексу планувальник може його проігнорувати. Основні причини:
- Функція на колонці в WHERE (наприклад,
DATE(created_at)) - Низька селективність (індекс на булевій колонці зі зміщеним розподілом)
- Сортування, що не збігається з порядком індексу
Рішення: переписуємо запит, щоб прибрати обгортки функцій, і створюємо складові індекси під конкретні патерни. Порядок колонок у складовому індексі: спочатку умови рівності, потім діапазонні та сортування.
Які антипатерни зустрічаються найчастіше?
-- Погано: SELECT * тягне зайві колонки; OFFSET збільшує навантаження; OR не використовує індекс; функція на колонці; NOT IN з NULL
SELECT * FROM products WHERE category_id = 5 LIMIT 50 OFFSET 10000;
SELECT * FROM users WHERE email = $1 OR phone = $1;
SELECT * FROM orders WHERE DATE(created_at) = $1;
SELECT * FROM products WHERE id NOT IN (SELECT product_id FROM order_items);
-- Добре: лише потрібні поля; keyset pagination; UNION ALL; range condition; NOT EXISTS
SELECT id, title, slug FROM products WHERE (created_at, id) > ($1, $2) ORDER BY created_at DESC LIMIT 50;
SELECT * FROM users WHERE email = $1 UNION ALL SELECT * FROM users WHERE phone = $1 LIMIT 1;
SELECT * FROM orders WHERE created_at >= $1 AND created_at < $2;
SELECT p.* FROM products p WHERE NOT EXISTS (SELECT 1 FROM order_items oi WHERE oi.product_id = p.id);
Порівняймо методи пагінації:
Порівняльна таблиця методів пагінації
| Метод пагінації | Навантаження на БД | Підтримка випадкового доступу | Вимагає індекс |
|---|---|---|---|
| OFFSET | Зростає з номером сторінки | Так | Не обов'язковий |
| Keyset | Константа | Ні | Обов'язковий |
| Кільцева навігація | Константа | Ні | Обов'язковий |
Keyset pagination в 100 разів ефективніша за OFFSET на великих даних.
Оптимізація JOIN: складові індекси
-- Додаємо складовий індекс для типового фільтра
CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);
-- Запит використовує index scan без Sort
SELECT id, total, status, created_at
FROM orders
WHERE user_id = $1
AND status = 'completed'
ORDER BY created_at DESC
LIMIT 10;
Порядок колонок в індексі: спочатку equality conditions (user_id = $1, status = 'completed'), потім range/sort (created_at DESC).
Налаштування work_mem та LATERAL
Якщо в EXPLAIN ANALYZE бачимо external merge (Disk: ...) при Sort — збільшуємо work_mem для сесії:
SET work_mem = '64MB';
-- Виконуємо важкий аналітичний запит
У postgresql.conf краще залишити work_mem низьким (4-8MB за замовчуванням) і піднімати для конкретних запитів через SET LOCAL work_mem.
-- LATERAL: для row-dependent subqueries
SELECT u.id, u.email, recent.total
FROM users u
CROSS JOIN LATERAL (
SELECT SUM(total) AS total
FROM orders o
WHERE o.user_id = u.id
AND o.created_at > NOW() - INTERVAL '30 days'
) AS recent;
LATERAL часто дає кращий план, ніж JOIN на агрегований CTE.
| Метрика | До оптимізації | Після оптимізації |
|---|---|---|
| Середній час запиту | 1 200 ms | 180 ms |
| Навантаження CPU (середня) | 85% | 25% |
| I/O reads за секунду | 500 | 80 |
Наприклад, оптимізація 15 запитів може заощадити до $1500 на місяць на серверах.
Що входить в роботу з оптимізації
- Аудит 10–15 найважчих запитів через pg_stat_statements та EXPLAIN ANALYZE
- Переписування запитів з усуненням антипатернів
- Додавання та налаштування індексів (включаючи складові та часткові)
- Налаштування параметрів PostgreSQL (shared_buffers, work_mem, effective_cache_size)
- Надання звіту з вимірами до/після та рекомендаціями для команди
- Додатково: навчання розробників роботі з планами запитів
Команда має 7+ років досвіду в оптимізації PostgreSQL, понад 50 успішних проектів.
В одному з проєктів ми скоротили час виконання запитів з 4 секунд до 200 мс — це знизило навантаження на сервер та дозволило уникнути upgrade інфраструктури. Оптимізація 15 повільних запитів може вивільнити значну частину ресурсів сервера.
Строки та вартість
Діагностика та оптимізація 10–15 повільних запитів — 2–3 дні. Глибокий аудит схеми та запитів для високонавантаженого застосунку — 3–5 днів. Вартість розраховується індивідуально після оцінки обсягу.
Пропонуємо оптимізацію SQL-запитів під ключ: від діагностики до впровадження за 2–3 дні. Напишіть нам для безкоштовної оцінки вашого проекту. Для старту проєкту зв'яжіться з нами в Telegram або поштою — ми проведемо безкоштовний аналіз перших двох запитів. Замовте діагностику — і отримайте звіт з вимірами до/після. Ми гарантуємо вимірне зниження часу запитів не менш ніж на 30% — результат фіксуємо до та після оптимізації.







