5 квітня 2026 р.
Реляційні бази даних є основою більшості масштабованих архітектур сучасних додатків. У міру зростання навантаження затримки при читанні та записі в базу даних стають основним фактором уповільнення роботи інтерфейсу. У PostgreSQL запит, який виконується за 5 мілісекунд на 10,000 рядків, може легко вирости до 15 секунд, коли обсяг таблиці перевищить 50 мільйонів рядків.
Оптимізація таких запитів вимагає набагато більшого, ніж просто випадкового додавання індексів. Потрібно розуміти логіку роботи планувальника PostgreSQL, організацію даних на диску, алгоритми виконання операцій та вплив моделі багатоверсійності (MVCC) на читання даних. У цій статті ми проаналізуємо плани виконання запитів, навчимося проектувати складені та покриваючі індекси, розберемо алгоритми з'єднання таблиць, прискоримо підрахунок кількості рядків та налаштуємо параметри autovacuum.
EXPLAIN (ANALYZE, BUFFERS)Для оптимізації будь-якого повільного запиту необхідно спочатку вивчити план його виконання. За замовчуванням використання оператора EXPLAIN дає лише приблизну оцінку вартості запиту. Щоб отримати реальні дані про роботу бази, завжди використовуйте команду EXPLAIN (ANALYZE, BUFFERS):
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email, created_at
FROM users
WHERE company_id = 45
ORDER BY created_at DESC
LIMIT 50;
Додавання ключового слова BUFFERS є критично важливим, оскільки воно показує кількість прочитаних та записаних сторінок даних (блоків) з диска та оперативної пам'яті.
Дерево виконання плану запиту
+--------------------------------------+
| 1. Limit (Реальний час) |
+--------------------------------------+
|
+--------------------------------------+
| 2. Sort (Ключ: created_at) |
+--------------------------------------+
|
+--------------------------------------+
| 3. Bitmap Heap Scan (company_id) |
+--------------------------------------+
|
+--------------------------------------+
| 4. Bitmap Index Scan (idx_company) |
+--------------------------------------+
SELECT та WHERE, присутні в самому індексі, PostgreSQL взагалі не звертається до основної таблиці, читаючи дані безпосередньо з індексу. Це найшвидша операція читання.Shared Hit: Блоки даних були зчитані з кешу оперативної пам'яті (shared buffers). Це працює дуже швидко.Shared Read: Блоки даних довелося зчитувати безпосередньо з диска (найповільніша частина запиту).Shared Read вказує на те, що база даних перевантажена операціями дискового введення-виведення.Ваша мета при оптимізації - звести запити до операцій Index Only Scan з високим показником Shared Hit.
Для ефективного пошуку індекси потрібно проектувати під конкретні типи запитів.
При створенні індексу на кілька колонок діє важливе правило: Колонки точної відповідності (equality) мають йти першими, колонки діапазонів (range) - другими.
Розглянемо запит:
SELECT id FROM orders WHERE user_id = 1054 AND created_at >= '2024-01-01';
Якщо створити індекс у такому порядку:
-- Неефективно: Пошук за датами завадить швидкому пошуку user_id
CREATE INDEX idx_orders_bad ON orders(created_at, user_id);
База спочатку шукатиме всі дати у вказаному діапазоні, а потім всередині них перебиратиме user_id. Натомість правильним буде такий варіант:
-- Ефективно: Точна рівність йде першою
CREATE INDEX idx_orders_good ON orders(user_id, created_at);
У цьому випадку PostgreSQL миттєво перейде до потрібної гілки user_id у B-Tree дереві та лінійно зчитає потрібний хронологічний діапазон дат.
INCLUDEЯкщо вам потрібно отримати швидкість Index Only Scan, але ви не хочете перевантажувати індексне дерево зайвими ключами (що збільшує його розмір і сповільнює операції запису), скористайтеся реченням INCLUDE. Воно додає додаткові колонки тільки в кінцеві листи B-Tree дерева:
CREATE INDEX idx_users_company_covering
ON users(company_id)
INCLUDE(email, created_at);
Завдяки цьому запит SELECT email, created_at FROM users WHERE company_id = 45 буде виконано миттєво через Index Only Scan, оскільки всі необхідні дані знаходяться всередині самого індексу.
При з'єднанні таблиць планувальник обирає один із трьох алгоритмів залежно від обсягу даних та наявності індексів.
Для кожного рядка із зовнішньої таблиці база даних шукає відповідний рядок у внутрішній таблиці.
Повністю зчитується менша таблиця, і для неї в оперативній пам'яті будується хеш-таблиця. Після цього сканується велика таблиця і кожен рядок перевіряється за хеш-ключами.
work_mem). Якщо хеш-таблиця перевищує ліміт пам'яті, PostgreSQL почне писати тимчасові файли на повільний диск, що призведе до падіння швидкості.Обидві таблиці сортуються за ключем з'єднання, а потім скануються паралельно для знаходження відповідностей.
Лайфхак для відладки: Якщо ви бачите у плані повільні Hash Join з використанням тимчасових файлів на диску, збільшіть ліміт оперативної пам'яті для поточної сесії:
-- Збільшити робочу пам'ять до 64 МБ для складних з'єднань
SET work_mem = '64MB';
COUNT(*) повільний та як це виправитиУ деяких базах даних підрахунок кількості рядків відбувається миттєво, бо вони зберігають загальне значення окремо. У PostgreSQL запит SELECT COUNT(*) FROM orders на мільйонах записів завжди триватиме довго.
Через модель багатоверсійності MVCC кожна транзакція бачить свій набір рядків. База повинна фізично прочитати кожну сторінку таблиці з диска та перевірити видимість кожного рядка для поточної транзакції.
Для панелей керування чи великих списків зазвичай не потрібна абсолютна точність до одного рядка. Ви можете миттєво дізнатися приблизну кількість рядків, звернувшись до системної таблиці pg_class:
SELECT reltuples::bigint AS approximate_count
FROM pg_class
WHERE relname = 'orders';
Цей запит виконується за частку мілісекунди, навіть якщо таблиця містить мільярди рядків, оскільки він читає заздалегідь збережену статистику планувальника.
PostgreSQL використовує модель MVCC для керування паралельними транзакціями. Коли ви оновлюєте (UPDATE) рядок, стара версія не видаляється фізично, а позначається як неактивна ("мертвий кортеж", або dead tuple). Нові дані вставляються поруч. Видалення запису (DELETE) також лише позначає його як мертвий.
Якщо ці мертві кортежі не прибирати, вони накопичуються в таблиці. При наступних запитах базі доведеться сканувати ці пусті мертві клітинки, витрачаючи ресурси диска.
Процес autovacuum працює у фоні та очищує ці мертві записи. Проте на високонавантажених проектах стандартні параметри autovacuum занадто консервативні, через що таблиці починають швидко роздуватися.
Щоб зробити очищення більш агресивним, налаштуйте параметри autovacuum для таблиць із високою інтенсивністю оновлень:
-- Запускати очищення при оновленні 10% рядків (за замовчуванням 20%)
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.10);
-- Запускати очищення після 500 змінених рядків
ALTER TABLE orders SET (autovacuum_vacuum_threshold = 500);
-- Зменшити затримку між циклами очищення до 2 мілісекунд (за замовчуванням 20мс)
ALTER TABLE orders SET (autovacuum_vacuum_cost_delay = 2);
-- Дозволити autovacuum обробляти більше даних за один крок
ALTER TABLE orders SET (autovacuum_vacuum_cost_limit = 1000);
Аналітичні запити часто використовують віконні функції (ROW_NUMBER(), RANK(), SUM() OVER(...)) для обчислення рангів або накопичувальних підсумків. Проте вони можуть працювати повільно, оскільки вимагають від бази групувати та сортувати дані в оперативній пам'яті.
При аналізі запитів з віконними функціями у звіті EXPLAIN ANALYZE часто можна побачити важкі операції Sort або Incremental Sort:
EXPLAIN (ANALYZE, BUFFERS)
SELECT user_id, amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) as row_num
FROM orders;
Якщо обсяг сортування перевищує ліміт work_mem, дані записуватимуться у тимчасові файли на диску.
Ви можете повністю прибрати важку фазу сортування, створивши складений індекс, який відповідає структурі блоків PARTITION BY та ORDER BY вашої віконної функції. Планувальник зчитуватиме дані з індексу вже у потрібному порядку, минаючи сортування.
Створіть складений індекс за таким шаблоном:
PARTITION BY на першому місціORDER BY на другому місці-- Оптимізація для: ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC)
CREATE INDEX idx_orders_window_opt ON orders(user_id, created_at DESC);
Після додавання цього індексу операція Sort у плані зникне - її замінить швидка операція WindowAgg, яка отримує дані безпосередньо з Index Scan. Це знижує навантаження на процесор та зменшує час виконання складних запитів до 10 разів на великих таблицях.
Оптимізація бази даних - це не одноразова процедура, а безперервний процес моніторингу та профілювання. Окрім налаштування autovacuum, важливо пам'ятати, що самі індекси також схильні до накопичення сміття (index bloat) при частих операціях запису та видалення. Процес autovacuum очищує таблиці, але не завжди може ефективно повернути місце на диску всередині дерева B-Tree.
Для боротьби з цим рекомендується періодично (наприклад, під час технічних вікон раз на місяць) виконувати команду перебудови індексів:
-- Перебудувати всі індекси таблиці orders у фоновому режимі (без блокування читання та запису)
REINDEX TABLE CONCURRENTLY orders;
Використання аналізу через EXPLAIN ANALYZE BUFFERS, проектування правильних індексів з пріоритетом рівності на початку та регулярний запуск обслуговування дозволить вашій базі даних PostgreSQL демонструвати стабільно високу швидкість та легко масштабуватися до мільярдів записів.