У MongoDB нет схемы, но это не значит, что у ваших данных нет формы. Это значит, что форма живёт в приложении и в индексах, а не на сервере, и что о неудачном выборе вы узнаёте в production, а не во время миграции. Большую часть дела решают два правила: встраивайте данные, которые всегда читаются вместе, и ссылайтесь на данные, которые читаются отдельно; и стройте по одному составному индексу на каждую форму запроса, располагая поля в порядке равенство, сортировка, диапазон.
Всё ниже - обоснование этих двух предложений, а также способы проверить свою работу через explain() и профайлер. В примерах магазин: customers, orders, products. Если вы ещё не выбрали между документной и реляционной моделью, короче об этом сказано в статье Postgres или MongoDB; здесь предполагается, что выбор сделан.
Встраивание или ссылки#
Вопрос не в том, что правильно. Вопрос в том, какую из двух цен вы готовы заплатить.
Встраивание кладёт связанные данные внутрь родительского документа. Одно чтение возвращает всё, join не нужен, а запись атомарна, потому что обновление одного документа в MongoDB атомарно независимо от того, как глубоко лежит изменение. Цена - дублирование и рост: одно и то же встроенное название товара живёт в десяти тысячах заказов, поэтому переименование товара означает правку десяти тысяч документов, а документ, который со временем растёт, в какой-то момент приходится переносить на диске.
Ссылки хранят идентификатор, а другой документ достают отдельно или через $lookup в aggregation. Ничего не дублируется, обновления делаются в одном месте, родительский документ остаётся небольшим. Цена - второй обмен с сервером или $lookup, который нужно как следует проиндексировать, иначе он будет медленным.
Практические правила, которые выдерживают столкновение с реальными приложениями:
- Один ко многим в малом числе, читается вместе, меняется редко: встраивайте. Адреса клиента. Позиции заказа. Позиции заказа - это в любом случае историческая запись: если цена товара изменится, заказ меняться не должен.
- Один ко многим, где у «многих» нет потолка: ссылки. Комментарии к посту, события пользователя, сообщения в канале. Дочерний документ хранит
_idродителя и индексируется по нему. - Многие ко многим: ссылки со стороны, с которой вы делаете запросы. Храните в статье массив идентификаторов тегов, если спрашиваете «какие теги у этой статьи», и проиндексируйте массив, чтобы можно было ответить и на обратный вопрос.
- Нужно в списке: продублируйте два-три поля, которые показывает список. Заказ, хранящий
productNameиunitPriceрядом сproductId, рисует корзину вообще без обращения кproducts. Это паттерн расширенной ссылки, и дублирование в нём сделано намеренно.
Ловушка, за которой нужно следить, - неограниченный массив. Документ user с массивом events, который растёт при каждом действии пользователя, три месяца работает прекрасно, а потом упирается в стену. Если вы не можете назвать максимум для массива, ему место в отдельной коллекции.
Лимиты, которые определяют форму#
| Лимит | Значение | Что это значит на практике |
|---|---|---|
| Размер документа BSON | 16 MB | Жёсткий потолок для встраивания |
| Глубина вложенности | 100 уровней | Случайно до неё не дойти |
| Полей в составном индексе | 32 | Разумно до этого не дойти |
| Полей-массивов в составном индексе | 1 | Ограничение multikey, ниже |
| Индексов на коллекцию | 64 | Вы должны быть далеко от него |
Шестнадцать мегабайт звучит огромно, пока массив растёт без границ. Практический порог гораздо ниже: документ, который вы часто обновляете, должен оставаться небольшим, потому что каждое обновление переписывает его целиком, а документ в 4 MB, переписываемый при каждом добавлении, - это очень много дисковой и кэшевой работы ради одного изменённого поля. Считайте, что несколько сотен килобайт - это точка, когда массив уже пора было вынести.
Если одно-единственное значение действительно должно быть больше 16 MB, для этого есть GridFS: он режет файл на части в двух коллекциях. Для большинства приложений лучший ответ - держать файл в объектном хранилище, а в MongoDB хранить ссылку.
Паттерны, которые стоит знать#
Четыре именованных паттерна покрывают большинство случаев, где простой ответ не работает.
Subset. Держите в родителе самые свежие или самые важные несколько записей, а остальные - в другой коллекции. Товар хранит десять последних отзывов для страницы товара; reviews хранит все для страницы «показать все». Вы соглашаетесь на запись в два места ради чтения одного документа на странице, которую открывают все.
Bucket. Группируйте много мелких записей в один документ по времени или по ключу. Тысяча показаний датчика в минуту, сохранённых как один документ на минуту, а не по одному на показание, превращает миллион документов в сутки в 1 440 и резко уменьшает индексы. В MongoDB 5.0 и новее есть коллекции time series, которые делают это за вас, и это лучший вариант, когда он подходит.
Computed. Храните ответ, а не исходные данные, если ответ читают намного чаще, чем он меняется. Текущие orderCount и totalSpent у клиента лучше, чем агрегация по всем заказам при каждой загрузке страницы. Обновляйте их той же операцией, которая создаёт заказ, через $inc.
Schema version. Добавьте поле schemaVersion в каждый документ с первого дня. Миграция большой коллекции на месте создаёт неудобства; ленивая миграция, где приложение понимает обе формы и переписывает документ, когда к нему обращается, их не создаёт. Миграции без простоя - это общий вид той же идеи.
Можно также заставить сервер следить за формой, и это стоит делать, когда схема перестала меняться каждую неделю:
db.runCommand({ collMod: "orders", validator: { $jsonSchema: { bsonType: "object", required: ["customerId", "status", "createdAt"], properties: { status: { enum: ["pending", "paid", "shipped", "cancelled"] }, createdAt: { bsonType: "date" } } }}, validationLevel: "moderate", validationAction: "warn"})Начните с validationAction: "warn", при котором нарушения пишутся в лог, а запись не отклоняется, и читайте лог неделю, прежде чем переключиться на error.
Как работают индексы MongoDB#
У каждой коллекции есть индекс по _id. Он уникален, его нельзя удалить, и именно из-за него поиск по идентификатору всегда быстр. Все остальные индексы создаёте вы сами, и каждый из них - это B-дерево, которое серверу приходится обновлять при каждой записи, затрагивающей его поля.
| Тип | Создаётся так | Для чего |
|---|---|---|
| Одно поле | { email: 1 } | Равенство и диапазон по одному полю |
| Составной | { status: 1, createdAt: -1 } | Одна форма запроса, см. ниже |
| Multikey | любой индекс по полю-массиву | Поиск элементов внутри массивов |
| Unique | { email: 1 }, { unique: true } | Обеспечение уникальности |
| Partial | partialFilterExpression | Индексирование только тех строк, что вы запрашиваете |
| TTL | { createdAt: 1 }, { expireAfterSeconds: 604800 } | Устаревание старых документов |
| Text | { description: "text" } | Примитивный полнотекстовый поиск, один на коллекцию |
| Wildcard | { "attrs.$**": 1 } | По-настоящему непредсказуемые имена полей |
| 2dsphere | { location: "2dsphere" } | Геопространственные запросы |
Направление в индексе по одному полю значения не имеет: сервер может обходить B-дерево в любую сторону. Направление в составном индексе важно только тогда, когда вы сортируете по нескольким его полям в разных направлениях.
Есть два поведения, свойственных именно MongoDB, и их стоит усвоить. Индекс по полю-массиву - это индекс multikey: он хранит по одной записи на каждый элемент массива, так что документ с пятьюдесятью тегами даёт пятьдесят записей. Это нормально, именно поэтому вообще можно запрашивать внутри массивов, но именно поэтому же составной индекс может содержать не больше одного поля-массива. А partial-индекс с partialFilterExpression: { status: "pending" } индексирует только заказы в статусе pending, что в таблице, где 99% строк имеют статус shipped, в десять раз меньше по размеру и в десять раз дешевле по записи. Предпочитайте partial-индексы sparse-индексам: их возможности строго шире.
TTL-индекс - самый дешёвый способ удалять сессии, токены и логи. Фоновый поток, удаляющий просроченные документы, запускается примерно раз в минуту, поэтому удаление приблизительное, а поле должно быть датой BSON, а не числом.
Составные индексы и правило ESR#
Один составной индекс обслуживает целое семейство запросов благодаря правилу префикса: индекс { a: 1, b: 1, c: 1 } может обслужить запросы по a, по a и b, по a, b и c, но не по одному b. Если расположить поля правильно, три индекса превращаются в один.
Правило порядка - равенство, сортировка, диапазон (equality, sort, range):
- Сначала поля равенства - те, что сопоставляются с точным значением. Они сужают сканирование до непрерывного участка ключей индекса.
- Затем поля сортировки. Если сортировка следует в индексе за полями равенства, результаты выходят уже упорядоченными, и отдельного шага сортировки нет.
- В конце поля диапазона:
$gt,$lt,$inпо промежутку,$regexс префиксом. Диапазон посреди индекса нарушает упорядоченность всего, что после него.
Для db.orders.find({ status: "paid", total: { $gt: 100 } }).sort({ createdAt: -1 }) индекс такой:
db.orders.createIndex({ status: 1, createdAt: -1, total: 1 })Равенство по status, сортировка по createdAt, диапазон по total. Если поставить total перед createdAt, выглядит естественнее, но план получит блокирующий этап SORT, а это разница между одной миллисекундой и несколькими сотнями.
Покрытый запрос - следующая ступень: если все поля, нужные запросу, есть в индексе, сервер вообще не обращается к документам. Поскольку _id возвращается по умолчанию и обычно в индексе отсутствует, для покрытия его нужно убрать из проекции: .find({ status: "paid" }, { _id: 0, status: 1, createdAt: 1 }). Когда это сработало, вы увидите totalDocsExamined: 0.
Не индексируйте всё подряд. Каждый индекс пишется при каждой подходящей вставке и обновлении, занимает кэш, которым иначе пользовались бы документы, и его приходится перестраивать при восстановлении. Три хороших составных индекса лучше двенадцати одиночных, а MongoDB редко объединяет два индекса для одного запроса, хотя технически может.
Чтение explain()#
db.orders.find({ customerId: 4821, createdAt: { $gte: since } }) .sort({ createdAt: -1 }).limit(20) .explain("executionStats")До индекса:
nReturned: 20executionTimeMillis: 412totalKeysExamined: 0totalDocsExamined: 2400000stage: SORT sortPattern: { createdAt: -1 } inputStage: COLLSCAN filter: { customerId: { $eq: 4821 } ... }После createIndex({ customerId: 1, createdAt: -1 }):
nReturned: 20executionTimeMillis: 1totalKeysExamined: 20totalDocsExamined: 20stage: LIMIT inputStage: FETCH inputStage: IXSCAN indexName: customerId_1_createdAt_-1Четыре числа говорят почти всё. nReturned - сколько вернул запрос. totalKeysExamined - сколько записей индекса прочитано. totalDocsExamined - сколько документов извлечено. Нужно, чтобы все три были примерно равны. Если ключей намного больше, чем возвращено, индекс недостаточно избирателен; если документов намного больше, чем ключей, сервер достаёт документы, чтобы применить фильтр, который должен был находиться в индексе; а COLLSCAN с миллионами просмотренных документов означает, что подходящего индекса нет вовсе.
Важные названия этапов: COLLSCAN читает коллекцию, IXSCAN идёт по индексу, FETCH загружает документы по их записям в индексе, SORT - блокирующая сортировка в памяти, а PROJECTION_COVERED означает, что ничего не извлекалось. Этап SORT, превысивший лимит памяти сервера на сортировку, падает с ошибкой Sort exceeded memory limit; лимит бывал 32 MB и 100 MB в зависимости от версии, и точная цифра указана в тексте ошибки. Решение - индекс, дающий порядок, а не больший лимит.
explain("executionStats") запускает запрос. Голый explain() использует подробность queryPlanner и только строит план, а это безопасный вариант для production. Aggregation тоже можно объяснить: db.orders.explain("executionStats").aggregate([...]), причём этапы конвейера после первого $group или $sort не могут использовать индекс, поэтому ставьте $match первым и индексируйте то, что он выбирает.
Поиск медленных запросов с помощью профайлера#
MongoDB пишет в лог сервера любую операцию медленнее slowms, по умолчанию 100 миллисекунд, независимо от того, включён ли профайлер. Этот лог - первое место, куда стоит смотреть. Для чего-то, что можно запрашивать, включите профайлер, он настраивается для каждой базы:
db.setProfilingLevel(1, { slowms: 50 }) // 1 = slow ops only, 2 = everythingdb.system.profile.find().sort({ ts: -1 }).limit(5).pretty()db.setProfilingLevel(0)system.profile - это capped-коллекция, по умолчанию маленькая, поэтому в ней хранится недавнее окно, а не история. Уровень 2 записывает каждую операцию и служит инструментом разработки, а не тем, что оставляют включённым.
Чтобы понять, какие индексы стоит оставить, спросите сервер, как часто каждый из них использовался с последнего перезапуска:
db.orders.aggregate([{ $indexStats: {} }])Индекс, у которого accesses.ops равно нулю после месяца работы, стоит вам записи и кэша впустую. Загляните заодно в db.orders.stats().indexSizes, чтобы увидеть, чего он стоит. А db.currentOp() показывает, что выполняется прямо сейчас, и есть db.killOp(opid) для aggregation, которую кто-то запустил вручную против production.
Как безопасно строить и удалять индексы#
Начиная с MongoDB 4.2 построение индекса берёт исключительную блокировку лишь ненадолго в начале и в конце, а в промежутке чтение и запись идут как обычно, так что прежняя опция background: true больше ничего не значит. Это не делает построение бесплатным: оно читает всю коллекцию и всё время использует CPU и память. Стройте в тихий час и следите за прогрессом через db.currentOp().
Удаление индекса - то, что заставляет нервничать, и в MongoDB 4.4 для этого появился правильный инструмент:
db.orders.hideIndex("status_1_total_1") // invisible to the planner, still maintaineddb.orders.unhideIndex("status_1_total_1") // instant undodb.orders.dropIndex("status_1_total_1") // once you are sureСкройте его, сутки наблюдайте за задержкой и удалите, если ничего не изменилось. Возврат мгновенный; перестраивать удалённый индекс на большой коллекции - нет.
Одно замечание про восстановление: индексы перестраиваются при восстановлении из дампа, и это часто самая медленная часть операции. Флаги, которые ею управляют, разобраны в статье mongodump и mongorestore.
Память, рабочий набор и кэш WiredTiger#
MongoDB быстр, когда рабочий набор - индексы плюс документы, к которым вы реально обращаетесь, - помещается в памяти, и резко замедляется, когда не помещается, потому что каждый промах превращается в чтение с диска. Движок хранения резервирует кэш размером max(50% of (RAM - 1 GB), 256 MB):
| RAM сервера | Кэш WiredTiger по умолчанию |
|---|---|
| 1 GB | 256 MB |
| 2 GB | 512 MB |
| 4 GB | 1.5 GB |
| 8 GB | 3.5 GB |
| 14 GB | 6.5 GB |
Остальная память идёт на соединения, фреймворк aggregation, построение индексов и собственный страничный кэш операционной системы, от которого MongoDB тоже выигрывает. Задайте storage.wiredTiger.engineConfig.cacheSizeGB явно, если хотите определённости в этой цифре, а не расчёта от того, что процесс считает объёмом машины.
Оценивайте по индексам, а не по данным. Коллекция в 40 GB, к которой обращаются только по _id, комфортно живёт на небольшом сервере; коллекция в 4 GB с шестью индексами и аналитическими запросами - нет. db.stats().indexSize даёт нужное число, и если оно больше кэша, ждите чтений с диска даже на обычных запросах.
Нехватка памяти обходится без изящества. В RE:NODE контейнер, дошедший до лимита памяти, останавливается ядром и запускается заново, а не остаётся в свопе, что для базы данных означает оборванные соединения и холодный кэш, а не машину, которая медленно перестаёт отвечать. Это лучший из сбоев, но всё равно сбой, поэтому оставляйте запас, держите размеры пулов в приложении честными - арифметика есть в статье пулы соединений и лимиты - и переходите на тариф выше до того, как кэш заполнится, а не после. Смотреть нужно на график памяти панели относительно лимита.
FAQ#
Встраивать или ссылаться?
Встраивайте, когда дочерние данные читаются вместе с родителем, принадлежат только ему и имеют известный максимальный размер. Ссылайтесь, когда их запрашивают отдельно, когда они общие для нескольких родителей или неограничены. Когда оба варианта оправданны, встраивайте: одно чтение дешевле двух, а массив всегда можно вынести позже.
Сколько индексов - это слишком много?
Единого числа нет, но если в коллекции индексов больше, чем форм запросов, часть из них не используется. Запустите $indexStats, найдите те, к которым не было обращений с последнего перезапуска, скройте их на неделю и удалите. Запись ускорится, а восстановление станет короче.
Почему запрос медленный, хотя индекс я создал?
Обычно из-за порядка полей. Индекс { createdAt: -1, status: 1 } не может эффективно обслужить запрос с фильтром по status и сортировкой по createdAt, потому что поле равенства не является префиксом. Запустите explain("executionStats") и сравните totalDocsExamined с nReturned; если разрыв велик, это не тот индекс, который нужен запросу.
Нужна ли MongoDB схема?
Сервер её не навязывает, пока вы не добавите валидатор JSON Schema, но у вашего приложения она есть, записали вы её или нет. Добавьте валидатор, когда форма устоится, начав с режима warn, и добавьте поле schemaVersion, чтобы перемена решения потом была фоновой задачей, а не аварией.
Что для меня значит лимит документа в 16 MB?
То, что у любого массива внутри документа должен быть потолок, который вы можете назвать. Задолго до 16 MB большие документы дороги в обновлении, потому что переписывается весь документ. Выносите всё, что растёт бесконечно, в отдельную коллекцию с индексом по идентификатору родителя.
Сколько RAM нужно моему серверу MongoDB?
Достаточно для ваших индексов плюс документов, которые вы читаете регулярно. Посмотрите db.stats().indexSize по каждой базе, добавьте ту часть данных, которая действительно горячая, и сравните с цифрами кэша в таблице выше. Если одни только индексы больше кэша, следующий тариф обойдётся дешевле, чем задержка, которую вы платите сейчас.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.