Анализ селективности
Skip-индексы — мощный инструмент, но они не бесплатны. Создание skip-индекса на каждый столбец “на всякий случай” — распространённая ошибка, которая увеличивает время вставки и размер данных без выигрыша в запросах. В этом уроке мы разберём, когда skip-индексы действительно помогают, когда вредят, и как это измерить.
Когда skip-индексы помогают
Skip-индекс эффективен, когда выполняются три условия одновременно:
1. Фильтр отсекает большую долю гранул
Skip-индекс пропускает блоки гранул, где нужных данных нет. Если ваш фильтр отсекает 90% данных — skip-индекс пропустит большинство блоков. Если фильтр отсекает 5% — почти все блоки всё равно будут прочитаны.
-- Хороший кандидат: status='ERROR' встречается в 1% строк
-- Skip-индекс пропустит ~99% блоков
SELECT * FROM logs WHERE status = 'ERROR';
-- Плохой кандидат: status='OK' встречается в 95% строк
-- Skip-индекс пропустит максимум 5% блоков -- почти бесполезен
SELECT * FROM logs WHERE status = 'OK';
2. Хорошая data locality (значения сгруппированы)
Skip-индекс агрегирует информацию по блоку гранул. Если значение “ERROR” равномерно размазано по всем гранулам (в каждой грануле хотя бы один “ERROR”), то skip-индекс не сможет исключить ни одну гранулу, даже если “ERROR” составляет всего 1% строк.
Если же значения сгруппированы (например, все ошибки произошли в определённый временной интервал), skip-индекс эффективно исключит гранулы без ошибок.
Data locality напрямую связана с ORDER BY таблицы. Столбцы, коррелирующие с ORDER BY ключом (например, error_code коррелирует с timestamp — ошибки кластеризуются по времени), показывают лучшую data locality для skip-индексов.
3. Запросы предсказуемы и повторяются
Skip-индекс имеет смысл, если определённый паттерн фильтрации используется регулярно. Создавать skip-индекс для ad-hoc запроса, выполняемого раз в месяц, нерационально — overhead хранения и вставки перевесит выигрыш.
Когда skip-индексы вредят
Случай 1: случайное распределение данных
Если значения столбца распределены случайно по гранулам, skip-индекс не сможет исключить ни одну гранулу. Например, UUID или случайный hash в каждой грануле будет содержать разные значения — minmax покажет огромный диапазон, set(N) переполнится, bloom_filter будет срабатывать на каждом блоке.
-- Плохо: random_id распределён случайно по гранулам
-- minmax: min=1, max=1000000 в каждом блоке → ничего не пропускает
INDEX idx_random random_id TYPE minmax GRANULARITY 4
Случай 2: overhead превышает выигрыш
Каждый skip-индекс добавляет:
- Время вставки: вычисление индекса при каждом INSERT
- Хранение: файл индекса на каждый part
- Merge overhead: пересчёт индекса при слиянии parts
Если запросы с фильтром по столбцу выполняются редко или сканируют большинство данных, overhead индекса превысит экономию на I/O.
Случай 3: слишком мелкая GRANULARITY
GRANULARITY=1 создаёт запись индекса на каждую гранулу — максимальная точность, но и максимальный размер индекса. Для таблиц с миллиардами строк это может означать миллионы записей skip-индекса, что само по себе замедляет обработку запросов.
Случай 4: skip-индекс на столбце, уже в ORDER BY
Если столбец входит в ORDER BY ключ, primary.idx уже обеспечивает pruning гранул для этого столбца. Добавлять skip-индекс бесполезно — primary key уже делает ту же работу.
-- Бессмысленно: event_date уже первый столбец ORDER BY
CREATE TABLE events (
event_date Date,
user_id UInt64,
INDEX idx_date event_date TYPE minmax GRANULARITY 4
) ENGINE = MergeTree()
ORDER BY (event_date, user_id);
Мониторинг: EXPLAIN indexes=1
Главный инструмент для оценки эффективности skip-индекса — EXPLAIN с параметром indexes = 1:
EXPLAIN indexes = 1
SELECT count()
FROM events
WHERE status = 'ERROR';
Пример вывода:
Expression ((Project names + Projection))
Aggregating
Expression (Before GROUP BY)
Filter (WHERE)
ReadFromMergeTree (default.events)
Indexes:
PrimaryKey
Keys: event_date
Condition: true
Parts: 8/8
Granules: 100/100
Skip
Name: idx_status
Description: set GRANULARITY 2
Parts: 3/8
Granules: 12/100
Как читать вывод:
PrimaryKey Parts: 8/8, Granules: 100/100— primary key не отфильтровал ничего (100 из 100 гранул)Skip Name: idx_status, Parts: 3/8, Granules: 12/100— skip-индекс сократил чтение с 100 до 12 гранул. Пропущено 88 гранул — индекс работает эффективно.
Если вывод показывает Granules: 100/100 после skip-индекса — индекс не помогает. Либо данные распределены случайно, либо выбран неподходящий тип индекса, либо GRANULARITY слишком крупная.
Мониторинг: system.part_log
Для мониторинга overhead skip-индексов используйте system.part_log:
-- Время merge с skip-индексами
SELECT
table,
event_type,
duration_ms,
rows,
size_in_bytes
FROM system.part_log
WHERE table = 'events'
AND event_type = 'MergeParts'
ORDER BY event_time DESC
LIMIT 10;
Если duration_ms значительно выросла после добавления skip-индексов, overhead может быть неоправданным.
Практический алгоритм принятия решения
Перед созданием skip-индекса задайте себе четыре вопроса:
- Столбец входит в ORDER BY? Если да — skip-индекс не нужен, primary key уже pruneит.
- Фильтр высокоселективен? Если нет (фильтр пропускает большинство строк) — skip-индекс бесполезен.
- Данные имеют data locality? Если значения равномерно размазаны по гранулам — skip-индекс не сможет ничего пропустить.
- Запрос выполняется регулярно? Если нет — overhead хранения не окупится.
Если все четыре ответа положительные — создайте skip-индекс. Выбор типа:
- Диапазонные фильтры (
>,<,BETWEEN) -> minmax - Равенство на low-cardinality -> set(N)
- Равенство/IN на high-cardinality -> bloom_filter
- Текстовый поиск -> text (не ngrambf_v1/tokenbf_v1)
Ключевые выводы
- Skip-индекс помогает, когда: фильтр высокоселективен, данные имеют хорошую data locality, запрос повторяется регулярно.
- Skip-индекс вредит при случайном распределении данных, слишком мелкой GRANULARITY, или на столбце, уже входящем в ORDER BY.
- EXPLAIN indexes = 1 — основной инструмент для проверки:
Granules: 12/100означает 88% пропущенных гранул. - Если
Granules: X/X(X = total), skip-индекс не работает — удалите его. - Всегда проверяйте эффективность после создания индекса, а не предполагайте.