Перейти к основному содержимому

Пагинация в API — шесть распространённых схем

Всем

Контекст: разбор REST URL, восемь принципов RESTful API. Углубление: проектирование API, документирование в OpenAPI. Параллель в SQL: LIMIT и ключевая выборка.


Зачем пагинация

Эндпоинты вроде GET /orders или GET /users редко отдают всю таблицу целиком: ответ раздувается, растёт время ответа и нагрузка на БД. Пагинация — договорённость, как клиент запрашивает порцию записей и как сервер сообщает, есть ли ещё данные.

В одном API обычно выбирают одну основную схему для всех коллекций (page/size или cursor), чтобы SDK и документация оставались предсказуемыми. Ниже — шесть распространённых подходов; на практике их комбинируют (гибрид), но клиенту важно видеть явный контракт.


1. Offset-based (смещение и лимит)

Сервер пропускает первые offset строк и возвращает не больше limit записей.

GET /v1/orders?offset=0&limit=3
GET /v1/orders?offset=3&limit=3
ПлюсыМинусы
Простая реализация (LIMIT/OFFSET в SQL)На больших offset запросы в БД дорогие
Удобно «перейти на страницу N» в UIПри вставках и удалениях между запросами возможны дубликаты и пропуски
Понятно в логах и PostmanНужен стабильный ORDER BY, иначе порядок строк «плывёт»

Когда уместно: админки, отчёты, каталоги с редкими изменениями, небольшие смещения.


2. Page-based (номер страницы)

Абстракция над offset: клиент передаёт номер страницы и размер, сервер сам считает смещение (offset = (page - 1) * size).

GET /v1/orders?page=2&size=3

Семантика та же, что у offset-based: страница 2 при size=3 — это записи с индексами 3, 4, 5 в отсортированном списке. Удобно для кнопок «Страница 1, 2, 3…» в интерфейсе.

В ответе часто возвращают метаданные:

{
"items": [ /* … */ ],
"page": 2,
"pageSize": 3,
"total": 128
}

Поле total требует отдельного COUNT(*) — на очень больших таблицах его иногда убирают или кэшируют.


3. Cursor-based (курсор)

Клиент не знает «номер страницы», а передаёт непрозрачный токен — позицию в упорядоченной выборке. Сервер кодирует в курсоре последний увиденный ключ и направление сортировки.

GET /v1/orders?limit=3
GET /v1/orders?cursor=eyJpZCI6MTAyLCJzIjoiYXNjIn0&limit=3

Пример ответа:

{
"items": [ /* id 100, 101, 102 */ ],
"pagination": {
"nextCursor": "eyJpZCI6MTAyLCJzIjoiYXNjIn0",
"hasMore": true
}
}
ПлюсыМинусы
Устойчивее при вставках в «хвост» лентыНельзя перейти на произвольную страницу без полного обхода
Хорошо для лент, чатов, таймлайновКурсор привязан к сортировке; смена sort ломает продолжение
Меньше «дыр» при активных изменениях, чем у чистого offsetТокен нужно подписывать или проверять, чтобы клиент не подделал границу

Когда уместно: соцсети, уведомления, синхронизация «что нового с момента X».


4. Keyset-based (пагинация по ключу)

Клиент явно указывает границу по индексируемому полю (часто первичный ключ или пара (created_at, id)). Это открытый вариант cursor: параметры читаемы в URL.

GET /v1/orders?after_id=102&limit=3
GET /v1/orders?created_at_lt=2025-11-14T00:00:00Z&limit=50

На сервере запрос похож на SQL:

SELECT * FROM orders
WHERE id > :after_id
ORDER BY id ASC
LIMIT 3;

Требования:

  • фиксированный ORDER BY по полям с индексом;
  • для составного ключа сортировка и условие в WHERE согласованы ((created_at, id)).

Тот же приём в REST и в SQL — в главе про LIMIT, OFFSET и ключевую выборку.


5. Time-based (по времени)

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

GET /v1/orders?start_time=2024-01-03T00:00:00Z&end_time=2024-01-05T23:59:59Z&limit=100

Часто сочетают с keyset или cursor внутри окна: сначала фильтр по дате, затем after_id или cursor для следующей порции внутри диапазона.

Важно зафиксировать часовой пояс (Z / +03:00) и формат (date-time в OpenAPI).


6. Hybrid (гибрид)

Объединяют два и более механизма — типично временное окно + курсор, чтобы и сузить данные, и стабильно листать внутри среза.

GET /v1/orders?cursor=abc&start_time=2024-01-03T00:00:00Z&end_time=2024-01-05T23:59:59Z&limit=50

Сценарий: отчёт «заказы за неделю» с подгрузкой ленты; курсор держит позицию, пока в том же окне добавляются записи.

Один контракт на коллекцию
Гибрид мощнее, но сложнее для клиента. В OpenAPI перечислите все допустимые query-параметры, значения по умолчанию и что произойдёт, если передать и page, и cursor одновременно (лучше — явная ошибка 400).


Сравнение и выбор схемы

СхемаПример queryПроизвольная страницаУстойчивость при измененияхНагрузка на БД
Offsetoffset=30&limit=10даслабаярастёт с offset
Pagepage=4&size=10даслабаякак offset
Cursorcursor=…&limit=10нетсильнаяобычно низкая
Keysetafter_id=102&limit=10нетсильнаянизкая при индексе
Timestart_time=…&end_time=…зависит от парысредняязависит от индекса по времени
Hybridокно + cursorчастичносильная в окнезависит от запроса

Практическая эвристика:

  • публичный каталог с кнопками страниц → page/size или offset/limit;
  • мобильная лента, чат, «подгрузить ещё» → cursor или keyset;
  • архив событий за период → time-based + keyset/cursor внутри;
  • B2B с жёстким SLA и большими таблицами → keyset по (sort_field, id).

Метаданные ответа и HTTP-заголовки

Клиенту нужно понимать, как запросить следующую порцию:

  • в теле JSONnextCursor, hasMore, total, page, pageSize;
  • в заголовке Link (RFC 5988) — rel="next", rel="prev", rel="first", rel="last";
  • опционально X-Total-Count — общее число элементов (осторожно на миллионах строк).

Пример заголовка:

Link: <https://api.example.com/v1/orders?cursor=eyJ...>; rel="next"

Для HATEOAS-зрелости API достаточно поля _links.next в JSON — см. модель Ричардсона.


Описание в OpenAPI

Параметры пагинации — обычные in: query с schema, default и description. Пример для page-based списка:

paths:
/orders:
get:
parameters:
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
description: Номер страницы (начиная с 1)
- name: size
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
description: Записей на странице
responses:
'200':
description: Страница заказов
content:
application/json:
schema:
$ref: '#/components/schemas/OrderPage'

Для cursor-based в description укажите: курсор непрозрачен, выдаётся в pagination.nextCursor, повторное использование старого курсора после смены сортировки недопустимо.

Разбор полей summary, description, схем ответа с total/page — в документировании API в OpenAPI.


Проверка в Postman и curl

curl -s "https://api.example.com/v1/orders?offset=0&limit=3" -H "Accept: application/json"
curl -s "https://api.example.com/v1/orders?cursor=TOKEN" -H "Accept: application/json"

Сценарии с пагинацией удобно собирать в коллекции Postman — см. Postman и curl.


Связанные материалы

ТемаСтатья
Фильтрация, сортировка, поиск в API8.05. Проектирование API
REST URL и единый язык queryAPI — интерфейсы
Паттерн «Итератор» и пагинация в кодеИтератор в Java
Пример GitHub API с LinkPython — HTTP-клиент

Контрольные вопросы

  1. Почему при offset-пагинации между запросом страницы 1 и страницы 2 может появиться дубликат?
  2. Чем keyset отличается от opaque cursor с точки зрения клиента?
  3. Зачем пагинации в SQL и API нужен стабильный ORDER BY?
  4. Какие поля ответа вы бы задокументировали для cursor-based списка заказов?

См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").