Адмінський дашборд логістичного SaaS мав панель «завдання, завершені цього тижня». Простий запит: порахувати рядки, де status = 'completed' і completed_at потрапляє в поточний тиждень. 50K рядків у таблиці на той момент.
Запит займав 6 секунд. На дашборді було 8 подібних панелей. Загальний час завантаження: 30+ секунд. Клієнт сказав «дашборд неможливо використовувати», і він мав рацію.
Фікс — одна команда CREATE INDEX. Знайти правильну зайняло три дні.
День 1: очевидне припущення виявилось хибним
Перший інстинкт: додати індекс на completed_at.
CREATE INDEX idx_jobs_completed_at ON jobs (completed_at);
Запустив EXPLAIN ANALYZE. Час запиту: все ще 5.8 секунд. Планувальник не використовував індекс.
Чому? Бо запит також фільтрував за status:
SELECT COUNT(*)
FROM jobs
WHERE status = 'completed'
AND completed_at >= '2026-05-19'
AND completed_at < '2026-05-26';
Postgres оцінив, що фільтр status = 'completed' відсіче 80% рядків, і обрав послідовний скан усієї таблиці, а потім фільтрацію за completed_at. Індекс на одну колонку completed_at був марним — планувальник вирішив, що швидше просканувати все, ніж звертатися до індексу і потім фільтрувати.
Спробував індекс на status:
CREATE INDEX idx_jobs_status ON jobs (status);
Час запиту: 2.1 секунди. Краще, але все ще жахливо для COUNT-запиту по 50K рядках.
День 2: пастка складеного індексу
Прочитав три треди на Stack Overflow і один пост. Усі казали «використовуй складений індекс». Ну й використав:
CREATE INDEX idx_jobs_status_completed_at ON jobs (status, completed_at);
Час запиту: 180ms. Величезне покращення. Задеплоїв.
Через дві години клієнт написав: «в'юха диспетчера стала гальмувати».
В'юха диспетчера робила інший запит:
SELECT *
FROM jobs
WHERE status IN ('assigned', 'accepted', 'in_progress')
AND zone_id = 'zone_42'
ORDER BY created_at DESC
LIMIT 20;
Цей запит став повільнішим. До моїх змін — 300ms. Тепер — 1.2 секунди. Мій новий складений індекс заплутав планувальник — він намагався зробити index scan по (status, completed_at) для запиту, що не фільтрує за completed_at, а потім фолбекався на сортування за created_at.
Відкотив індекс. Дашборд повернувся до 6 секунд. В'юха диспетчера — до 300ms.
День 3: зрозуміти, що Postgres реально потрібно
Перестав вгадувати і реально прочитав вивід EXPLAIN ANALYZE рядок за рядком.
План дашбордного запиту виглядав так (спрощено):
Seq Scan on jobs (cost=0.00..1847.00 rows=312 width=0)
Filter: ((status = 'completed') AND (completed_at >= '2026-05-19') AND (completed_at < '2026-05-26'))
Rows Removed by Filter: 49688
312 очікуваних рядків, 49,688 відкинуто фільтром. Postgres читав кожен рядок і викидав 99.4% з них.
План запиту диспетчера:
Index Scan using idx_jobs_zone_id on jobs (cost=0.29..847.00 rows=120 width=384)
Filter: (status = ANY ('{assigned,accepted,in_progress}'))
Sort Key: created_at DESC
Вже використовував індекс по zone_id. Фільтр status був пост-індексним фільтром. Це було нормально — 120 рядків з індексу, відфільтрувати до ~20, відсортувати, ліміт. Достатньо швидко.
Інсайт: дашборд і диспетчерський запит мали різні патерни доступу. Дашборду потрібно було стрибнути прямо до status + діапазон дат. Диспетчеру потрібен був zone_id першим, а статус — як фільтр. Один складений індекс не міг обслуговувати обидва добре.
Фікс: частковий індекс.
CREATE INDEX idx_jobs_completed_week
ON jobs (completed_at)
WHERE status = 'completed';
Цей індекс містить лише рядки, де status = 'completed'. Для дашбордного запиту Postgres стрибає прямо в маленький індекс (лише ~15K з 50K рядків), сканує діапазон дат, готово. Розмір індексу: 40% від повнотабличного.
Час дашбордного запиту: 12ms. Запит диспетчера: без змін — 300ms (частковий індекс невидимий для запитів, що не відповідають WHERE-клаузі).
Решта панелей дашборду отримали аналогічні часткові індекси:
CREATE INDEX idx_jobs_created_week
ON jobs (created_at)
WHERE status = 'created';
CREATE INDEX idx_jobs_assigned_zone
ON jobs (zone_id, created_at DESC)
WHERE status IN ('assigned', 'accepted');
Загальний час завантаження дашборду: з 30+ секунд до менш ніж 400ms. Усі вісім панелей.
Що я виніс
EXPLAIN ANALYZE — не опціонально. Я витратив День 1 на вгадування замість читання. План каже тобі рівно те, що Postgres робить і чому. Якщо ти додаєш індекс без запуску EXPLAIN ANALYZE — ти робиш перфоманс-роботу із закритими очима.
Складені індекси — не магія. Порядок колонок важливий. Складений індекс (status, completed_at) марний для запитів, що не фільтрують спочатку за status. І він може активно плутати планувальник для запитів, що фільтрують за status, але сортують за іншою колонкою.
Часткові індекси недовикористовуються. Якщо в таблиці є колонка status і більшість запитів фільтрують за конкретним значенням статусу — частковий індекс з WHERE status = 'value' менший, швидший і не заважає іншим запитам. Це правильний інструмент, коли різні запити потребують різних патернів доступу до однієї таблиці.
Тестуй запит, який ти не оптимізуєш. Мій фікс Дня 2 пришвидшив дашборд і зламав в'юху диспетчера. Кожну зміну індексу треба тестувати проти всіх критичних запитів, а не лише того, на якому ти сфокусований. Тепер у мене в кожному проєкті є файл critical_queries.sql — 5–10 запитів, що найважливіші, з очікуваними базовими показниками.
50K рядків — це ніщо. У таблиці було 50K рядків. У зрілому продукті було б 5M. Послідовний скан, що займає 6 секунд при 50K, займав би 10 хвилин при 5M. Проблеми з індексами не старіють — вони накопичуються. Правильний час для лагодження — коли вперше помічаєш сповільнення, а не коли таблиця в 100 разів більша.
Мета-урок
Три дні на однорядковий фікс — звучить соромно. Але фікс був не в рядку — він був у розумінні, чому саме цей рядок працює, а три інші ні. Якби я задеплоїв складений індекс із Дня 2, це була б регресія, замаскована під оптимізацію.
Дорога частина перфоманс-роботи — ніколи не CREATE INDEX. Це читання плану, розуміння патернів доступу і перевірка, що твій фікс не ламає запити, на які ти не дивишся.
Якщо твій дашборд гальмує і ти не розумієш чому — напиши. Іноді це один індекс. Іноді — запит. Іноді — схема. Але майже ніколи — «треба переписати все».