- Оценивать пользу индекса, вычисляя высоту B-дерева и селективность
- Читать вывод
EXPLAIN QUERY PLANиEXPLAIN ANALYZEи переписывать условия, не пригодные для индекса - Проектировать составные и покрывающие индексы и понимать, когда индекс создавать не нужно
В таблице с десятью миллионами заказов запрос, находящий заказы одного покупателя, выполняется 8 секунд; с правильным индексом тот же запрос работает миллисекунды. SQL — декларативный язык: мы пишем, что хотим получить, а как это найти, выбирает оптимизатор СУБД. Он сравнивает несколько возможных планов и на основе статистики таблиц берёт самый дешёвый. В этом уроке мы разберёмся, как «думает» оптимизатор: как устроен индекс, как читать план и как писать запросы, облегчающие его работу. Хорошая новость: особые инструменты не нужны — всё начинается с команды EXPLAIN и нескольких простых правил.
Индекс B-дерево изнутри
В большинстве СУБД индекс по умолчанию — B-дерево (точнее, B+ дерево): ключи хранятся в отсортированном виде на страницах диска, в каждой внутренней странице — сотни ключей и указатели на уровень ниже, а листья ссылаются на строки и связаны друг с другом в цепочку. Дерево всегда сбалансировано: путь от корня до любого листа одной длины. Поэтому поиск занимает всего несколько чтений страниц, а цепочка листьев делает дешёвыми запросы по диапазону (BETWEEN, >, ORDER BY).
- hвысота дерева — число страниц, читаемых для поиска одного ключа
- Nчисло ключей (строк) в индексе
- fкоэффициент ветвления — сколько ключей помещается на странице (обычно сотни)
Высота растёт логарифмически с числом строк: когда таблица вырастает в 500 раз, у дерева появляется всего один новый уровень.
В таблице orders N = 10⁸ строк, на страницу помещается 100 строк, коэффициент ветвления индекса f = 500. Сколько страниц придётся прочитать, чтобы найти 12 заказов одного покупателя, с индексом и без него?
Показать решениеСкрыть решение
2) Высота дерева: log 10⁸ ÷ log 500 = 8 ÷ 2,70 ≈ 2,96, с округлением вверх h = 3 страницы.
3) В худшем случае найденные 12 строк лежат на 12 разных страницах: всего ≈ 3 + 12 = 15 страниц.
4) Разница: 10⁶ ÷ 15 ≈ в 67 000 раз меньше чтений. Проверка здравым смыслом: если бы нужно было 30% строк, чтение разбросанных страниц по одной обошлось бы дороже последовательного просмотра — тогда оптимизатор выбирает полный просмотр.
- sселективность условия (от 0 до 1)
- nчисло строк, удовлетворяющих условию
- Nвсе строки таблицы
Малая s (условие возвращает мало строк) хороша для индекса; при большой s полный просмотр может оказаться дешевле.
Условия, пригодные для индекса (sargable)
Sargable (search argument able) — условие, пригодное для поиска по индексу: столбец стоит «голым» по одну сторону сравнения. Если применить к столбцу функцию или арифметику (strftime('%m', order_date) = '03', price * 1.18 > 100), отсортированные значения индекса бесполезны, и СУБД приходится проверять каждую строку. Достаточно переписать то же условие как диапазон.
CREATE INDEX idx_orders_date ON orders (order_date);
-- not sargable: a function on the column
EXPLAIN QUERY PLAN
SELECT id FROM orders
WHERE strftime('%m', order_date) = '03';▸ Ожидаемый результат
id | parent | notused | detail 2 | 0 | 214 | SCAN orders USING COVERING INDEX idx_orders_date
SCAN — проверяется каждая запись индекса; индекс выбран лишь потому, что он меньше таблицы.CREATE INDEX idx_orders_date ON orders (order_date);
-- sargable: a range on the bare column
EXPLAIN QUERY PLAN
SELECT id FROM orders
WHERE order_date >= '2025-03-01' AND order_date < '2025-04-01';▸ Ожидаемый результат
id | parent | notused | detail 2 | 0 | 154 | SEARCH orders USING COVERING INDEX idx_orders_date (order_date>? AND order_date<?)
SEARCH — спуск по дереву к началу диапазона и чтение только нужных листьев. SQLite хранит id в каждом индексе, поэтому индекс «покрывающий». Первые три числа зависят от версии, важен столбец detail.Составные и покрывающие индексы
Составной индекс отсортирован по нескольким столбцам: (customer_id, order_date) сначала по покупателю, а внутри одного покупателя — по дате. Он работает как телефонный справочник: искать можно по фамилии или по фамилии и имени, но не по одному имени — это правило левого префикса. Если индекс содержит все столбцы, нужные запросу, СУБД вообще не обращается к таблице — такой индекс называют покрывающим (covering). Кроме того, когда порядок индекса совпадает с ORDER BY, исчезает отдельный шаг сортировки.
Запрос из личного кабинета покупателя: SELECT id, order_date FROM orders WHERE customer_id = 1 AND strftime('%Y', order_date) = '2025' ORDER BY order_date. В его плане — SCAN orders и USE TEMP B-TREE FOR ORDER BY. Как его ускорить?
Показать решениеСкрыть решение
order_date >= '2025-01-01' AND order_date < '2026-01-01'.2) Создай составной индекс: сначала столбец равенства, затем столбец диапазона и сортировки —
(customer_id, order_date).3) Индекс ищет точно по
customer_id и по диапазону order_date и сразу отдаёт строки в порядке дат — временная сортировка исчезает.4) Поскольку
id тоже есть в индексе, обращаться к таблице не нужно: план показывает SEARCH ... USING COVERING INDEX.-- before: function on the column, no suitable index
EXPLAIN QUERY PLAN
SELECT id, order_date FROM orders
WHERE customer_id = 1 AND strftime('%Y', order_date) = '2025'
ORDER BY order_date;▸ Ожидаемый результат
id | parent | notused | detail 3 | 0 | 216 | SCAN orders 15 | 0 | 0 | USE TEMP B-TREE FOR ORDER BY
-- after: sargable range + composite index
CREATE INDEX idx_orders_cust_date ON orders (customer_id, order_date);
EXPLAIN QUERY PLAN
SELECT id, order_date FROM orders
WHERE customer_id = 1
AND order_date >= '2025-01-01' AND order_date < '2026-01-01'
ORDER BY order_date;▸ Ожидаемый результат
id | parent | notused | detail 3 | 0 | 46 | SEARCH orders USING COVERING INDEX idx_orders_cust_date (customer_id=? AND order_date>? AND order_date<?)
В PostgreSQL EXPLAIN показывает план и оценку стоимости, а EXPLAIN ANALYZE реально выполняет запрос и показывает фактическое время. Ниже — типичный результат на таблице из 1 000 000 строк до и после создания индекса (числа зависят от оборудования). cost=начальная..полная — в условных единицах, rows — оценка оптимизатора; если она сильно отличается от фактического rows, статистика устарела и нужен ANALYZE.
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;
CREATE INDEX idx_orders_customer ON orders (customer_id);
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;Seq Scan on orders (cost=0.00..20834.00 rows=10 width=24) (actual time=0.018..61.922 rows=12 loops=1) Filter: (customer_id = 42) Rows Removed by Filter: 999988 Planning Time: 0.081 ms Execution Time: 61.950 ms Index Scan using idx_orders_customer on orders (cost=0.42..44.58 rows=10 width=24) (actual time=0.024..0.039 rows=12 loops=1) Index Cond: (customer_id = 42) Planning Time: 0.210 ms Execution Time: 0.061 ms
Rows Removed by Filter — строки, прочитанные впустую.Когда индекс не нужен
- Маленькие таблицы — просмотреть таблицу в несколько страниц дешевле, чем спускаться по дереву.
- Низкая селективность — на столбце с двумя значениями вроде
is_activeусловие возвращает половину строк. - Таблицы с интенсивной записью — каждый индекс обновляется при каждом
INSERT,UPDATEиDELETE; для журналов и данных с датчиков это дорого. - Дублирующиеся индексы — если есть
(customer_id, order_date), отдельный индекс(customer_id)лишний: его заменяет левый префикс.
Создай индекс по order_date и выбери заказы первого квартала 2025 года (январь–март) с помощью условия-диапазона, пригодного для индекса: id и order_date, отсортированные по дате и id. Не используй strftime.
CREATE INDEX idx_orders_date ON orders (order_date);
-- slow version: WHERE strftime('%m', order_date) IN ('01', '02', '03')
SELECT id, order_date
FROM orders
-- WHERE: a range on the bare column
ORDER BY order_date, id;▸ Ожидаемый результат
id | order_date 1 | 2025-01-15 2 | 2025-01-15 3 | 2025-02-02 4 | 2025-02-10 5 | 2025-03-05 6 | 2025-03-18
Оцени пользу индекса по products.category: для каждой категории выведи число строк (rows_count) и селективность в процентах (selectivity_pct, 1 знак после запятой). Отсортируй по числу строк по убыванию, затем по категории.
SELECT category,
COUNT(*) AS rows_count
-- selectivity_pct = 100 * n / N
FROM products
GROUP BY category
ORDER BY rows_count DESC, category;▸ Ожидаемый результат
category | rows_count | selectivity_pct Electronics | 4 | 40 Accessories | 2 | 20 Stationery | 2 | 20 Games | 1 | 10 Home | 1 | 10
Главное
- B-дерево сбалансировано: его высота h = ⌈log N ÷ log f⌉, поэтому поиск требует лишь нескольких чтений страниц.
- Индекс окупается при малой селективности s = n ÷ N; при большой оптимизатор может выбрать полный просмотр.
- В условии, пригодном для индекса, столбец остаётся «голым»: функции и арифметику переносим на сторону константы, для дат пишем диапазон.
- Составной индекс подчиняется правилу левого префикса: сначала столбец равенства, затем диапазона/сортировки; покрывающий индекс избавляет от обращения к таблице.
- Проверяй планы через
EXPLAIN, избегай индексов на маленьких, малоселективных и интенсивно изменяемых таблицах и измеряй эффект каждого изменения.
Проверь себя
Вопросов: 10. Каждый правильный ответ приносит XP.