Раздел 36 · MongoDB с нуля

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

junior~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке мы перебрали виды индексов и пообещали отдельный разговор про самый частый из них, составной. С ним часто ошибаются так: поля выбраны верно, а стоят в неудачном порядке. Индекс вроде бы используется, explain показывает IXSCAN, а запрос всё равно читает тысячи лишних записей и сортирует в памяти. Сегодня разберём правило, по которому порядок полей выбирают с первого раза.

Картотека заказов

Представь шкаф с карточками заказов. Менеджер каждый день спрашивает одно и то же: «покажи доставленные заказы дороже девяти тысяч, по дням, первые десять».

Если ящики шкафа подписаны по статусу, а внутри ящика карточки лежат по дням, задача решается за минуту. Открываешь ящик «доставлен», идёшь по карточкам от первого дня и откладываешь те, что дороже девяти тысяч. Набрал десять и закрыл ящик.

А если внутри ящика карточки лежат по сумме? Тогда нужные карточки найти легко, они в конце ящика. Но они перемешаны по дням. Придётся вынуть все две с половиной тысячи на стол, разложить по дням и только потом взять первые десять.

Составной индекс работает как такой шкаф: первое поле это ящики, второе это порядок карточек в ящике, третье порядок внутри одинаковых значений второго. Порядок полей решает, придётся ли раскладывать карточки на столе.

Коллекция для опытов

На девяти книгах разницы не увидеть, поэтому все примеры урока сняты с настоящего MongoDB 8 на коллекции из ста тысяч заказов. Если у тебя есть свой сервер из урока про установку, создай такую же:

use shopstats
const statuses = ['new', 'paid', 'shipped', 'delivered']
const docs = []
for (let i = 0; i < 100000; i++) {
  docs.push({ _id: i, customer: 'c' + (i % 2000), status: statuses[Math.floor(i / 7) % 4], amount: (i * 7919) % 10000, day: i % 365 })
}
db.orders.insertMany(docs)

У каждого заказа покупатель из двух тысяч, один из четырёх статусов, сумма от 0 до 9999 и номер дня в году. Доставленных заказов 24997, а доставленных дороже девяти тысяч 2502.

В уроке про explain мы писали функцию short. Сделаем её чуть умнее: пусть она показывает всю цепочку этапов плана, от внешнего к внутреннему.

const stages = (s) => s.stage + (s.inputStage ? ' > ' + stages(s.inputStage) : '')
const plan = (cursor) => {
  const s = cursor.explain('executionStats').executionStats
  return { stages: stages(s.executionStages), nReturned: s.nReturned, keys: s.totalKeysExamined, docs: s.totalDocsExamined }
}

Три индекса на один запрос

Вот запрос менеджера:

const q = () => db.orders.find({ status: 'delivered', amount: { $gte: 9000 } }).sort({ day: 1 }).limit(10)

В нём три вида условий, и у каждого своя буква:

  • E, equality, равенство: status: 'delivered', поле сравнивается с одним значением.
  • S, sort, сортировка: sort({ day: 1 }).
  • R, range, диапазон: amount: { $gte: 9000 }, поле попадает в промежуток.

Построим три индекса с этими полями в разном порядке:

db.orders.createIndex({ status: 1, amount: 1, day: 1 })
db.orders.createIndex({ status: 1, day: 1, amount: 1 })
db.orders.createIndex({ amount: 1, status: 1, day: 1 })

Чтобы сравнить их честно, заставим сервер выполнить запрос по каждому через hint. Метод принимает имя индекса:

plan(q().hint('status_1_amount_1_day_1'))
plan(q().hint('status_1_day_1_amount_1'))
plan(q().hint('amount_1_status_1_day_1'))
{
  stages: 'FETCH > SORT > IXSCAN',
  nReturned: 10,
  keys: 2502,
  docs: 10
}
{ stages: 'LIMIT > FETCH > IXSCAN', nReturned: 10, keys: 12, docs: 10 }
{
  stages: 'FETCH > SORT > IXSCAN',
  nReturned: 10,
  keys: 3502,
  docs: 10
}

Сведём в таблицу:

ИндексПорядокЭтапыПрочитано ключей
status, amount, dayE, R, SFETCH > SORT > IXSCAN2502
status, day, amountE, S, RLIMIT > FETCH > IXSCAN12
amount, status, dayR, E, SFETCH > SORT > IXSCAN3502

Ради десяти заказов второй индекс прочитал двенадцать ключей, первый в двести раз больше, третий почти в триста.

урок из курса

Урок из курса «MongoDB с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.