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

Подборка типичных граблей, на которые 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 на каждом документе, игнорирует индексы и небезопасен — избегайте его.
  • Хорошая схема проектируется «от запросов»: читаемое вместе хранится вместе, растущее — по ссылке.
Проверьте себя
1. Каков жёсткий предел размера одного документа в MongoDB?
A16 мегабайт
B1 мегабайт
C256 мегабайт
DЛимита нет, документ может быть любого размера
2. Почему regex /анн/i (без якоря и с флагом регистронезависимости) работает медленно на большой коллекции?
AРегулярные выражения в MongoDB вообще не поддерживаются
BБез префикса ^ и из-за флага i индекс применить нельзя — каждая строка проверяется отдельно (полный перебор)
CФлаг i ускоряет поиск, проблема только в кириллице
DMongoDB сначала сортирует коллекцию, а потом ищет
3. Чем плох оператор $where с JavaScript-выражением?
AОн работает только в агрегации, но не в find
BОн исполняет JS на каждом документе, не может использовать индексы и небезопасен при пользовательском вводе
CОн слишком быстрый и поэтому перегружает сервер
DОн автоматически создаёт лишние индексы