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

Грабли производительности

junior~25 мин

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

В прошлом уроке мы научились читать отчёт explain целиком. Теперь соберём типичные ошибки, которые в этом отчёте видны сразу. Почти все они выглядят невинно: индекс есть, запрос короткий, результат правильный. А сервер при этом читает всю коллекцию. Это последний урок кластера про индексы и производительность.

Грабли в траве

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

Все примеры сняты с настоящего MongoDB 8 на коллекции из ста тысяч заказов из урока про ESR. На ней лежат индексы из двух прошлых уроков, в том числе customer_1_amount_1, а функция plan выводит этапы и счётчики.

Регулярное выражение без якоря

Менеджер ищет покупателей по части кода:

plan(db.orders.find({ customer: /42$/ }))
plan(db.orders.find({ customer: /^c42/ }))
plan(db.orders.find({ customer: /^C42/i }))
{ stages: 'FETCH > IXSCAN', nReturned: 1000, keys: 100000, docs: 1000 }
{ stages: 'FETCH > IXSCAN', nReturned: 550, keys: 551, docs: 550 }
{ stages: 'FETCH > IXSCAN', nReturned: 550, keys: 100000, docs: 550 }

Во всех трёх случаях в плане IXSCAN, и это обманчиво. Первый и третий запрос прочитали все сто тысяч ключей.

Индекс упорядочен по строкам, как словарь. Если регулярное выражение начинается с якоря ^ и известных букв, сервер находит в словаре место, где начинается c42, и читает до конца этого куска. Это второй запрос: 551 ключ. А «оканчивается на 42» в словаре не найти, придётся перебрать всё. Флаг i, без учёта регистра, тоже ломает диапазон: C42 и c42 в индексе стоят в разных местах.

Правило: по индексу ищут префикс, /^текст/, с учётом регистра. Если нужен поиск по середине слова, это задача для текстового индекса из урока про типы индексов или отдельного поиска. Регистр решают, храня рядом поле в нижнем регистре. Та же проблема в PostgreSQL с LIKE '%…', мы разбирали её в уроке про неиспользуемый индекс.

Отрицание: $ne и $nin

plan(db.orders.find({ status: { $ne: 'delivered' } }))
plan(db.orders.find({ status: { $nin: ['new', 'paid'] } }))
{
  stages: 'FETCH > IXSCAN',
  nReturned: 75003,
  keys: 75004,
  docs: 75003
}
{
  stages: 'FETCH > IXSCAN',
  nReturned: 49994,
  keys: 49996,
  docs: 49994
}

Индекс используется, лишних ключей почти нет, но запрос всё равно тяжёлый: «всё, кроме доставленных» это три четверти коллекции. Индекс полезен, когда запрос отбирает малую долю документов, мы видели это ещё в уроке про explain. Отрицание почти всегда отбирает большую.

Есть и вторая ловушка: $ne и $nin находят документы, где поля нет вовсе, мы видели это в уроке про операторы запроса. Если тебе нужны «новые и оплаченные», напиши это прямо: { status: { $in: ['new', 'paid'] } }. Запрос станет и точнее, и понятнее.

Большой skip

Страница списка заказов, листаем далеко:

plan(db.orders.find().sort({ _id: 1 }).skip(90000).limit(10))
plan(db.orders.find({ _id: { $gt: 89999 } }).sort({ _id: 1 }).limit(10))
{
  stages: 'LIMIT > FETCH > SKIP > IXSCAN',
  nReturned: 10,
  keys: 90010,
  docs: 10
}
{ stages: 'LIMIT > FETCH > IXSCAN', nReturned: 10, keys: 10, docs: 10 }

skip(90000) не перепрыгивает девяносто тысяч ключей, он их проходит один за другим. Чем дальше страница, тем медленнее. Второй запрос просит «десять после последнего показанного _id» и читает ровно десять ключей на любой странице. Это пагинация по последнему _id из урока про сортировку и пагинацию. Для коротких списков skip годится, для длинных лент берут второй способ.

урок из курса

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

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