Pagination et curseurs
1) Pourquoi la pagination est nécessaire
La pagination limite la quantité de données transmises et rendues par le client, réduit la charge de travail sur le stockage/réseau et définit une façon déterministe de « marcher » dans la collection. Dans les systèmes réels, la pagination n'est pas seulement 'page = 1 & limit = 50', mais un ensemble de contrats de protocole et d'invariants de cohérence.
Objectifs types :- Contrôle de la latence et de la mémoire à la demande.
- Navigation stable lorsque le jeu de données est modifié (insertion/suppression).
- Possibilité de reprendre à partir du site (resumption).
- Cache et précharge (prefetch).
- Protection contre les abus (rate limiting, backpressure).
2) Modèles de pagination
2. 1 OFFSET/LIMIT (page)
Idée : « laissez passer N lignes, revenez M ».
Avantages : simplicité, compatible avec presque n'importe quel SQL/NoSQL.
- Dégradation linéaire : Les grands OFFSET conduisent à la numérisation complète/skip-cost.
- Instabilité dans les inserts/suppressions entre les requêtes (décalage flottant).
- Il est difficile d'assurer une « renouvelable » précise.
sql
SELECT
FROM orders
ORDER BY created_at DESC, id DESC
OFFSET 1000 LIMIT 50;
2. 2 Cursor/Keyset/Seek-pagination
« Continue avec la clé K ». Le curseur est une position dans un ensemble trié.
Avantages :- O (1) l'accès à la continuation lorsque l'indice est disponible.
- Stabilité lors des changements de collection.
- La meilleure latence sur les « pages » profondes.
- Il faut des clés de tri strictement définies, uniques et monotones.
- Plus difficile à mettre en œuvre et à déboguer.
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 Tokens de continuation (jetons opaques)
Idée : le serveur renvoie le jeton opaque dans lequel la « position » est codée (et éventuellement l'état des chardes/filtres). Le client ne comprend pas les entrailles et renvoie simplement un jeton pour la page suivante.
Avantages : flexibilité, possibilité de changer le schéma sans casser l'API.
Inconvénients : gestion de la durée de vie des tokens, compatibilité avec les déploiements.
2. 4 Curseurs temporaires et logiques
Time-based : « tous les enregistrements jusqu'à T », le curseur est l'étiquette temporelle (convient aux flux append-only).
Log-sequence/offset-based : le curseur est un décalage dans le journal (Kafka offset, journal seq).
Global monotonic IDs : Snowflake/UUIDv7 comme clés triables pour un seek stable.
3) Conception de cours et de tokens
3. 1 Propriétés d'un bon curseur
Opacité (opaque) : le client est indépendant du format.
Auteur/intégrité : signature HMAC pour empêcher le remplacement/manipulation.
Contexte : comprend le tri, les filtres, la version du schéma, tenant/shard.
Durée de vie : TTL et « inapplicable » (non-replay) lors du changement d'index/de droits d'accès.
Taille : compacte (<= 1-2 KB), adaptée à l'URL.
3. 2 Format de token
Pile recommandée : JSON → compression (zstd/deflate) → Base64URL → HMAC.
Structure de la charge utile (exemple) :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
}
En haut, « mac = HMAC (secret, payload) » est ajouté et tout est codé en un seul jeton de chaîne.
3. 3 Sécurité
Signer (HMAC/SHA-256).
Chiffrer en option (AES-GCM) en présence de valeurs sensibles (PII).
Validation sur le serveur : version, TTL, autorisation de l'utilisateur (RBAC/ABAC).
4) Cohérence et invariants
4. 1 Tri stable
Utilisez le déterminisme complet : 'ORDER BY ts DESC, id DESC'.
La clé de tri doit être unique (ajoutez 'id'comme tiebreaker).
L'index doit correspondre au tri (covering index).
4. 2 snapshot et isolation
Pour les pages « non », utilisez le snapshot read-consistent (MVCC/txid).
Si le snapshot n'est pas pratique (données coûteuses/nombreuses), formulez le contrat : « le curseur renvoie les éléments, strictement avant la position ». C'est naturel pour les nouvelles.
4. 3 Inserts/suppressions entre les pages
Le modèle seek minimise les « doublons/sauts ».
Documenter le comportement lors de la suppression/modification : des « trous » rares entre les pages sont autorisés, mais pas « dans le temps ».
5) Indexation et schémas d'identification
Les indices composites sont strictement dans l'ordre de tri : '(created_at DESC, id DESC)'.
ID monotone : les Snowflake/UUIDv7 donnent de l'ordre dans le temps → accélèrent la recherche.
Clés chaudes : répartissez-le par clé shard (par exemple, 'tenant _ id', 'region') et triez-le à l'intérieur du shard.
Générateurs ID : évitez les collisions et « horloge du futur » (clock skew) - synchronisation du temps, « régression » dans les sauts NTP.
6) Pagination Cross-Chard
6. 1 Schémas
Scatter-Gather : requêtes parallèles à toutes les chardes, cours de seek locaux, puis k-way merge sur l'agrégateur.
Per-Shard Cursors : Le jeton contient des positions pour chaque charde.
Bounded fan-out : limitez le nombre de chardes en une seule étape (rate limiting/timeout budget).
6. 2 jetons pour multi-shard
Stockez le tableau '{shard _ id, last_pos}'. Dans l'étape suivante, reprenez pour chaque shard actif et remettez-le en donnant une page triée globalement.
7) Contrats de protocole
7. 1 REST
Demande :
GET /v1/orders? limit=50&cursor=eyJ2IjoiMyIsInNvcnQiOiJjcmVh... (opaque)
Réponse :
json
{
"items": [ /... / ],
"page": {
"limit": 50,
"next_cursor": "eyJ2IjozLCJwb3MiOiJjcmVh...==",
"has_more": true
}
}
Recommandations :
- 'Limit 'avec une limite supérieure (par exemple max = 200).
- 'Next _ cursor 'est absent si' has _ more = false '.
- Idempotence GET, mise en cache des réponses sans 'next _ cursor' (première page pour les filtres fixes et snapshot).
7. 2 GraphQL (approche relais)
Contrat type '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 » doit être opaque et signé ; n'utilisez pas de « Base64 brut (id) » sans HMAC.
7. 3 gRPC
Utilisez 'page _ size' et '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 Threads et WebSockets
Pour les bandes continues : curseur comme « dernière vue offset/ts ».
Maintenez 'resume _ from' lors de la reconfiguration :json
{ "action":"subscribe", "topic":"orders", "resume_from":"2025-10-31T12:00:00Z#987654321" }
8) Cache, pré-chargement, CDN
ETag/If-None-Match pour la première page avec des filtres stables.
Cache-Control avec TTL court (par exemple, 5-30 s) pour les listes publiques.
Prefetch : retournez 'next _ cursor' et les indices ('Link : rel = « next »), le client peut précharger la page suivante.
Variations : considérez 'filter/sort/locale/tenant' comme la clé de la partie cache.
9) Gestion de la charge et limitation
La limite supérieure 'limit', par example 200.
Server-side backpressure : si le temps de demande est> budget, réduire 'limit' dans la réponse (et informer explicitement le client de la taille réelle de la page).
Taux limite par utilisateur/token/tenant.
Timeout/Retry : pause exponentielle, demandes répétées idempotentes.
10) Aspects UX
Scroll vs numérotation : défilement infini → curseurs ; pages numérotées → offset (mais expliquer l'inexactitude lors de la mise à jour des données).
Bouton « Retourner à la place » : stockez la pile de curseurs du client.
Pages vides : si 'has _ more = false', n'affichez pas le bouton Plus.
Limites stables : n'affichez le « total » exact que s'il est bon marché (sinon, l'approximatif « approx _ total »).
11) Tests et cas edge
Chèques :- Tri stable : Les éléments avec le même 't'ne clignotent pas.
- Insertion/suppression : aucune répétition n'apparaît à la jonction des pages.
- Modification des filtres entre les pages : le jeton doit être rejeté comme « obsolète/incompatible ».
- Token TTL : erreur correcte à l'expiration du délai.
- Grande profondeur : la latence ne croît pas linéairement.
- Multishard : ordre merge correct, absence de starvation des chardes « lentes ».
python
Generate N entries with random inserts between calls
Verify that all pages are merged = = whole ordered fetch
12) Observabilité et SLO
Métriques :- 'list _ request _ latency _ ms' (P50/P95/P99) en longueur de page.
- 'Seek _ index _ hit _ ratio '(proportion de requêtes qui ont quitté l'index de couverture).
- 'Next _ cursor _ invalid _ rate '(erreurs de validation/TTL/signature).
- 'Merge _ fanout '.
- 'duplicates _ on _ boundary 'et' gaps _ on _ boundary '(détail sur la télémétrie client).
- Corréler 'cursor _ id'dans les logs, masquer payload.
- Taguez les spans : 'page _ size', 'source _ shards', 'db _ index _ used'.
- Disponibilité : 99. 9 % sur 'List' méthodes.
- Latence : P95 <200 ms pour 'page _ size <= 50' à l'écharpe locale.
- Erreur de token : <0. 1 % du nombre total d'appels.
13) Migrations et interopérabilité
Incluez 'v' dans le token et maintenez les anciennes versions de N semaines.
Si vous changez de clé de tri, envoyez l'erreur '409 Conflict' avec une invite pour effectuer une nouvelle liste sans curseur.
Cas catastrophique (rougeurs de tous les tokens) : changez 'signing _ key _ id'et rejetez les anciens.
14) Exemples de réalisation
14. 1 Génération de token (pseudo-code)
python payload = json. dumps({...}). encode()
compressed = zlib. compress(payload)
mac = hmac_sha256(signing_key, compressed)
token = base64url_encode(mac + compressed)
14. 2 Validation du token
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 Demande seek avec clé composite
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) Sécurité et conformité
N'incluez pas dans les jetons les champs bruts dont vous pouvez déduire le PII.
Signez et limitez votre TTL.
Essayez de rendre les tokens intolérables entre les utilisateurs (inscrivez 'sub/tenant/roles' dans le payload et vérifiez lors de la validation).
Ne logez que les hachages de token.
16) Erreurs fréquentes et anti-modèles
Base64 (id) comme un curseur : facile à falsifier/ramasser, brise le contrat lorsque vous changez de tri.
Absence de tie-breaker : 'ORDER BY ts DESC'sans' id' → doublons/sauts.
Changer les filtres entre les pages sans invalider le token.
OFFSET profond : lent et imprévisible.
Jetons sans version et TTL.
17) Mini-chéquier de mise en œuvre
1. Définissez le tri et ajoutez un tie-breaker unique.
2. Créez un index de couverture pour cet ordre.
3. Sélectionnez le modèle : seek + jeton opaque.
4. Mettre en œuvre la signature (et, si nécessaire, le chiffrement) du token.
5. Poser le TTL et le versioning.
6. Formez et documentez les contrats 'has _ more', 'next _ cursor'.
7. Pensez à un schéma de chardons croisés (si nécessaire) et à un k-way merge.
8. Ajoutez des métriques, des alertes et des SLO.
9. Couvrez la limite des pages avec les tests property-based.
10. Décrivez la stratégie de migration des tokens.
18) Brèves recommandations sur le choix de l'approche
Répertoires/recherches où le « numéro de page » et le total approximatif sont importants : disons 'OFFSET/LIMIT' + cache ; signalez que le total est approximatif.
Rubans, analyses, listes profondes, RPS élevés : seulement cursor/seek.
Collections chardonnées/distribuées : per-shard cursors + merge token.
Flux/CDC : curseurs comme offsets/ts avec reprise.
19) Exemple d'arrangement API (résumé)
`GET /v1/items? limit=50&cursor=...`
La réponse inclut toujours 'page. limit`, `page. has_more', en option 'page. next_cursor`.
Le curseur est opaque, signé, c TTL.
Le tri est déterministe : 'ORDER BY created_at DESC, id DESC'.
Comportement des changements de jeu : les éléments ne « reviennent » pas par rapport au curseur.
Les métriques et les erreurs sont normalisées : 'invalid _ cursor', 'expired _ cursor', 'mismatch _ filters'.
Cet article donne des principes architecturaux et des modèles prêts à l'emploi pour concevoir une pagination qui reste rapide, prévisible et sécurisée, même dans des environnements de big data, de chardonnages et de jeux d'enregistrements changeant activement.