Skip to main content
Назад до блогу
5 хв читання

Індекс у Postgres, на пошук якого пішло 3 дні

Запит дашборду, що мав відпрацьовувати за 40ms, займав 6 секунд. Фікс — одна команда CREATE INDEX. Знайти, яка саме, зайняло три дні.

performancecase-study

Адмінський дашборд логістичного 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. Це читання плану, розуміння патернів доступу і перевірка, що твій фікс не ламає запити, на які ти не дивишся.


Якщо твій дашборд гальмує і ти не розумієш чому — напиши. Іноді це один індекс. Іноді — запит. Іноді — схема. Але майже ніколи — «треба переписати все».