Правило ESR и порядок полей в составном индексе

ESR — правило, которое подсказывает правильный порядок полей в составном индексе: сначала Equality, потом Sort, потом Range.

ESR (Equality, Sort, Range) — порядок, в котором поля должны идти в составном индексе: сперва поля с проверкой на равенство, затем поля сортировки, в конце — поля с диапазонным условием. Этот порядок даёт самый узкий и эффективный обход дерева.

В прошлом уроке мы видели, что составной индекс хранит поля по порядку: сначала сортирует по первому, внутри него — по второму. Из этого следует неочевидное: порядок полей в индексе важнее, чем порядок условий в запросе. Один и тот же набор полей, расставленный по-разному, даёт быстрый индекс или почти бесполезный. ESR — мнемоника, которая помогает не угадывать, а ставить поля правильно.

В этом уроке разберём, почему порядок решает всё, как работает префикс индекса и как заменить «зоопарк» из десятка индексов одним умным составным.

Зачем это на практике

Разработчики часто создают по отдельному индексу под каждый запрос. Через год коллекция обрастает двадцатью индексами, половина из которых дублирует друг друга по префиксу. Они зря едят память и тормозят запись. ESR позволяет спроектировать один индекс, который обслужит сразу несколько шаблонов запросов, потому что более короткие запросы пользуются префиксом длинного индекса.

Три типа условий в запросе

Чтобы применить ESR, нужно классифицировать каждое поле запроса по типу условия:

ТипЧто этоПримеры операторов
Equality (равенство)точное совпадение по одному значению{ status: "paid" }, $eq, $in
Sort (сортировка)поле в .sort().sort({ createdAt: -1 })
Range (диапазон)условие на интервал значений$gt, $gte, $lt, $lte, $ne

Возьмём типичный запрос интернет-магазина:

db.orders.find({
  status: "paid",              // Equality
  amount: { $gte: 1000 }       // Range
}).sort({ createdAt: -1 })     // Sort

Здесь status — Equality, createdAt — Sort, amount — Range. По правилу ESR индекс должен идти именно в этом порядке.

Правильный порядок: E, потом S, потом R

// ПРАВИЛЬНО — по ESR
db.orders.createIndex({ status: 1, createdAt: -1, amount: 1 })

Почему именно так? Разберём по шагам, как индекс обходит дерево:

  • Equality первым. Условие status: "paid" сразу сужает дерево до одной ветки — всех заказов со статусом paid. Это самый дешёвый способ отрезать большинство документов.
  • Sort вторым. Внутри ветки paid документы уже физически упорядочены по createdAt убыванию. MongoDB просто читает их подряд — сортировка достаётся бесплатно, не нужно собирать результат в память и сортировать его.
  • Range последним. Условие amount >= 1000 применяется к уже отсортированному потоку. Если бы Range стоял до Sort, он «разорвал» бы непрерывный диапазон, и порядок сортировки на выходе сломался бы.

Что будет при неправильном порядке

Поставим Range раньше Sort — частая ошибка:

// ПЛОХО — Range стоит перед Sort
db.orders.createIndex({ status: 1, amount: 1, createdAt: -1 })

Индекс по-прежнему быстро найдёт документы с нужным status и amount >= 1000. Но дальше беда: внутри диапазона amount значения createdAt идут вперемешку. Чтобы выдать результат, отсортированный по дате, MongoDB вынуждена собрать все найденные документы в память и отсортировать их вручную — это блокирующая сортировка (SORT-стадия в плане). На больших выборках она съедает память и упирается в лимит 32 МБ, после которого запрос падает с ошибкой, если не разрешён allowDiskUse.

В explain неправильный порядок выдаёт себя строкой SORT в стадиях плана: значит, индекс не смог обеспечить порядок сам.

Префикс индекса: один индекс вместо многих

Главная экономия от ESR — переиспользование префикса. Составной индекс обслуживает любой свой левый префикс. Возьмём индекс { status: 1, createdAt: -1, amount: 1 }. Он один покрывает сразу несколько запросов:

// 1) только status — использует префикс { status }
db.orders.find({ status: "paid" })

// 2) status + сортировка по дате — префикс { status, createdAt }
db.orders.find({ status: "paid" }).sort({ createdAt: -1 })

// 3) status + дата + диапазон — весь индекс целиком
db.orders.find({ status: "paid", amount: { $gte: 1000 } }).sort({ createdAt: -1 })

Все три запроса обслуживаются одним индексом. Создавать отдельные { status: 1 } и { status: 1, createdAt: -1 } не нужно — они избыточны, потому что являются префиксами уже существующего индекса. Это и есть «один умный индекс вместо многих».

Обратное тоже верно: запрос только по createdAt (без status) этим индексом не обслужится, потому что createdAt — не левый префикс. Если такой запрос частый, под него нужен отдельный индекс.

Как это работает под капотом

Составной индекс — это одно B-дерево, где ключ каждой записи представляет собой конкатенацию значений всех полей в заданном порядке: сначала status, затем createdAt, затем amount. Дерево отсортировано лексикографически по этому составному ключу. Поэтому фиксируя первое поле равенством, мы попадаем в непрерывный отрезок дерева, внутри которого второе поле уже отсортировано, и так далее.

Когда планировщик MongoDB выбирает индекс, он строит несколько кандидатных планов, прогоняет каждый на небольшом числе документов и оставляет тот, что быстрее находит результат. План кэшируется по форме запроса. Но планировщик не переставит поля за вас: если индекс заложен не по ESR, лучшее, что он сделает, — добавит дорогую стадию SORT. Архитектуру индекса определяете вы, а не оптимизатор.

Частые ошибки

  • Range перед Sort. Самая частая ошибка ESR: диапазон разрывает отсортированный порядок, и появляется блокирующая стадия SORT. Поле сортировки должно стоять раньше диапазонных условий.
  • Дублирующие префиксные индексы. Держать одновременно { a: 1 }, { a: 1, b: 1 } и { a: 1, b: 1, c: 1 } расточительно: первые два — префиксы третьего и не нужны.
  • Игнорирование направления для сортировки. Для одиночного поля направление не важно, но при сортировке по нескольким полям направления в индексе и в .sort() должны совпадать (или быть строго противоположными для всех полей сразу), иначе индекс не обеспечит порядок.
  • Слишком широкий составной индекс. Запихнуть восемь полей в один индекс «чтобы покрыть всё» — плохо: индекс становится огромным, а большинство запросов используют лишь короткий префикс.

Итоги

  • ESR: поля в составном индексе ставят в порядке Equality → Sort → Range.
  • Equality первым максимально сужает дерево; Sort вторым делает сортировку бесплатной; Range последним не ломает порядок.
  • Range, поставленный перед Sort, порождает дорогую блокирующую стадию SORT (лимит 32 МБ).
  • Один составной индекс обслуживает все свои левые префиксы — отдельные префиксные индексы избыточны.
  • Планировщик выбирает между существующими индексами, но не переставит поля за вас — порядок проектируете вы.
Проверьте себя
1. В каком порядке правило ESR предписывает располагать поля в составном индексе?
ARange, затем Sort, затем Equality
BSort, затем Equality, затем Range
CEquality, затем Sort, затем Range
DПорядок не важен — главное, чтобы все поля были в индексе
2. Что произойдёт, если в индексе диапазонное поле (Range) поставить раньше поля сортировки (Sort)?
AЗапрос вообще не сможет использовать индекс
BПоявится дорогая блокирующая стадия SORT — MongoDB будет сортировать результат в памяти
CИндекс автоматически переставит поля в правильный порядок
DНичего не изменится, порядок полей в индексе роли не играет
3. Сколько отдельных индексов нужно, чтобы обслужить запросы по { status }, по { status + sort(createdAt) } и по { status + amount-диапазон + sort(createdAt) }?
AТри отдельных индекса — по одному на каждый запрос
BОдин составной индекс { status, createdAt, amount }, так как остальные запросы пользуются его левым префиксом
CДва индекса: один для сортировки, другой для диапазона
DНи одного — такие запросы индекс использовать не может