Оптимізація SQL-запитів веб-застосунку

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Оптимізація SQL-запитів веб-застосунку
Складна
~2-3 робочих дні
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Оптимізація SQL-запитів для веб-додатків

Повільні запити вбивають UX швидше ніж будь-який інший фактор. 95% проблем продуктивності на рівні БД вирішуються одним із чотирьох методів: додаванням індексу, переписуванням запиту, денормалізацією або кешуванням. Розберемо діагностику та конкретні техніки.

Інструменти діагностики

pg_stat_statements — перше, що потрібно включити на продакшені:

-- postgresql.conf
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 колонці — немає підходящого індексу.

Типові антипаттерни та рішення

1. SELECT * в ORM

-- Погано: тягнемо всі 30 колонок, включаючи BLOB-поля
SELECT * FROM products WHERE category_id = 5;

-- Добре: тільки потрібні поля
SELECT id, title, slug, price, status FROM products WHERE category_id = 5;

2. OFFSET пагінація на великих таблицях

-- Погано: при offset=10000 PostgreSQL читає 10050 рядків і відкидає 10000
SELECT * FROM products ORDER BY created_at DESC LIMIT 50 OFFSET 10000;

-- Добре: keyset пагінація
SELECT * FROM products
WHERE (created_at, id) < ('2024-03-01 12:00:00', 5000)
ORDER BY created_at DESC, id DESC
LIMIT 50;

Keyset пагінація вимагає складеного індексу (created_at DESC, id DESC) та передачі останнього значення з попередньої сторінки.

3. OR на різних колонках

-- Погано: OR часто не використовує індекс
SELECT * FROM users WHERE email = $1 OR phone = $1;

-- Добре: UNION
SELECT * FROM users WHERE email = $1
UNION ALL
SELECT * FROM users WHERE phone = $1
LIMIT 1;

4. Функції в WHERE

-- Погано: функція на колонці блокує індекс
SELECT * FROM orders WHERE DATE(created_at) = '2024-03-15';

-- Добре: range умова
SELECT * FROM orders
WHERE created_at >= '2024-03-15'
  AND created_at < '2024-03-16';

5. NOT IN з підзапитом

-- Погано: якщо підзапит повернеш NULL — весь NOT IN повернеш порожній результат
SELECT * FROM products WHERE id NOT IN (SELECT product_id FROM order_items);

-- Добре: NOT EXISTS або LEFT JOIN
SELECT p.* FROM products p
LEFT JOIN order_items oi ON oi.product_id = p.id
WHERE oi.product_id IS NULL;

Оптимізація 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 умови (user_id = $1, status = 'completed'), потім range/sort (created_at DESC).

work_mem та Sort Spill

Якщо в EXPLAIN ANALYZE бачимо external merge (Disk: ...) під час Sort — збільшуємо work_mem для сесії:

SET work_mem = '64MB';
-- Виконуємо важкий аналітичний запит
-- Повертаємо назад або використовуємо настройку на рівні з'єднання

В postgresql.conf краще залишити work_mem низьким (4-8MB за замовчуванням) і підносити для конкретних запитів через SET LOCAL work_mem.

CTE vs Підзапит vs Lateral

-- CTE (WITH) в PostgreSQL 12+ оптимізується як підзапит за замовчуванням
-- До PG12: CTE завжди матеріалізується — optimization fence
WITH recent_orders AS (
    SELECT user_id, COUNT(*) AS cnt
    FROM orders
    WHERE created_at > NOW() - INTERVAL '30 days'
    GROUP BY user_id
)
SELECT u.*, ro.cnt
FROM users u
JOIN recent_orders ro ON ro.user_id = u.id;

-- LATERAL: для row-dependent підзапитів
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.

Статистика планувальника

Якщо план вибирає неправильний метод доступу — оновлюємо статистику:

ANALYZE products;
-- або для оновлення статистики конкретної колонки з підвищеною точністю:
ALTER TABLE products ALTER COLUMN status SET STATISTICS 500;
ANALYZE products (status);

За замовчуванням default_statistics_target = 100. Для колонок із високою кардинальністю (timestamps, UUID) піднімаємо до 200–500.

Графіки

Діагностика та оптимізація 10–15 повільних запитів (через pg_stat_statements, EXPLAIN ANALYZE, додавання індексів, переписування): 2–3 дні. Глибокий аудит схеми та запитів для високонавантаженого додатку: 3–5 днів.