Перейти к содержанию
Educora
Университет30 мин20 / 22

Оптимизация запросов и индексы

Узнай, как работает индекс B-дерево, как читать план запроса, как писать условия, пригодные для индекса (sargable), что такое составные и покрывающие индексы и когда индекс вредит.

Проверь себя
В этом уроке ты узнаешь
  • Оценивать пользу индекса, вычисляя высоту B-дерева и селективность
  • Читать вывод EXPLAIN QUERY PLAN и EXPLAIN ANALYZE и переписывать условия, не пригодные для индекса
  • Проектировать составные и покрывающие индексы и понимать, когда индекс создавать не нужно

В таблице с десятью миллионами заказов запрос, находящий заказы одного покупателя, выполняется 8 секунд; с правильным индексом тот же запрос работает миллисекунды. SQL — декларативный язык: мы пишем, что хотим получить, а как это найти, выбирает оптимизатор СУБД. Он сравнивает несколько возможных планов и на основе статистики таблиц берёт самый дешёвый. В этом уроке мы разберёмся, как «думает» оптимизатор: как устроен индекс, как читать план и как писать запросы, облегчающие его работу. Хорошая новость: особые инструменты не нужны — всё начинается с команды EXPLAIN и нескольких простых правил.

Индекс B-дерево изнутри

В большинстве СУБД индекс по умолчанию — B-дерево (точнее, B+ дерево): ключи хранятся в отсортированном виде на страницах диска, в каждой внутренней странице — сотни ключей и указатели на уровень ниже, а листья ссылаются на строки и связаны друг с другом в цепочку. Дерево всегда сбалансировано: путь от корня до любого листа одной длины. Поэтому поиск занимает всего несколько чтений страниц, а цепочка листьев делает дешёвыми запросы по диапазону (BETWEEN, >, ORDER BY).

h = ⌈ log N ÷ log f ⌉
где:
  • hвысота дерева — число страниц, читаемых для поиска одного ключа
  • Nчисло ключей (строк) в индексе
  • fкоэффициент ветвления — сколько ключей помещается на странице (обычно сотни)

Высота растёт логарифмически с числом строк: когда таблица вырастает в 500 раз, у дерева появляется всего один новый уровень.

Пример 1: индекс или полный просмотр?

В таблице orders N = 10⁸ строк, на страницу помещается 100 строк, коэффициент ветвления индекса f = 500. Сколько страниц придётся прочитать, чтобы найти 12 заказов одного покупателя, с индексом и без него?

Показать решение
1) Полный просмотр: читается вся таблица — 10⁸ ÷ 100 = 10⁶ страниц.
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 = n ÷ N
где:
  • sселективность условия (от 0 до 1)
  • nчисло строк, удовлетворяющих условию
  • Nвсе строки таблицы

Малая s (условие возвращает мало строк) хороша для индекса; при большой s полный просмотр может оказаться дешевле.

Условия, пригодные для индекса (sargable)

Sargable (search argument able) — условие, пригодное для поиска по индексу: столбец стоит «голым» по одну сторону сравнения. Если применить к столбцу функцию или арифметику (strftime('%m', order_date) = '03', price * 1.18 > 100), отсортированные значения индекса бесполезны, и СУБД приходится проверять каждую строку. Достаточно переписать то же условие как диапазон.

SQL
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 — проверяется каждая запись индекса; индекс выбран лишь потому, что он меньше таблицы.
SQL
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, исчезает отдельный шаг сортировки.

Пример 2: исправляем медленный запрос

Запрос из личного кабинета покупателя: 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. Как его ускорить?

Показать решение
1) Сделай условие по дате sargable: 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.
SQL
-- 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
SQL
-- 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.

SQL
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
PostgreSQL (не запускается, пример вывода). Rows Removed by Filter — строки, прочитанные впустую.

Когда индекс не нужен

  • Маленькие таблицы — просмотреть таблицу в несколько страниц дешевле, чем спускаться по дереву.
  • Низкая селективность — на столбце с двумя значениями вроде is_active условие возвращает половину строк.
  • Таблицы с интенсивной записью — каждый индекс обновляется при каждом INSERT, UPDATE и DELETE; для журналов и данных с датчиков это дорого.
  • Дублирующиеся индексы — если есть (customer_id, order_date), отдельный индекс (customer_id) лишний: его заменяет левый префикс.
Задание

Создай индекс по order_date и выбери заказы первого квартала 2025 года (январь–март) с помощью условия-диапазона, пригодного для индекса: id и order_date, отсортированные по дате и id. Не используй strftime.

Задание · SQL
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 знак после запятой). Отсортируй по числу строк по убыванию, затем по категории.

Задание · SQL
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.

1 / 10
В таблице 250 000 строк, коэффициент ветвления f = 500. Какова высота B-дерева?