GH GambleHub

Пагинация и курсоры

1) Зачем нужна пагинация

Пагинация ограничивает объем данных, передаваемых и рендеримых клиентом, снижает нагрузку на хранилища/сети и задает детерминированный способ «прогулки» по коллекции. В реальных системах пагинация — это не только `page=1&limit=50`, а набор протокольных контрактов и инвариантов согласованности.

Типовые цели:
  • Контроль латентности и памяти на запрос.
  • Стабильная навигация при изменении набора данных (вставки/удаления).
  • Возможность возобновления с места (resumption).
  • Кеширование и предзагрузка (prefetch).
  • Защита от злоупотреблений (rate limiting, backpressure).

2) Модели пагинации

2.1 OFFSET/LIMIT (страничная)

Идея: «пропусти N строк, верни M».
Плюсы: простота, совместимо почти с любым SQL/NoSQL.

Минусы:
  • Линейная деградация: большие OFFSET приводят к полному сканированию/skip-cost.
  • Нестабильность при вставках/удалениях между запросами (смещения «плавают»).
  • Трудно обеспечить точную «возобновляемость».
SQL-пример:
sql
SELECT
FROM orders
ORDER BY created_at DESC, id DESC
OFFSET 1000 LIMIT 50;

2.2 Cursor/Keyset/Seek-пагинация

Идея: «продолжай с ключа K». Курсор — это позиция в отсортированном наборе.

Плюсы:
  • O(1) доступ к продолжению при наличии индекса.
  • Стабильность при изменениях коллекции.
  • Лучшая латентность на глубоких «страницах».
Минусы:
  • Нужны строго определенные, уникальные и монотонные ключи сортировки.
  • Сложнее реализации и отладки.
SQL-пример (seek):
sql
-- Resumption after steam (created_at, id) = (:last_ts,:last_id)
SELECT
FROM orders
WHERE (created_at, id) < (:last_ts,:last_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;

2.3 Continuation tokens (непрозрачные токены)

Идея: сервер возвращает opaque-токен, в котором закодирована «позиция» (и, возможно, состояние шардов/фильтров). Клиент не понимает внутренностей и просто возвращает токен для следующей страницы.
Плюсы: гибкость, возможность менять схему без ломки API.
Минусы: управление сроком жизни токенов, совместимость при деплоях.

2.4 Временные и логические курсоры

Time-based: «все записи до T», курсор — метка времени (подходит при append-only потоках).
Log-sequence/offset-based: курсор — смещение в логе (Kafka offset, journal seq).
Global monotonic IDs: Snowflake/UUIDv7 как сортируемые ключи для стабильного seek.

3) Проектирование курсов и токенов

3.1 Свойства хорошего курсора

Непрозрачность (opaque): клиент не зависит от формата.
Авторство/целостность: подпись HMAC, чтобы предотвратить подмену/манипуляцию.
Контекст: включает сортировку, фильтры, версию схемы, tenant/shard.
Срок жизни: TTL и «неподнимаемость» (non-replay) при смене индексов/прав доступа.
Размер: компактный (<= 1–2 KB), пригодный для URL.

3.2 Формат токена

Рекомендуемый стек: JSON → сжатие (zstd/deflate) → Base64URL → HMAC.

Структура полезной нагрузки (пример):
json
{
"v": 3 ,//token version
"sort": ["created_at:desc","id:desc"],
"pos": {"created_at":"2025-10-30T12:34:56. 789Z","id":"987654321"},
"filters": {"user_id":"42","status":["paid","shipped"]},
"tenant": "eu-west-1",
"shards": [{"s ": "a, "" hi":"..."}] ,//optional: cross-shard context
"issued_at": "2025-10-31T14:00:00Z",
"ttl_sec": 3600
}

Сверху добавляется `mac = HMAC(secret, payload)` и все кодируется в один строковый токен.

3.3 Безопасность

Подписывать (HMAC/SHA-256).
Опционально шифровать (AES-GCM) при наличии чувствительных значений (PII).
Валидация на сервере: версия, TTL, полномочия пользователя (RBAC/ABAC).

4) Согласованность и инварианты

4.1 Стабильная сортировка

Используйте полный детерминизм: `ORDER BY ts DESC, id DESC`.
Ключ сортировки должен быть уникальным (добавляйте `id` как tiebreaker).
Индекс должен соответствовать сортировке (covering index).

4.2 Снимки (snapshot) и изоляция

Для «нескачущих» страниц используйте read-consistent snapshot (MVCC/txid).
Если снапшот нецелесообразен (дорого/много данных), формулируйте контракт: «курсор возвращает элементы, строго ранее позиции». Это естественно для новостных лент.

4.3 Вставки/удаления между страницами

Seek-модель минимизирует «дубликаты/пропуски».
Документируйте поведение при удалении/изменении: допускаются редкие «дырки» между страницами, но не «назад во времени».

5) Индексирование и схемы идентификаторов

Композитные индексы строго в порядке сортировки: `(created_at DESC, id DESC)`.
Монотонные ID: Snowflake/UUIDv7 дают порядок по времени → ускоряют seek.
Горячие ключи: распределяйте по shard-key (например, `tenant_id`, `region`) и сортируйте внутри шарда.
Генераторы ID: избегайте коллизий и «часов из будущего» (clock skew) — синхронизация времени, «регрессия» при NTP-прыжках.

6) Кросс-шардовая пагинация

6.1 Схемы

Scatter-Gather: параллельные запросы во все шарды, локальные seek-курсы, затем k-way merge на агрегаторе.
Per-Shard Cursors: токен содержит позиции по каждому шарду.
Bounded fan-out: ограничивайте число шардов за один шаг (rate limiting/timeout budget).

6.2 Токены для multi-shard

Храните массив `{shard_id, last_pos}`. На следующем шаге возобновляйтесь для каждого активного шарда и снова мержите, отдавая глобально отсортированную страницу.

7) Протокольные контракты

7.1 REST

Запрос:

GET /v1/orders? limit=50&cursor=eyJ2IjoiMyIsInNvcnQiOiJjcmVh... (opaque)
Ответ:
json
{
"items": [ /... / ],
"page": {
"limit": 50,
"next_cursor": "eyJ2IjozLCJwb3MiOiJjcmVh...==",
"has_more": true
}
}
Рекомендации:
  • `limit` с верхней границей (например, max=200).
  • `next_cursor` отсутствует, если `has_more=false`.
  • Идемпотентность GET, кэшируемость ответов без `next_cursor` (первая страница при фиксированных фильтрах и snapshot).

7.2 GraphQL (Relay-подход)

Типичный контракт `connection`:
graphql type Query {
orders(first: Int, after: String, filter: OrderFilter): OrderConnection!
}

type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
}

type OrderEdge {
node: Order!
cursor: String! // opaque
}

type PageInfo {
hasNextPage: Boolean!
endCursor: String
}

`cursor` должен быть непрозрачным и подписанным; не используйте «сырой Base64(id)» без HMAC.

7.3 gRPC

Используйте `page_size` и `page_token`:
proto message ListOrdersRequest {
string filter = 1;
int32 page_size = 2;
string page_token = 3; // opaque
}

message ListOrdersResponse {
repeated Order items = 1;
string next_page_token = 2; // opaque bool has_more = 3;
}

7.4 Потоки и WebSockets

Для непрерывных лент: курсор как «последний увиденный offset/ts».

Поддерживайте `resume_from` при реконнекте:
json
{ "action":"subscribe", "topic":"orders", "resume_from":"2025-10-31T12:00:00Z#987654321" }

8) Кеширование, предзагрузка, CDN

ETag/If-None-Match для первой страницы со стабильными фильтрами.
Cache-Control с коротким TTL (например, 5–30 с) для публичных списков.
Prefetch: возвращайте `next_cursor` и подсказки (`Link: rel="next"`), клиент может предзагрузить следующую страницу.
Вариации: учитывайте `filter/sort/locale/tenant` как ключ части кеша.

9) Управление нагрузкой и лимитирование

Верхняя граница `limit`, например 200.
Server-side backpressure: если время запроса > budget, уменьшайте `limit` в ответе (и явно сообщайте клиенту фактический размер страницы).
Rate limits на пользователя/токен/tenant.
Timeout/Retry: экспоненциальная пауза, идемпотентные повторные запросы.

10) UX-аспекты

Скролл против нумерации: бесконечная прокрутка → курсоры; номерные страницы → offset (но объясните неточность при обновлении данных).
Кнопка «вернуться на место»: храните стек курсоров клиента.
Пустые страницы: если `has_more=false`, не показывайте кнопку «Еще».
Стабильные границы: показывайте точный `total` только если он дешев (иначе — приблизительный `approx_total`).

11) Тестирование и edge-кейсы

Чек-листы:
  • Стабильная сортировка: элементы с одинаковым `ts` не «мигают».
  • Вставки/удаления: не появляются повторы на стыке страниц.
  • Изменение фильтров между страницами: токен должен отклоняться как «устаревший/несовместимый».
  • TTL токена: корректная ошибка по истечении срока.
  • Большая глубина: латентность не растет линейно.
  • Мультишард: корректный merge-порядок, отсутствие starvation «медленных» шардов.
Пример property-based теста (псевдокод):
python
Generate N entries with random inserts between calls
Verify that all pages are merged = = whole ordered fetch

12) Наблюдаемость и SLO

Метрики:
  • `list_request_latency_ms` (P50/P95/P99) по длине страницы.
  • `seek_index_hit_ratio` (доля запросов, ушедших по покрывающему индексу).
  • `next_cursor_invalid_rate` (ошибки валидации/TTL/подписи).
  • `merge_fanout` (кол-во задействованных шардов на страницу).
  • `duplicates_on_boundary` и `gaps_on_boundary` (детект на клиентской телеметрии).
Логи/трейсинг:
  • Коррелируйте `cursor_id` в логах, маскируйте payload.
  • Тегируйте спаны: `page_size`, `source_shards`, `db_index_used`.
SLO-пример:
  • Доступность: 99.9% на `List` методы.
  • Латентность: P95 < 200 мс для `page_size<=50` при локальном шарде.
  • Ошибка токена: < 0.1% от общего числа вызовов.

13) Миграции и совместимость

Включайте `v` в токене и поддерживайте старые версии N недель.
При смене ключей сортировки — отправляйте «мягкую» ошибку `409 Conflict` с подсказкой выполнить свежий листинг без курсора.
Катастрофический случай (ревок всех токенов): меняйте `signing_key_id` и отклоняйте старые.

14) Примеры реализаций

14.1 Генерация токена (псевдокод)

python payload = json. dumps({...}). encode()
compressed = zlib. compress(payload)
mac = hmac_sha256(signing_key, compressed)
token = base64url_encode(mac + compressed)

14.2 Валидация токена

python raw = base64url_decode(token)
mac, compressed = raw[:32], raw[32:]
assert mac == hmac_sha256(signing_key, compressed)
payload = json. loads(zlib. decompress(compressed))
assert now() - payload["issued_at"] < payload["ttl_sec"]
assert payload["filters"] == req. filters

14.3 Seek-запрос с композитным ключом

sql
-- Page # 1
SELECT FROM feed
WHERE tenant_id =:t
ORDER BY ts DESC, id DESC
LIMIT:limit;

-- Next pages, continued after (ts0, id0)
SELECT FROM feed
WHERE tenant_id =:t
AND (ts <:ts0 OR (ts =:ts0 AND id <:id0))
ORDER BY ts DESC, id DESC
LIMIT:limit;

15) Безопасность и соответствие

Не включайте в токены сырые поля, из которых можно вывести PII.
Подписывайте и ограничивайте TTL.
Старайтесь делать токены непереносимыми между пользователями (вписывайте `sub/tenant/roles` в payload и сверяйте при валидации).
Логируйте только хеши токенов.

16) Частые ошибки и анти-паттерны

Base64(id) как курсор: легко подделать/подобрать, ломает контракт при смене сортировки.
Отсутствие tie-breaker: `ORDER BY ts DESC` без `id` → дубликаты/скачки.
Смена фильтров между страницами без инвалидирования токена.
Глубокий OFFSET: медленно и непредсказуемо.
Токены без версии и TTL.

17) Мини-чеклист внедрения

1. Определите сортировку и добавьте уникальный tie-breaker.
2. Создайте покрывающий индекс под этот порядок.
3. Выберите модель: seek + непрозрачный токен.
4. Реализуйте подпись (и, при необходимости, шифрование) токена.
5. Заложите TTL и версионирование.
6. Сформулируйте и задокументируйте контракты `has_more`, `next_cursor`.
7. Продумайте кросс-шардовую схему (если нужно) и k-way merge.
8. Добавьте метрики, алерты и SLO.
9. Покройте property-based тестами границы страниц.
10. Опишите миграционную стратегию токенов.

18) Краткие рекомендации по выбору подхода

Каталоги/поиски, где важен «номер страницы» и приблизительный total: допустим `OFFSET/LIMIT` + кэш; сообщайте, что total приблизителен.
Ленты, аналитика, глубокие списки, высокие RPS: только cursor/seek.
Шардированные/распределенные коллекции: per-shard cursors + merge токен.
Потоки/CDC: курсоры как offsets/ts с возобновлением.

19) Пример договоренности API (резюме)

`GET /v1/items?limit=50&cursor=...`

Ответ всегда включает `page.limit`, `page.has_more`, опционально `page.next_cursor`.
Курсор непрозрачный, подписанный, c TTL.
Сортировка детерминирована: `ORDER BY created_at DESC, id DESC`.
Поведение при изменениях набора: элементы не «возвращаются назад» относительно курсора.
Метрики и ошибки стандартизированы: `invalid_cursor`, `expired_cursor`, `mismatch_filters`.

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

Contact

Свяжитесь с нами

Обращайтесь по любым вопросам или за поддержкой.Мы всегда готовы помочь!

Начать интеграцию

Email — обязателен. Telegram или WhatsApp — по желанию.

Ваше имя необязательно
Email необязательно
Тема необязательно
Сообщение необязательно
Telegram необязательно
@
Если укажете Telegram — мы ответим и там, в дополнение к Email.
WhatsApp необязательно
Формат: +код страны и номер (например, +380XXXXXXXXX).

Нажимая кнопку, вы соглашаетесь на обработку данных.