Back

Паттерны пагинации в MongoDB

Паттерны пагинации в MongoDB

Вы создаёте ленту новостей, список товаров или страницу с результатами поиска. Коллекция разрослась до миллионов документов, и внезапно запросы пагинации выполняются секундами вместо миллисекунд. Виновник почти всегда один и тот же: вы используете skip и limit в масштабе.

Эта статья охватывает основные паттерны пагинации в MongoDB — на основе смещения (offset) и на основе курсора/ключевого набора (keyset) — объясняя, когда каждый из них работает хорошо, а когда нет. Никаких советов, специфичных для конкретных фреймворков, только паттерны, которые останутся актуальными по мере роста ваших данных.

Ключевые выводы

  • Пагинация со смещением с использованием skip() и limit() интуитивно понятна, но деградирует линейно по мере увеличения значений смещения — используйте её для поверхностной пагинации или небольших наборов данных.
  • Keyset-пагинация (на основе курсора) использует запросы диапазонов для стабильной производительности независимо от позиции, что делает её идеальной для больших наборов данных.
  • Всегда используйте уникальное поле-разделитель (обычно _id) при keyset-пагинации, чтобы предотвратить дубликаты или пропуск документов.
  • Выбирайте паттерн пагинации на основе размера данных, паттернов доступа и того, нужна ли пользователям последовательная навигация или произвольные переходы между страницами.

Пагинация MongoDB с Skip и Limit: знакомый подход

Пагинация на основе смещения использует skip() и limit() для разделения результатов на страницы. Она интуитивно понятна и напрямую соответствует UI-паттернам типа «страница 1, страница 2, страница 3».

db.posts.find()
  .sort({ createdAt: -1 })
  .skip((page - 1) * pageSize)
  .limit(pageSize)

Этот паттерн отлично работает для поверхностной пагинации — первых нескольких страниц результатов, административных панелей с ограниченными данными или внутренних инструментов, где производительность не критична.

Почему Skip деградирует в масштабе

Индексы MongoDB — это B-tree структуры, а не массивы. Хотя база данных может эффективно выполнять поиск в индексе, ей всё равно приходится проходить через пропускаемые записи, чтобы достичь запрошенного смещения. Пропуск 10 документов означает прохождение через 10 записей индекса. Пропуск 100 000 — прохождение через 100 000 записей.

Это означает, что страница 1 быстрая, страница 100 медленнее, а страница 10 000 значительно медленнее. Использование CPU увеличивается линейно со значением смещения, независимо от размера страницы.

Используйте пагинацию со смещением, когда:

  • Пользователи редко переходят дальше первых нескольких страниц
  • Общий набор данных невелик (менее 10 000 документов)
  • Вам нужна функциональность «перейти на страницу X» и вы принимаете компромисс
  • Создаёте внутренние инструменты, где время выполнения запроса менее критично

Курсорная пагинация MongoDB: стабильная производительность

Keyset-пагинация MongoDB (также называемая курсорной пагинацией) использует запросы диапазонов вместо позиционных смещений. Вместо того чтобы говорить «пропусти 1000 документов», вы говорите «дай мне документы после этой конкретной точки».

db.posts.find({ createdAt: { $lt: lastSeenDate } })
  .sort({ createdAt: -1 })
  .limit(pageSize)

База данных выполняет эффективный поиск в индексе для определения начальной точки, затем читает только нужные вам документы. Страница 1 и страница 10 000 имеют идентичные характеристики производительности.

Требование поля-разделителя

Одно поле сортировки создаёт проблемы, когда значения не уникальны. Если несколько документов имеют одинаковую временную метку createdAt, некоторые могут быть пропущены или продублированы на разных страницах.

Решение — составная сортировка с уникальным разделителем, обычно _id:

db.posts.find({
  $or: [
    { createdAt: { $lt: lastDate } },
    { createdAt: lastDate, _id: { $lt: lastId } }
  ]
})
.sort({ createdAt: -1, _id: -1 })
.limit(pageSize)

Это требует соответствующего составного индекса:

db.posts.createIndex({ createdAt: -1, _id: -1 })

Порядок полей индекса должен совпадать с порядком сортировки для оптимальной производительности.

Оговорки по согласованности

Курсорная пагинация более стабильна, чем пагинация со смещением, но она не обеспечивает магическую согласованность. Если документы вставляются, удаляются или изменяемые поля сортировки меняются между запросами, пользователи всё равно могут видеть дубликаты или пропускать элементы. Разница в том, что keyset-пагинация привязывается к конкретным значениям, а не к позициям, поэтому одновременная запись обычно вызывает меньше видимых аномалий.

Для лент и списков, где идеальная согласованность не требуется, этот компромисс обычно приемлем.

Пагинация Atlas Search: другой механизм

Если вы используете MongoDB Atlas Search со стадией $search, пагинация работает иначе. Atlas Search использует собственную систему на основе токенов — через searchSequenceToken под капотом — с параметрами searchAfter и searchBefore вместо курсоров _id. Не смешивайте эти подходы — используйте метод пагинации, соответствующий типу вашего запроса.

Выбор правильного паттерна

СценарийРекомендуемый паттерн
Ленты с бесконечной прокруткойKeyset-пагинация
Кнопки «Загрузить ещё»Keyset-пагинация
Административные таблицы с номерами страницСмещение (только первые страницы)
Большие наборы данных с последовательной навигациейKeyset-пагинация
Небольшие статичные наборы данныхЛюбой подходит

Решение часто сводится к требованиям UI. Если пользователям нужно переходить на произвольные страницы, вы ограничены пагинацией со смещением или гибридными подходами. Если достаточно последовательной навигации — вперёд, назад, загрузить ещё — keyset-пагинация масштабируется лучше.

Заключение

Пагинация со смещением с использованием skip и limit проста, но деградирует линейно с увеличением размера смещения. Используйте её для поверхностной пагинации или небольших наборов данных.

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

Ни один из паттернов не является универсально неправильным. Выбирайте на основе размера данных, паттернов доступа и требований UI.

Часто задаваемые вопросы

Да, вы можете использовать несколько полей сортировки с keyset-пагинацией. Ключевое требование — ваше последнее поле должно быть уникальным, чтобы служить разделителем. Ваш составной индекс должен точно соответствовать порядку и направлению всех полей сортировки. Логика запроса становится более сложной с каждым дополнительным полем, так как вам нужны вложенные условия OR для обработки случаев равенства для каждого предыдущего поля.

Для обратной пагинации один из распространённых подходов — изменить операторы сравнения и направление сортировки. Если прямое направление использует $lt с сортировкой по убыванию, обратное использует $gt с сортировкой по возрастанию. Получите результаты, затем переверните их в приложении, чтобы сохранить порядок отображения. Сохраняйте курсоры как первого, так и последнего документа с каждой страницы для обеспечения двунаправленной навигации.

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

Keyset-пагинация не поддерживает общее количество естественным образом, потому что не отслеживает позицию. Вы можете выполнить отдельный запрос подсчёта, но это добавляет накладные расходы на больших коллекциях. Подумайте, действительно ли пользователям нужны точные подсчёты — часто приблизительные подсчёты или просто указание на наличие дополнительных результатов обеспечивают достаточный контекст без затрат на производительность.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay