Пагинация: offset и cursor

Урок о том, как отдавать большие коллекции по частям и какой способ пагинации выбрать.

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

Представьте эндпоинт GET /v1/orders, за которым стоит таблица на пять миллионов заказов. Без пагинации один запрос попытается сериализовать всё: сервер выест память, ответ будет весить сотни мегабайт, клиент захлебнётся, а пользователь увидит крутящийся спиннер вместо данных. Пагинация решает это, отдавая, скажем, по 20 записей за раз. Но «отдать 20 записей» можно несколькими способами, и выбор между ними — это компромисс между простотой и устойчивостью на больших данных.

Offset/limit — простой и привычный

Самый понятный способ: клиент говорит «пропусти первые N и дай следующие M».

GET /v1/orders?limit=20&offset=0    -- первая страница
GET /v1/orders?limit=20&offset=20   -- вторая
GET /v1/orders?limit=20&offset=40   -- третья

Под капотом это ложится прямо на SQL: SELECT ... ORDER BY id LIMIT 20 OFFSET 40. Способ интуитивен и позволяет прыгнуть на любую страницу: чтобы попасть на страницу 50 при размере 20, ставим offset=980. Именно поэтому offset любят интерфейсы с нумерованными страницами «1 2 3 ... 50».

Проблема больших смещений

База данных не умеет «телепортироваться» к 980-й строке. Чтобы выполнить OFFSET 980, она перебирает и отбрасывает первые 980 строк, и только потом возвращает 20. На странице 5000 (offset=100000) сервер сканирует сотню тысяч строк, чтобы отдать двадцать — запрос тормозит тем сильнее, чем глубже страница. Это фундаментальное свойство offset, а не недосмотр.

Дрейф данных

Вторая беда — дрейф. Пока пользователь листает, в коллекцию добавляются и удаляются записи. Если между загрузкой страницы 1 и страницы 2 в начало списка вставили новый заказ, все смещения «съезжают» на единицу: одна запись покажется дважды, другую вы пропустите. На активно меняющихся данных offset показывает несогласованную картину.

Cursor/keyset — стабильный для ленты

Курсорная (keyset) пагинация смотрит на проблему иначе: вместо «пропусти 980 строк» она говорит «дай записи после вот этой конкретной». Курсор — это указатель на последний элемент предыдущей страницы, обычно закодированный (например, в base64) идентификатор или пара значений сортировки.

GET /v1/orders?limit=20
GET /v1/orders?limit=20&cursor=eyJpZCI6MjB9
GET /v1/orders?limit=20&cursor=eyJpZCI6NDB9

Под капотом это SELECT ... WHERE id > 40 ORDER BY id LIMIT 20. По индексированному столбцу id база сразу прыгает в нужное место — скорость одинакова и на первой, и на миллионной странице. И дрейфа нет: условие «id больше последнего» не зависит от того, сколько строк добавили в начало.

Цена устойчивости

За стабильность платят гибкостью: нельзя перейти на произвольную страницу N. Курсор знает только «следующую» (и иногда «предыдущую») страницу — ленты вроде бесконечной прокрутки соцсетей именно так и устроены. Если интерфейсу нужны кнопки «1 2 3 ... 50», keyset не подойдёт. Ещё нюанс: курсор должен опираться на стабильный уникальный порядок (обычно по id или паре created_at, id), иначе записи с одинаковым ключом сортировки потеряются.

Page-based — обёртка над offset

Часто вместо offset клиенту дают номер страницы: ?page=3&per_page=20. Это удобнее читать, но математически то же самое — сервер вычисляет offset = (page - 1) * per_page и наследует все проблемы offset на глубоких страницах. Page-based — это эргономика поверх той же механики, а не отдельный третий способ.

Метаданные пагинации

Клиенту нужно знать, есть ли ещё данные и сколько их. Для offset/page удобно вернуть total и ссылки:

{
  "data": [ { "id": 41 }, { "id": 42 } ],
  "meta": {
    "total": 5000,
    "page": 3,
    "per_page": 20,
    "total_pages": 250
  }
}

Важная тонкость: подсчёт total стоит дорого — это отдельный COUNT(*) по таблице (а с фильтрами и того тяжелее). На очень больших данных total иногда сознательно опускают. Для курсорной пагинации total обычно вовсе не считают — вместо него отдают курсор следующей страницы:

{
  "data": [ { "id": 39 }, { "id": 40 } ],
  "meta": {
    "next_cursor": "eyJpZCI6NDB9",
    "has_more": true
  }
}

Заголовки против тела

Метаданные пагинации можно положить либо в тело (в meta, как выше), либо в HTTP-заголовки. Подход с заголовками держит тело «чистым» — там только данные:

HTTP/1.1 200 OK
X-Total-Count: 5000
Link: <https://api.example.com/v1/orders?page=4>; rel="next",
      <https://api.example.com/v1/orders?page=250>; rel="last"

Стандартный заголовок Link (RFC 8288) с rel="next"/"prev"/"last" — классика GitHub API. Подход с телом и конвертом нагляднее для фронтенда, который и так читает JSON. Жёсткого правила нет; главное — единообразие во всём API.

Как работает под капотом

Разница между offset и keyset — это разница между двумя стратегиями доступа в БД. OFFSET k заставляет планировщик прочитать и отбросить k строк: время растёт линейно с глубиной страницы, потому что «пропустить» в общем случае нельзя без чтения. Keyset с условием WHERE id > ? по индексу делает index seek — дерево индекса спускается прямо к нужному ключу за логарифмическое время, не трогая пропущенные строки. Поэтому курсор стабильно быстр на любой глубине. Дрейф объясняется так же на уровне модели: offset нумерует позиции, а позиции меняются при вставках и удалениях; keyset привязывается к значению ключа, которое от чужих вставок не зависит. Курсор обычно кодируют в base64 не ради безопасности, а чтобы клиент воспринимал его как непрозрачный токен и не пытался конструировать вручную — это оставляет серверу свободу менять внутренний формат курсора.

Частые ошибки

  • Offset на глубоких страницах ленты. Бесконечная прокрутка на offset=100000 убивает производительность — для лент нужен keyset.
  • Нет лимита на limit. Клиент просит ?limit=1000000 и кладёт сервер. Всегда ограничивайте максимум (например, 100) и задавайте дефолт.
  • Курсор без стабильной сортировки. Keyset по неуникальному полю без тай-брейка теряет или дублирует записи на границах страниц.
  • Дорогой total на каждом запросе. COUNT(*) по огромной отфильтрованной таблице на каждой странице — лишняя нагрузка; считайте total реже или опускайте.
  • Прозрачный курсор. Если курсор — это голый ?cursor=40, клиенты начнут на него завязываться, и вы не сможете сменить схему. Кодируйте его как непрозрачный токен.

Итоги

  • Пагинация обязательна для коллекций: защищает память сервера и отзывчивость клиента.
  • Offset/limit прост и даёт переход на любую страницу, но тормозит на больших смещениях и страдает от дрейфа.
  • Cursor/keyset стабилен и быстр на любой глубине, но не умеет «прыгнуть на страницу N» — идеален для лент.
  • Page-based — это offset с эргономикой номеров страниц, со всеми его ограничениями.
  • Метаданные (total, next_cursor) кладите в тело или в заголовки Link/X-Total-Count — но единообразно; всегда ограничивайте limit.
Проверьте себя
1. Почему offset-пагинация замедляется на глубоких страницах (например offset=100000)?
AJSON-сериализация больших offset медленнее
BБаза данных вынуждена прочитать и отбросить первые 100000 строк, прежде чем вернуть нужные
CБольшой offset не помещается в целочисленный тип и переполняется
DHTTP запрещает offset больше 65535
2. Какое главное ограничение у cursor/keyset-пагинации по сравнению с offset?
AОна медленнее на больших данных
BОна страдает от дрейфа данных сильнее offset
CПо ней нельзя перейти сразу на произвольную страницу N — только следующая/предыдущая
DОна работает только с XML, но не с JSON
3. Что вызывает «дрейф данных» при offset-пагинации?
AИзменение размера страницы между запросами
BВставки и удаления записей, сдвигающие позиции, из-за чего строки дублируются или пропадают между страницами
CКеширование ответа на стороне прокси
DИспользование snake_case вместо camelCase в полях