ПроКодинг - Откроем для вас мир IT!

Представьте, что вы открываете каталог товаров в интернет-магазине. Если бы сайт попытался загрузить все миллионы позиций сразу, браузер завис бы намертво. Именно поэтому мы привыкли листать страницы или бесконечно скроллить ленту. В мире бэкенд-разработки этот принцип работает так же, но ошибки здесь стоят дороже: медленный ответ сервера может обрушить весь пользовательский опыт.

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

Почему нельзя просто вернуть все данные

Новички часто думают: «Зачем усложнять? Пусть клиент сам отфильтрует то, что ему нужно». Звучит логично, пока не столкнетесь с реальностью. Передача лишних данных увеличивает размер ответа (payload), нагружает сеть и заставляет клиентское приложение тратить ресурсы на парсинг мусора. Но главная проблема кроется на стороне базы данных.

Когда вы запрашиваете большие массивы без ограничений, PostgreSQL или другие СУБД могут блокировать таблицы на время сортировки и выборки. Это приводит к росту времени отклика (latency) и падению пропускной способности всего сервиса. Пагинация решает эту проблему, разбивая большой набор на управляемые куски. Фильтрация же уменьшает объем данных еще до этапа пагинации, позволяя базе работать с меньшим числом строк.

Основные типы пагинации: OFFSET vs Cursor

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

  • Offset-based (по смещению): Классический подход. Клиент отправляет параметры page и limit. Сервер выполняет SQL-запрос с LIMIT offset, limit. Простой для понимания, но имеет скрытые проблемы при больших объемах данных.
  • Cursor-based (курсорная): Более современный метод. Клиент получает уникальный идентификатор последней прочитанной записи (курсор) и использует его для получения следующей порции. Идеально подходит для бесконечного скролла в социальных сетях.

Давайте посмотрим на практическую разницу. Представьте таблицу с 10 миллионами пользователей. Если вы хотите получить 50-ю страницу по 20 элементов (offset = 980), база данных должна просканировать первые 980 записей, чтобы их пропустить, и только затем вернуть следующие 20. С каждой новой страницей нагрузка растет линейно. На 1000-й странице база будет тупить заметно дольше, чем на первой.

Курсорная пагинация решает эту проблему. Вместо номера страницы вы храните значение уникального поля (например, id или created_at). Запрос выглядит так: «Дай мне 20 записей, у которых ID больше, чем X». База использует индекс, чтобы мгновенно найти точку входа, и не тратит время на пропуск тысяч ненужных строк. Это делает запросы предсказуемо быстрыми независимо от глубины прокрутки.

Сравнение методов пагинации
Критерий Offset-based Cursor-based
Простота реализации Высокая Средняя
Производительность на глубоких страницах Низкая (линейный рост) Высокая (постоянная)
Возможность перехода к произвольной странице Да Нет (только вперед/назад)
Устойчивость к добавлению новых данных Нестабильна (данные могут «прыгать») Стабильна
Лучший сценарий использования Админ-панели, отчеты Ленты новостей, соцсети, каталоги

Как правильно проектировать фильтрацию

Фильтрация - это способ сузить поиск. Но как передать условия из фронтенда в бэкенд, чтобы код оставался чистым, а URL не превращался в кашу?

Стандартным решением являются query string параметры. Например, для поиска книг можно использовать строку: /api/books?author=Tolkien&year=1954&genre=fantasy. Это интуитивно понятно и легко кешируется браузерами и CDN-сервисами.

Однако здесь есть нюансы. Какие операторы сравнения поддерживать? Равенство (=) - обязательно. Диапазоны (min_price, max_price) - очень популярны. Поиск по подстроке (contains) - требует осторожности, так как часто приводит к полному сканированию таблицы, если нет полнотекстового поиска.

Не забывайте про безопасность. Каждый параметр фильтрации должен валидироваться на сервере. Если клиент прислает ?sort=raw_sql_injection, ваш ORM должен знать, какие поля допустимо сортировать. Иначе злоумышленник сможет вычитать схему базы данных или замедлить сервер тяжелыми запросами.

Abstract art comparing slow offset pagination with fast cursor-based access

Сортировка: третий столп эффективности

Пагинация и фильтрация редко работают в одиночку. Они всегда идут рука об руку с сортировкой. Пользователь хочет видеть товары сначала от дешевых, а новости - от свежих. Но сортировка - это дорого.

Если вы сортируете данные в памяти приложения (in-memory sorting) после загрузки всей выборки, вы убиваете смысл пагинации. Сортировка должна происходить на уровне базы данных с использованием индексов. Для этого важно правильно передавать направление сортировки (ASC/DESC) и указывать поле, по которому идет упорядочивание.

Совет из практики: ограничьте количество полей, по которым можно сортировать одновременно. Сортировка по одному полю использует один индекс. Сортировка по двум разным полям, которые не покрыты составным индексом, заставляет базу делать file sort, что резко снижает скорость. Лучше предложить пользователю выбрать один главный критерий сортировки.

Типичные ошибки и как их избежать

Даже опытные разработчики попадают в ловушки. Вот список частых проблем, которые стоит проверить перед релизом:

  1. Отсутствие лимита по умолчанию. Если клиент забудет передать limit, сервер вернет всю таблицу. Всегда устанавливайте дефолтное значение (например, 20) и максимальное ограничение (например, 100).
  2. Использование OFFSET для бесконечного скролла. Как мы уже обсуждали, на 100-й странице это будет медленно. Переходите на курсоры для публичных лент.
  3. Глубокие вложенности фильтров. Избегайте сложных JSON-структур в query params. Плоские ключи-значения проще парсить и отлаживать.
  4. Неправильная обработка пустых результатов. Если фильтр не находит ничего, верните пустой массив и метаданные о том, что записей нет, а не ошибку 404.

Также стоит подумать о том, как вы будете кешировать ответы. Если данные меняются редко, пагинированные ответы можно кешировать в Redis. Ключ кеша должен включать все параметры: пагинацию, фильтры и сортировку. Иначе пользователь получит старые данные после изменения фильтров.

Macro shot of a hand using a glass funnel to sort colorful data shapes

Практические примеры кода

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

Пример Offset пагинации:

Запрос: GET /api/orders?page=3&limit=10

SQL генерируется ORM примерно так:

SELECT * FROM orders ORDER BY id DESC LIMIT 10 OFFSET 20;

Пример Cursor пагинации:

Запрос: GET /api/orders?cursor=1042&limit=10 (где 1042 - это ID последнего просмотренного заказа)

SQL генерируется так:

SELECT * FROM orders WHERE id < 1042 ORDER BY id DESC LIMIT 10;

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

Метаданные ответа: говорите с клиентом

Чтобы клиент понимал, сколько всего данных существует и есть ли следующая страница, оборачивайте результат в объект с метаданными. Не возвращайте просто массив объектов. Возвращайте структуру:

{
  "data": [ ... ],
  "meta": {
    "total_count": 1542,
    "current_page": 1,
    "total_pages": 155,
    "next_cursor": "abc123",
    "has_next": true
  }
}

Это стандарт де-факто. Он позволяет фронтенду рисовать кнопки «Назад/Вперед», прогресс-бары или текст «Показано 1-20 из 1542». Без этих метаданных клиенту приходится делать дополнительные запросы только ради того, чтобы узнать общее количество записей.

Частые вопросы

Какой максимальный размер страницы (limit) считается оптимальным?

Для большинства веб-приложений оптимум находится в диапазоне от 10 до 50 записей. Большие размеры увеличивают время парсинга на клиенте и занимают больше места в сети. Для десктопных приложений или экспорта данных можно разрешать лимит до 100-200, но не более.

Можно ли комбинировать курсорную пагинацию с фильтрацией?

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

Что лучше использовать для поиска: фильтрацию или отдельный поисковый движок?

Если вам нужен точный подбор по атрибутам (цена, дата, статус), используйте стандартную фильтрацию в SQL. Если требуется полнотекстовый поиск по названиям с поддержкой опечаток, синонимов и релевантности, лучше интегрировать Elasticsearch или Meilisearch. Смешивать эти задачи в одном запросе к реляционной БД часто приводит к плохой производительности.

Как обрабатывать изменение данных во время прокрутки?

При использовании offset-пагинации новые записи могут «выталкивать» текущие элементы на следующую страницу, вызывая дублирование или пропуски. Курсорная пагинация устойчивее к этому эффекту, если сортировка происходит по неизменяемому полю (как ID). Однако если данные удаляются, возможны небольшие лаги в ленте, что обычно приемлемо для пользовательского интерфейса.

Нужно ли кешировать результаты пагинации?

Если данные меняются реже, чем раз в минуту, и трафик высокий - да. Кешируйте ответ в Redis с TTL (time-to-live). Ключ кеша должен быть хэшем всех входных параметров (страница, лимит, фильтры, сортировка). Это снимает нагрузку с базы данных и ускоряет ответ для повторных запросов.