PostgreSQL
Covering Index и Index-Only Scan
Дашборд показывает заказы пользователя за последние 30 дней - запрос на 500 строк. EXPLAIN: Index Scan, 800 buffers, 12 мс. Добавление одной строки `INCLUDE (amount, status)` к существующему индексу: Index Only Scan, 6 buffers, 0.5 мс. Тот же запрос, тот же сервер, без переписывания SQL - в 24 раза быстрее. Это и есть covering index в одной строчке DDL.
- **Stripe**: дашборды активности мерчантов используют covering-индексы на (merchant_id, created_at DESC) INCLUDE (amount, currency, status) - сокращение p99 latency с 200мс до 15мс
- **GitHub**: страница 'мои PR' опирается на covering index на pulls с включёнными title/state/updated_at - выдерживает миллионы запросов в час без чтения heap
- **Notion**: workspace-индекс на page-table с INCLUDE для title/icon/updated_at позволяет рендерить сайдбар одним Index Only Scan, без N+1 fetches
Что такое covering index
Обычный B-Tree индекс хранит ключ и ctid (указатель на строку в heap). При выполнении `SELECT amount FROM orders WHERE user_id = 42` PostgreSQL находит листовой узел индекса за 3-4 обращения к диску, затем читает heap-страницу, чтобы достать `amount`. Это **Index Scan**: индекс отвечает на условие WHERE, но heap нужен для возврата дополнительных столбцов. **Covering index** включает в индекс все необходимые столбцы запроса - тогда heap читать не нужно. Запрос обслуживается одним проходом по индексу, без random I/O в основную таблицу.
Термин 'covering' означает, что индекс **покрывает** запрос: все столбцы, которые SELECT хочет вернуть, физически содержатся в листовых узлах индекса. До PostgreSQL 11 covering достигался только через включение столбца в ключ (`CREATE INDEX ... (user_id, amount)`), что увеличивало размер индекса и заставляло сортировать по второму столбцу. С 11-й версии появилось ключевое слово `INCLUDE` - столбцы хранятся в листьях без участия в навигации и сортировке.
В чём разница между `INDEX (user_id, amount)` и `INDEX (user_id) INCLUDE (amount)` с точки зрения PostgreSQL?
Синтаксис INCLUDE и стратегия колонок
При проектировании covering index стратегия проста: ключевые столбцы - те, по которым идёт фильтрация и сортировка; INCLUDE-столбцы - те, что только возвращаются. Если запрос делает `WHERE user_id = ? AND created_at > ?` и возвращает `(id, amount, status)`, то ключ - `(user_id, created_at)`, INCLUDE - `(id, amount, status)`. Преимущество перед составным ключом из всех пяти столбцов: меньше байт на ключ, выше branching factor, быстрее навигация. INCLUDE-столбцы хранятся только в листьях, а не на каждом уровне дерева.
Не каждый запрос стоит покрывать. Стоимость covering index: запись становится дороже (нужно обновлять INCLUDE-данные при UPDATE), индекс занимает больше места на диске и в shared_buffers. Покрытие имеет смысл для горячих SELECT-запросов с предсказуемым набором возвращаемых столбцов. Покрытие SELECT * почти всегда неверный выбор - тащит все TOAST-данные в индекс.
Запрос `SELECT id, total FROM invoices WHERE customer_id = ? ORDER BY issued_at DESC LIMIT 50`. Какая структура индекса оптимальна для covering?
Index Only Scan: механика без обращения в heap
**Index Only Scan** - план выполнения, при котором PostgreSQL возвращает данные прямо из индекса, не читая heap. Это сильно дешевле обычного Index Scan: одна страница индекса вместо двух обращений (индекс + heap). Для запроса по 100 строкам с random I/O в heap разница может составлять 10-20x. Но есть тонкость: PostgreSQL хранит видимость версий строк (MVCC visibility) только в heap, не в индексе. Чтобы решить, видима ли строка текущей транзакции, обычно нужно сверяться с heap-страницей. Index Only Scan возможен только если эта проверка не нужна.
PostgreSQL хранит каждую версию строки в heap отдельно (MVCC). Индекс может ссылаться на устаревшую версию или на ту, что ещё не закоммитчена. Чтобы определить, валидна ли версия для текущей транзакции, в общем случае нужно читать heap-страницу и смотреть xmin/xmax. Это сводит на нет выгоду covering-индекса, если каждая строка всё равно требует heap fetch.
EXPLAIN показывает `Index Only Scan ... Heap Fetches: 8000` при возврате 10000 строк. Что это означает на практике?
Visibility Map и роль VACUUM
**Visibility Map** (VM) - битовая карта, по одному биту на каждую heap-страницу таблицы (по факту два бита: all-visible и all-frozen). Бит all-visible=1 означает, что каждая версия каждой строки на этой странице видна всем существующим и будущим транзакциям - то есть проверка xmin/xmax не нужна. Index Only Scan смотрит в VM перед обращением в heap: если бит установлен, heap пропускается. Бит сбрасывается на 0 при любой записи на страницу (INSERT/UPDATE/DELETE) и устанавливается обратно только VACUUM-ом.
VM хранится в отдельном forke файла таблицы (`relname_vm`). Размер VM крошечный: 1 бит на 8KB страницы = 1/65536 размера таблицы. Поэтому VM почти всегда помещается в shared_buffers целиком. Эффективность Index Only Scan напрямую зависит от свежести VM, которую поддерживает autovacuum. Высокая скорость записи без VACUUM = постоянно сброшенные биты = плохая работа covering index.
Таблица `events` принимает 5000 INSERT-в-минуту. Создан covering index, но Heap Fetches остаются высокими. Что наиболее эффективно решит проблему?
Trade-offs и измерение выигрыша
Covering index - инструмент с измеримой ценой. Выгода: чтение, которое раньше делало два I/O (индекс + heap), теперь делает один. Для запросов с малой селективностью (тысячи строк) выигрыш достигает 10x по latency. Цена: каждая запись в таблицу триггерит обновление дополнительных INCLUDE-столбцов в индексе; HOT updates становятся невозможны, если INCLUDE-столбец входит в обновляемое поле. На write-heavy системах с 80% INSERT/UPDATE добавление широкого covering index может ухудшить throughput на 15-30%. Правило: covering имеет смысл только для горячих SELECT-путей, проверенных по `pg_stat_statements`.
Метрики для решения 'добавлять ли covering': (1) частота запроса - из pg_stat_statements; (2) текущая стоимость - EXPLAIN ANALYZE с BUFFERS; (3) profile записи на таблицу - pg_stat_user_tables.n_tup_upd; (4) размер индекса - pg_size_pretty(pg_relation_size). Если запрос вызывается миллион раз в день, экономит 5мс каждый раз, а индекс добавляет 50мс к одному UPDATE из тысячи - выгода очевидна. Если запрос вызывается раз в час - индекс не окупится.
Чем больше столбцов в INCLUDE, тем эффективнее индекс
Каждый INCLUDE-столбец увеличивает размер листовой страницы и стоимость каждого UPDATE/INSERT. Полезны только те столбцы, что реально нужны конкретным горячим запросам.
Широкий covering = меньше строк на одной странице индекса = больше страниц для чтения. На границе covering превращается в worse-than-heap: размер индекса приближается к размеру таблицы. Точное проектирование выгоднее жадного включения.
Когда covering index с INCLUDE даёт максимальный практический выигрыш?
Ключевые идеи
- **Covering index**: содержит все столбцы, которые нужны запросу - SELECT обслуживается одним проходом по индексу без обращения в heap
- **INCLUDE-столбцы** (с PostgreSQL 11): хранятся только в листьях, не участвуют в сортировке и навигации - дают covering без раздувания дерева
- **Index Only Scan** работает только если страница помечена all-visible в visibility map; VM поддерживается VACUUM-ом, при отставании VACUUM падает обратно к Index Scan + heap fetch
- **Trade-off**: covering ускоряет read-пути в 5-10x, но добавляет write amplification - применять прицельно для горячих запросов из pg_stat_statements
Связанные темы
Covering index стоит на стыке нескольких механизмов PostgreSQL:
- B-Tree индексы — Covering - расширение обычного B-Tree через INCLUDE; принципы branching factor и составных ключей напрямую применимы к проектированию
- MVCC и VACUUM — Эффективность Index Only Scan зависит от visibility map; настройка autovacuum критична для covering на write-active таблицах
- Планировщик запросов — Выбор Index Only Scan vs Index Scan vs Bitmap Heap Scan делается на основе статистики; EXPLAIN ANALYZE - единственный объективный инструмент проверки
Вопросы для размышления
- Почему PostgreSQL не использует Index Only Scan по умолчанию для каждого индекса, ведь это всегда дешевле?
- В каких ситуациях даже корректно спроектированный covering index может оказаться медленнее обычного Index Scan + heap fetch?
- Visibility map - крошечная структура, но критична для covering. Какие ещё подобные 'derived' структуры PostgreSQL могли бы появиться для других оптимизаций?
Связанные уроки
- pg-12-btree — Covering index строится поверх B-tree структуры
- pg-14-partial-expr — Partial и expression indexes дополняют covering стратегию
- pg-18-explain — EXPLAIN ANALYZE показывает Index Only Scan для covering индексов
- ds-12-balanced-trees — B-tree - балансированное дерево, covering - надстройка над ним
- db-09-indexes-btree