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

Проектируем схему: блог и магазин

junior~20 мин

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

В прошлом уроке мы увидели, как MongoDB держит данные целыми и надёжными. Самая сильная защита там была не транзакция, а удачная модель: то, что меняется вместе, лежит в одном документе. Сегодня практика. Спроектируем с нуля две схемы, блог и магазин, и пройдём весь путь: от вопросов приложения к документам.

Сначала меню, потом кухня

Ресторан не строят так: сначала кухню, а потом думают, что подавать. Сначала пишут меню, а под него ставят плиты и холодильники. Если в меню пицца, нужна печь, а не фритюрница.

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

План на оба примера один:

  1. Выписать главные запросы и прикинуть частоту.
  2. Для каждой связи ответить на вопросы из урока про встраивание: читают вместе? сколько штук? меняется отдельно?
  3. Решить, что встроить, на что сослаться и что скопировать.
  4. Проверить схему запросами.

Блог

Запросы

Блог о книгах. Что он делает чаще всего:

ЗапросЧастота
лента: последние посты с названием, автором и числом комментариевна каждом открытии главной
страница поста с текстом и последними комментариямиочень часто
все комментарии к посту по порядкукогда читатель нажал «показать все»
посты по меткечасто
новый комментарийредко по сравнению с чтением

Решения

Автор поста. В ленте нужно имя автора, а не вся его анкета. Значит, в пост кладём сводку { _id, name }: это копия-сводка из урока про денормализацию. Имя меняется редко, а читается на каждой главной.

Метки. Их у поста несколько штук, и вне поста они не живут. Массив строк внутри поста.

Комментарии. Здесь главный выбор. Встроить их в пост соблазнительно: пост и комментарии читаются вместе. Но под популярным постом комментариев могут быть тысячи, а это массив без верхней границы, антипаттерн, который рано или поздно упрётся в 16 МБ. Поэтому все комментарии живут в своей коллекции со ссылкой post_id.

Последние комментарии и счётчик. Странице поста нужны три свежих комментария и общее число. Ходить за ними во вторую коллекцию на каждом открытии дорого. Держим в посте их копию: массив из трёх последних и счётчик. Такой приём называют шаблоном подмножества: в документе часть, полный список отдельно.

Схема

db.posts.insertMany([
  {
    _id: 1, title: 'Почему я перечитываю Булгакова', author: { _id: 1, name: 'Ольга' },
    tags: ['книги', 'классика'], created_at: ISODate('2026-09-01'),
    comments_count: 0, recent_comments: []
  },
  {
    _id: 2, title: 'Пять книг об алгоритмах', author: { _id: 2, name: 'Павел' },
    tags: ['книги', 'программирование'], created_at: ISODate('2026-09-20'),
    comments_count: 0, recent_comments: []
  }
])

Новый комментарий это две записи: документ в comments и обновление поста. Счётчик и подмножество меняются одной командой, а $slice: -3 оставляет в массиве только три последних:

db.comments.insertOne({ post_id: 1, user: 'Роман', text: 'Согласен, каждый раз новое', created_at: ISODate('2026-09-02') })
db.posts.updateOne(
  { _id: 1 },
  {
    $inc: { comments_count: 1 },
    $push: { recent_comments: { $each: [{ user: 'Роман', text: 'Согласен, каждый раз новое' }], $slice: -3 } }
  }
)

Если между этими двумя командами сервер упадёт, комментарий будет, а копия в посте отстанет. Для блога это допустимо: копию можно пересчитать фоновой задачей. Для денег такой подход не годится, там нужна транзакция.

Проверка запросами

Лента, одним запросом и без похода в другие коллекции:

db.posts.find({}, { title: 1, 'author.name': 1, comments_count: 1 }).sort({ created_at: -1 })
[
  {
    _id: 2,
    title: 'Пять книг об алгоритмах',
    author: { name: 'Павел' },
    comments_count: 0
  },
  {
    _id: 1,
    title: 'Почему я перечитываю Булгакова',
    author: { name: 'Ольга' },
    comments_count: 1
  }
]

Посты по метке находит обычный фильтр по массиву:

db.posts.find({ tags: 'классика' }, { _id: 0, title: 1 })
[ { title: 'Почему я перечитываю Булгакова' } ]

Все комментарии к посту, в порядке написания:

db.comments.find({ post_id: 1 }, { _id: 0, user: 1, text: 1 }).sort({ created_at: 1 })
[ { user: 'Роман', text: 'Согласен, каждый раз новое' } ]

Каждый запрос из таблицы закрыт одним обращением к одной коллекции. Это и есть признак удачной схемы.

урок из курса

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

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