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).

Натискаючи кнопку, ви погоджуєтесь на обробку даних.