В прошлом уроке мы научились читать отчёт 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 ₽.
Оплата пока не подключена: премиум и курсы открываются промокодом.