Пагинация в 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 | Произвольная страница | Устойчивость при изменениях | Нагрузка на БД |
|---|---|---|---|---|
| Offset | offset=30&limit=10 | да | слабая | растёт с offset |
| Page | page=4&size=10 | да | слабая | как offset |
| Cursor | cursor=…&limit=10 | нет | сильная | обычно низкая |
| Keyset | after_id=102&limit=10 | нет | сильная | низкая при индексе |
| Time | start_time=…&end_time=… | зависит от пары | средняя | зависит от индекса по времени |
| Hybrid | окно + cursor | частично | сильная в окне | зависит от запроса |
Практическая эвристика:
- публичный каталог с кнопками страниц → page/size или offset/limit;
- мобильная лента, чат, "подгрузить ещё" → cursor или keyset;
- архив событий за период → time-based + keyset/cursor внутри;
- B2B с жёстким SLA и большими таблицами → keyset по
(sort_field, id).
Метаданные ответа и HTTP-заголовки
Клиенту нужно понимать, как запросить следующую порцию:
- в теле JSON —
nextCursor,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. Синтаксис curl для offset/cursor и заголовков — утилита curl, curl / fetch — примеры.
Связанные материалы
| Тема | Статья |
|---|---|
| Фильтрация, сортировка, поиск в API | 8.05. Проектирование API |
| REST URL и единый язык query | API — интерфейсы |
| Паттерн "Итератор" и пагинация в коде | Итератор в Java |
Пример GitHub API с Link | Python — HTTP-клиент |
Контрольные вопросы
- Почему при offset-пагинации между запросом страницы 1 и страницы 2 может появиться дубликат?
- Чем keyset отличается от opaque cursor с точки зрения клиента?
- Зачем пагинации в SQL и API нужен стабильный
ORDER BY? - Какие поля ответа вы бы задокументировали для cursor-based списка заказов?