Грабли производительности
Подборка типичных граблей, на которые MongoDB-проекты наступают на проде: от лимита размера документа до запросов, которые незаметно делают полный перебор.
Антипаттерн производительности — решение, которое работает на тестовых данных, но деградирует на боевом объёме: запрос замедляется, документ упирается в лимит, индекс перестаёт применяться. Большинство таких проблем предсказуемы и их видно заранее.
Предыдущие уроки дали инструменты: типы индексов, ESR и explain(). Этот урок — практический чек-лист того, что чаще всего ломает производительность MongoDB в реальных системах. Каждая грабля сопровождается тем, как её распознать и чем заменить.
Зачем это на практике
Эти проблемы редко проявляются на старте: на тысяче документов всё летает. Они выстреливают через полгода роста, когда коллекция распухла, массивы стали огромными, а запросы — медленными. Знать антипаттерны заранее дешевле, чем потом экстренно переписывать схему на работающем проде под нагрузкой.
Грабля 1: лимит документа 16 МБ
Один документ BSON не может превышать 16 мегабайт. Это жёсткий предел движка, а не настройка. Проблема возникает, когда в документ бесконечно дописывают данные: лог событий, историю, комментарии. Сначала всё хорошо, а потом вставка падает с ошибкой о превышении размера.
// антипаттерн: вся история заказов внутри одного пользователя
{
"_id": 1,
"name": "Анна",
"orderHistory": [ /* ... 50 000 заказов, растёт каждый день ... */ ]
}
Решение — выносить безгранично растущие данные в отдельную коллекцию со ссылкой. История заказов — это коллекция orders с полем userId, а не массив внутри пользователя. Эмбеддинг хорош для ограниченных по размеру данных; для растущих списков — ссылки.
Грабля 2: неограниченный рост массивов
Даже не дойдя до 16 МБ, гигантский массив внутри документа вредит. Каждое обновление документа переписывает его целиком, индексы по массиву (multikey) пухнут, а запросы $push становятся всё дороже. Массив на десятки тысяч элементов — почти всегда сигнал, что данные пора выносить.
Если же массив по смыслу ограничен (например, «последние 20 событий»), используйте $push с модификатором $slice, чтобы держать длину под контролем:
// добавить событие и оставить только 20 последних
db.users.updateOne(
{ _id: 1 },
{ $push: { recentEvents: { $each: [newEvent], $slice: -20 } } }
)
Это популярный паттерн: документ остаётся компактным и предсказуемым по размеру, а полная история (если нужна) живёт в отдельной коллекции.
Грабля 3: сортировка без индекса
Сортировка без поддерживающего индекса заставляет MongoDB загрузить весь результат в память и отсортировать его там. На это действует лимит 32 МБ: если отсортированный набор больше, запрос падает с ошибкой Sort exceeded memory limit (если явно не разрешён allowDiskUse, который к тому же медленный).
// без индекса по createdAt это блокирующая сортировка в памяти
db.events.find({ type: "click" }).sort({ createdAt: -1 })
// лечение — индекс, который сразу отдаёт данные в нужном порядке
db.events.createIndex({ type: 1, createdAt: -1 }) // по ESR
Распознать проблему просто: в explain появляется стадия SORT. Правильный составной индекс (Equality + Sort по ESR) убирает её, и сортировка достаётся бесплатно из порядка индекса.
Грабля 4: regex без префикса
Регулярные выражения коварны. Индекс по строковому полю может ускорить regex только, если шаблон привязан к началу строки (^) и не использует флаг без учёта регистра. Тогда MongoDB сужает обход индекса до диапазона с нужным префиксом.
// ХОРОШО: префиксный поиск — индекс по name работает
db.users.find({ name: /^Анн/ })
// ПЛОХО: шаблон без якоря — полный перебор индекса/коллекции
db.users.find({ name: /анн/i })
Шаблон /анн/ без ^ или с флагом i вынуждает проверить каждую строку — индекс не помогает. Для поиска «содержит подстроку» или поиска без учёта регистра берут текстовый индекс $text или внешний поиск (Atlas Search), а не regex по большой коллекции.
Грабля 5: оператор $where и JavaScript-выражения
Оператор $where позволяет фильтровать документы произвольным JavaScript-выражением. Звучит мощно, но это ловушка: $where запускает интерпретатор JS на каждом документе коллекции, не может использовать индексы и работает в разы медленнее обычных операторов. Плюс это риск инъекции, если выражение строят из пользовательского ввода.
// АНТИПАТТЕРН: JS-функция исполняется для каждого документа, индексы не работают
db.products.find({ $where: "this.price * this.qty > 1000" })
// ЛУЧШЕ: посчитать поле заранее или выразить через обычные операторы/агрегацию
db.products.find({ total: { $gt: 1000 } })
Почти любую логику $where можно выразить штатными операторами запроса или агрегацией с $expr, которые умеют работать с индексами. $where — крайнее средство, которого в нормальном коде быть не должно.
Грабля 6: антипаттерны схемы
Часть проблем растёт из самой модели данных, а не из запросов:
- Массивная коллекция-«помойка». Складывать сущности разной природы в одну коллекцию с полем-дискриминатором тяжело индексировать: запросы вынуждены фильтровать по типу, а индексы раздуваются.
- Чрезмерная нормализация. MongoDB — не реляционная БД. Разбивать всё на множество мелких коллекций и собирать через
$lookupна каждый запрос дорого:$lookupна больших данных без индекса по соединяемому полю работает как вложенный перебор. - Документы с сотнями необязательных полей. Разреженные «широкие» документы тратят место и затрудняют индексацию. Часто это признак, что разнородные данные стоило разнести.
Здоровая схема в MongoDB строится «от запросов»: данные, которые читаются вместе, обычно лежат вместе (эмбеддинг), а безгранично растущее и переиспользуемое — выносится в отдельные коллекции по ссылке.
Как это работает под капотом
Общий корень большинства граблей — отношение прочитанного к возвращённому. COLLSCAN, regex без префикса, $where и сортировка без индекса роднит одно: движок вынужден коснуться куда большего числа документов, чем отдаёт в ответе. Лимиты 16 МБ на документ и 32 МБ на сортировку — это защитные предохранители WiredTiger, которые срабатывают, когда данные перерастают «здоровый» размер операции в памяти. explain("executionStats") из прошлого урока — универсальный детектор: если totalDocsExamined на порядки больше nReturned или в плане есть COLLSCAN/SORT, вы наступили на одну из этих граблей.
Частые ошибки
- «Положу историю в массив, потом разберусь». Безграничный массив рано или поздно упрётся в 16 МБ или сделает обновления неподъёмными. Выносите растущие списки сразу.
- Поиск подстроки через regex на большой коллекции. Без якоря
^это полный перебор. Для подстрок и поиска без регистра — текстовый индекс или Atlas Search. - Сортировка по неиндексированному полю на проде. Работает на маленьких данных, падает по лимиту 32 МБ на больших. Под каждую частую сортировку — индекс по ESR.
- $where как «удобный фильтр». Он отключает индексы, медленный и небезопасный. Заменяйте обычными операторами или
$expr.
Итоги
- Документ ограничен 16 МБ — безгранично растущие данные выносите в отдельную коллекцию по ссылке.
- Гигантские массивы дороги в обновлении; ограничивайте их через
$pushс$slice. - Сортировка без индекса — блокирующая, с лимитом 32 МБ; лечится составным индексом по ESR.
- Regex ускоряется индексом только с якорем
^и без флагаi; для подстрок берут текстовый поиск. $whereзапускает JS на каждом документе, игнорирует индексы и небезопасен — избегайте его.- Хорошая схема проектируется «от запросов»: читаемое вместе хранится вместе, растущее — по ссылке.