Правило 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 МБ).
- Один составной индекс обслуживает все свои левые префиксы — отдельные префиксные индексы избыточны.
- Планировщик выбирает между существующими индексами, но не переставит поля за вас — порядок проектируете вы.