Не все index scans равны: как мы сократили latency запросов на 99%

Когда вы оптимизируете SQL-запрос, первая интуиция: «он должен использовать индекс». Если EXPLAIN показывает Index Scan или Index Seek — все довольны.

Но реальность сложнее: index scan может быть ужасно медленным. Планировщик БД выбирает индекс, который технически подходит под запрос, и начинает сканировать. Если индекс слабо-селективный — он прокачивает миллионы строк ради тысячи результатов. С точки зрения EXPLAIN — всё хорошо. С точки зрения p99 latency — катастрофа.

Реальный кейс

Компонент Datadog обращался к Postgres-таблице со схемой:

CREATE TABLE events (
  id        BIGSERIAL PRIMARY KEY,
  org_id    BIGINT NOT NULL,
  event_ts  TIMESTAMPTZ NOT NULL,
  kind      TEXT,
  payload   JSONB,
  -- ...
);
CREATE INDEX idx_events_org ON events (org_id);
CREATE INDEX idx_events_kind ON events (kind);

Запрос — типичная аналитика:

SELECT *
FROM events
WHERE org_id = $1
  AND kind = 'login'
  AND event_ts > NOW() - INTERVAL '1 hour'
ORDER BY event_ts DESC
LIMIT 100;

P99 latency — 6 секунд. P50 — 800 мс. План:

Limit  (cost=0.43..2841.55 rows=100 width=...)
  ->  Index Scan using idx_events_kind on events
        Index Cond: (kind = 'login')
        Filter: (org_id = $1 AND event_ts > ...)

Формально — index scan. По факту — Postgres шёл по индексу idx_events_kind, потому что kind = 'login' показался ему селективным условием. Но login-событий было 40% от таблицы. Чтобы найти 100 строк нужного org_id за последний час, БД сканировала миллионы записей login-событий, проверяя каждую на org_id и timestamp.

Как обнаружить такие сканы

Datadog Database Monitoring (DBM) собирает:

  • Buffers read / shared blocks hit — реальное количество страниц БД, которые запрос прочёл из памяти / диска.

  • Rows examined vs rows returned — фундаментальное соотношение. Если examined = 5_000_000 и returned = 100 — это red flag, даже если план говорит «index scan».

  • Wait events — что запрос ждал (CPU, IO, lock).

В DBM-консоли запросы сортируются по «efficiency score»: rows returned ÷ rows examined. Запросы с низким score попадают в топ — и это первое, на что смотрит DBA.

Что мы увидели

Для проблемного запроса в DBM показал:

  • Rows examined: ~3M в среднем за вызов.

  • Rows returned: 100.

  • Efficiency: 0.003%.

  • P99 latency: 6s.

  • Buffers read: ~25_000 страниц = 200 МБ за запрос.

Это не «индекс работает медленно». Это «индекс выбран неправильный, и сканируется кратно больше, чем нужно».

Решение

Два шага:

  1. Compound index на (org_id, event_ts DESC), потому что реальный фильтр — это «эти org внутри окна по времени»:

    CREATE INDEX idx_events_org_ts
      ON events (org_id, event_ts DESC);
  2. (Опционально) Partial index, чтобы дополнительно сократить размер:

    CREATE INDEX idx_events_org_ts_login
      ON events (org_id, event_ts DESC)
      WHERE kind = 'login';

После миграции EXPLAIN выбирает новый составной индекс. Планировщик идёт:

  • в дерево по org_id (быстро),

  • в нужное окно по event_ts (быстро),

  • фильтрует kind = 'login' уже после.

Для нашего workload — оптимально.

Результат

  • Rows examined: ~150 (вместо 3M).

  • P99: 34 мс (вместо 6 с) — улучшение на 99.4%.

  • Buffers read: 8 страниц.

Эти цифры мы увидели в DBM-консоли в режиме «before/after» и подтвердили на production-трафике.

Уроки

«Index scan» в плане — не сигнал, что всё хорошо

План показывает какой индекс выбран. Не насколько эффективно он используется. Метрика rows examined / rows returned — лучше любого EXPLAIN-вывода.

Слабо-селективные колонки в одиночных индексах — антипаттерн

Если значение kind = 'login' встречается в 40% строк — индекс по kind бесполезен. Он будет либо полностью игнорироваться (если планировщик догадается), либо использоваться плохо. В обоих случаях — лишний размер на диске и в RAM, нагрузка на VACUUM.

Compound и partial — основные инструменты борьбы

Если у вас типичный workload «список по orgs за последний час» — compound по (org_id, event_ts) бьёт два разных индекса. Partial индексы дают ещё одно сокращение, если у вас есть «горячий» подмножество.

Observability важнее догадок

Без DBM (или эквивалентного pg_stat_statements + анализ buffers) такие баги не_заметны. EXPLAIN выглядит нормально. Запрос проходит. Только latency-распределение по реальному трафику показывает проблему. На синтетическом дев-трафике этот баг бы вообще не воспроизвёлся, потому что таблица маленькая и любой план быстрый.

Главное

«Запрос использует индекс» — это не оценка качества. Это констатация выбора планировщика. Оценка качества — это сколько работы запрос проделывает на каждую возвращённую строку. Когда это соотношение хуже 1:1000, у вас наверняка inefficient scan, который ждёт, что его найдут — обычно во время инцидента.