Пагинация в Django под нагрузкой: разбор живого проекта
На небольшом каталоге пагинация через OFFSET выглядит безобидно, но на больших страницах начинает тратить время на пропуск уже ненужных строк. Разбираем Django 5.x и PostgreSQL 16.

В каталоге на Django 5.x запрос к первой странице может работать быстро, а к странице с большим номером — внезапно упираться в диск и CPU. Причина часто не в шаблоне, а в связке LIMIT/OFFSET: PostgreSQL сначала находит и пропускает строки, которые пользователь не увидит. Разберём на живом сценарии каталога товаров, как отличить проблему пагинации от проблем индекса, когда оставить обычные страницы, а когда перейти на keyset-подход.
Что происходило в каталоге
В проекте список товаров строился через Django ORM. Сортировка шла по дате публикации, а пользователю показывались ссылки «предыдущая» и «следующая». На небольшом объёме данных это выглядело так:
products = Product.objects.filter(is_active=True).order_by('-published_at', '-id')
Затем Paginator добавлял ограничение и смещение. Для первой страницы база возвращала первые 50 строк. Для страницы 1000 ей требовалось найти ещё 49 950 строк, чтобы отбросить их и отдать следующие 50.
Это не означает, что каждый запрос обязательно станет медленным. План зависит от индексов, селективности фильтров, размера строк, кеша и версии PostgreSQL. Проблема проявляется при росте смещения, а не по фиксированному номеру страницы.
Номер страницы удобен человеку, но для базы данных это инструкция сначала пройти мимо большого количества данных.
Сноска. Keyset pagination, или пагинация по курсору, — способ выбирать следующую порцию относительно последнего уже полученного объекта: «дата и идентификатор меньше этой пары», а не «пропусти N строк».
Что проверить до переделки
Сначала нужно отделить медленный SQL от лишней работы приложения. Для одного и того же фильтра зафиксируйте размер страницы и проверьте несколько значений смещения: начало выдачи, середину и участок ближе к концу набора. Выполняйте запросы на данных, похожих на production, и не сравнивайте холодный кэш с прогретым.
- Запишите фактический SQL. В Django это можно сделать через логи базы данных или панель мониторинга. Проверяйте не только
SELECT, но и отдельный запрос подсчёта строк:Paginatorобычно обращается кCOUNT(*), чтобы построить число страниц. - Посмотрите план. Для SQL-запроса используйте
EXPLAIN (ANALYZE, BUFFERS)на тестовой копии данных. Сравнивайте количество прочитанных строк, время, обращения к буферам и выбранный индекс. - Проверьте порядок сортировки. Сортировка только по
published_atопасна: у нескольких товаров может быть одинаковая дата. Добавьте уникальный стабилизатор, напримерid. - Проверьте связанные объекты. Если шаблон обращается к продавцу или категории для каждой строки, задержка может быть вызвана N+1-запросами, а не самой пагинацией.
Проверка успешна, если для каждого сценария у вас есть SQL, план выполнения и время отдельно для базы и приложения. Без этого переход на другой вид пагинации будет предположением, а не исправлением.
Три варианта для одного каталога
OFFSET через Django Paginator
Это стандартный вариант для страниц, на которые пользователь может перейти по номеру. В Django 5.x он читается просто:
from django.core.paginator import Paginator
products = Product.objects.filter(
is_active=True
).order_by('-published_at', '-id')
paginator = Paginator(products, 50)
page = paginator.get_page(request.GET.get('page'))
get_page() обрабатывает некорректный номер страницы и возвращает объект страницы. Для интерфейса это удобно, но сам SQL всё равно использует смещение. При большом количестве страниц стоимость перехода к дальнему результату может расти.
Плюс подхода — привычный URL вроде ?page=20, возможность показать общее число страниц и переход к произвольной странице. Минус — дорогое большое смещение и отдельная цена подсчёта общего количества строк.
Keyset по паре «дата и id»
Для бесконечной ленты или кнопки «показать ещё» номер страницы не нужен. Клиент передаёт последний объект из предыдущей порции, а база ищет записи после него в том же порядке.
from django.db.models import Q
products = Product.objects.filter(is_active=True)
if cursor_published_at is not None and cursor_id is not None:
products = products.filter(
Q(published_at__lt=cursor_published_at) |
Q(
published_at=cursor_published_at,
id__lt=cursor_id,
)
)
products = products.order_by('-published_at', '-id')[:50]
Здесь курсор состоит из двух значений, потому что одной даты недостаточно для однозначного порядка. В реальном API его лучше упаковывать в непрозрачное значение, проверять типы и не позволять клиенту произвольно менять границу выборки.
Такой запрос не пропускает тысячи строк. Он начинает поиск с позиции, заданной последним объектом, если индекс соответствует фильтру и сортировке. Цена — невозможность честно перейти на страницу 1000 и необходимость продумать формат курсора, обратную навигацию и изменение фильтров.
Курсор из готового API-инструмента
Если каталог отдаётся через Django REST Framework, можно использовать его официальную cursor pagination вместо самостоятельного формата. Это снижает количество кода, но не отменяет требований к стабильной сортировке, индексу и проверке поведения на удалённых и добавленных объектах. Точное имя класса и параметры зависят от версии DRF, поэтому их нужно сверять с документацией установленной версии, а не копировать конфигурацию от другого проекта.
| Подход | Когда подходит | Цена решения |
|---|---|---|
| OFFSET и номер страницы | Небольшие списки, поиск по страницам, нужен переход к номеру | Большие смещения и подсчёт общего количества строк могут стать дорогими |
| Keyset по сортировочному ключу | Ленты, каталоги с кнопкой «ещё», большие таблицы | Нет произвольного перехода; нужен стабильный уникальный порядок |
| Cursor pagination в API-фреймворке | Единый API и готовая клиентская навигация | Нужно учитывать ограничения конкретной версии и формата курсора |
Что ломается первым
Нестабильная сортировка
Если сортировать только по дате, две соседние страницы могут пересекаться или пропускать объект. Между запросами другой процесс добавит запись с той же датой, а база не обязана сохранять порядок строк с одинаковым значением. Добавляйте уникальный второй ключ и используйте его одинаково в order_by(), фильтре курсора и индексе.
Фильтр изменился между запросами
Курсор, созданный для одного набора фильтров, нельзя безопасно применять к другому. Если пользователь изменил категорию, поисковую строку или сортировку, старый курсор нужно сбросить. Иначе выдача начнётся с позиции, которая не имеет смысла для нового набора.
Записи добавляются и удаляются
OFFSET чувствителен к изменениям перед текущей страницей: новая строка может сдвинуть все последующие позиции. Keyset устойчивее к таким сдвигам, но тоже не обещает снимок данных. Если пользователю нужен строго неизменный набор результатов, потребуется отдельная стратегия фиксации версии выборки или времени среза.
Индекс не соответствует запросу
Сам переход на keyset не создаёт индекс автоматически. Для комбинации фильтра и сортировки нужно проверить план и подобрать индекс под реальные условия проекта. Универсальный индекс «на всякий случай» увеличивает размер базы и стоимость записи, поэтому его добавляют после измерения, а не по названию техники.
Когда какой вариант выбирать
- Оставляйте
Paginator, если объём таблицы умеренный, пользователям нужен номер страницы, а план на дальних страницах остаётся приемлемым. - Переходите на keyset, если интерфейс уже работает как лента, дальние страницы медленные, а переход к номеру не является требованием продукта.
- Используйте готовый cursor-класс API-фреймворка, если формат ответа и навигация должны быть одинаковыми для нескольких клиентов.
- Не маскируйте проблему кешем, если данные часто меняются или пользователи видят разные страницы. Кэширование результата и выбор стратегии пагинации — отдельные задачи.
Критерий выбора простой: если бизнес требует «открыть страницу 37», keyset не заменит OFFSET без изменения интерфейса. Если нужен только следующий блок результатов, сохранение номеров страниц оплачивает сложность, которая пользователю не нужна.
Что делать дальше
Начните с одного самого тяжёлого запроса каталога. Снимите планы для первой и дальнейшей страниц, отдельно измерьте COUNT(*), проверьте N+1 и зафиксируйте порядок сортировки. Затем добавьте интеграционный тест: две страницы не должны пересекаться при одинаковых значениях даты, а изменение фильтра должно сбрасывать курсор.
После этого смотрите в production на время SQL-запросов, количество возвращённых строк, чтение буферов и долю запросов к дальним страницам. Если продукт готов отказаться от номеров страниц, внедряйте keyset сначала за feature flag и сравнивайте планы на том же объёме данных. Следующий отдельный вопрос — согласованность выдачи при массовом импорте и удалении товаров; его не стоит прятать внутри настройки пагинации.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: Django documentation: Pagination, PostgreSQL 16 documentation: LIMIT and OFFSET
Частые вопросы
Почему первая страница быстрая, а дальняя медленная?
Можно ли заменить Paginator на keyset без изменения API?
Что будет, если у товаров одинаковая дата публикации?
Что делать, если после перехода на курсор часть товаров пропадает?
Нужно ли всегда добавлять отдельный индекс под пагинацию?
Есть ли различия между Django 5.x и другой версией?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.