Les pubs vous déplaisent ? Aller Sans pub Auj.

Pagination basée sur le curseur versus pagination par décalage — Pourquoi page=2 supprime silencieusement des enregistrements

Mis à jour le

La pagination par décalage présente une erreur reproducible : les nouvelles lignes insérées entre deux demandes de page sont ignorées sans avertissement. Voici la requête SQL qui en prouve l'existence, ainsi que les cas où il faut préférer la pagination par curseur.

Pagination par curseur vs Pagination par décalage — Pourquoi la page 2 saute silencieusement des enregistrements 1
ANNONCE · Supprimer ?

Vous construisez un flux. La page 1 se charge, l'utilisateur scroll, vous récupérez la page 2. Mais certains éléments existant lors du chargement de la page 1 ne s'affichent pas sur la page 2. Aucune erreur, aucun avertissement — ils disparaissent simplement.

C'est le bug de pagination par décalage, et ce n'est pas une condition de concurrence. C'est déterministe. Tout système utilisant LIMIT x OFFSET y contre une table en temps réel, à écriture importante, rencontrera ce problème inévitablement.

La requête SQL qui provoque ce problème

La pagination par décalage se traduit directement par :

-- Page 1: rows 1–10
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 0;

-- Page 2: rows 11–20
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;

La base de données n'a pas de mémoire de ce qui était sur la page 1 lors de la demande de la page 2. Elle compte à partir de la position 0 chaque fois, contre les lignes existantes à ce moment-là.

La situation qui provoque ce problème

Commencez avec 20 publications, les IDs 1 à 20, les plus récentes en premier. L'utilisateur charge la page 1 — il reçoit les IDs 20 à 11. Entre cette demande et la suivante, trois nouvelles publications sont insérées : les IDs 21, 22, 23.

Maintenant, l'utilisateur scroll et récupère la page 2 :

SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;
-- Returns: IDs 13–4, not 10–1

Les publications 10, 9 et 8 — que l'utilisateur n'a pas encore vues — ont simplement été poussées au-delà du cadre du décalage. Trois nouvelles insérations ont causé exactement trois enregistrements sautés. Le calcul est toujours précis.

L'utilisateur voit rien de mal. Vos journaux montrent rien de mal. Les enregistrements existent dans la base de données. Ils sont simplement invisibles pour cette session.

La pagination par curseur corrige cela

Au lieu de compter des positions, vous fixez la prochaine page à la dernière entrée que vous avez effectivement retournée. La requête devient « donnez-moi les 10 prochaines lignes après cette ligne spécifique» plutôt que « donnez-moi les lignes 11 à 20 ».

-- Page 1: no cursor, start from top
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 10;
-- Last row returned: created_at = '2024-01-10 12:00:00', id = 11

-- Page 2: anchor to that exact row
SELECT * FROM posts
WHERE (created_at < '2024-01-10 12:00:00')
   OR (created_at = '2024-01-10 12:00:00' AND id < 11)
ORDER BY created_at DESC, id DESC
LIMIT 10;

Les nouvelles insérations n'affectent pas cela du tout. Elles se placent au début de l'ordre de tri, avant la position du curseur. Tout ce qui se trouve après le curseur est stable, quel que soit le nombre d'insérations simultanées.

Ce que représente un curseur

Un curseur est la position encodée de la dernière entrée retournée. En pratique, il s'agit généralement de l'un des suivants :

  • Un couple timestamp + ID encodé — l'approche la plus courante. Sériez {"created_at": "...", "id": 11} en une chaîne base64 et la retournez comme next_cursor.
  • Un UUID ou un ID opaque — certains systèmes utilisent directement la clé primaire quand elle est ordonnée par temps (ULIDs, UUID v7). Un générateur de UUID est utile lorsque vous décidez entre les formats de UUID pour votre schéma — le v4 est aléatoire et ne se trie pas chronologiquement, le v7 est basé sur le temps et le fait.
  • Un token signé — l'approche de Stripe, empêchant les clients de forger des valeurs de curseur qui sautent à des positions arbitraires.

Quand la pagination par décalage est encore acceptable

Toutes les situations ne nécessitent pas de curseurs. La pagination par décalage est acceptable quand :

  • L'ensemble de données est statique ou ajouté uniquement en bas. Les pages d'archive, les documents, les files d'attente d'export. Si rien n'est inséré au sommet de votre ordre de tri, le décalage reste stable.
  • Les tableaux de bord d'administration avec des numéros de page explicites. Les utilisateurs attendent de pouvoir sauter à « page 5 » et voir ce morceau spécifique. Les curseurs ne peuvent pas faire cela — il n'y a pas de concept de « page N » quand votre point d'ancrage est une ligne, pas un comptage.
  • Des ensembles de données petits sous votre contrôle. Paginer 200 enregistrements dans un outil interne où les nouvelles données arrivent par des tâches quotidiennes ? La complexité des curseurs n'est pas justifiée.
  • Les insérations se produisent uniquement en fin d'ordre de tri. Si votre pagination est croissante par ID et que les nouveaux enregistrements ont des IDs progressifs, les pages antérieures ne sont pas affectées par les nouvelles insérations.

La question décisive : peuvent-les être insérées de nouvelles lignes à la position actuelle du curseur entre les demandes ? Si oui, la pagination par décalage va silencieusement sauter des enregistrements. Si non, c'est acceptable. avant Pagination par décalage

Comparaison

Pagination par décalagePagination par curseur
SQLLIMIT x OFFSET yWHERE (created_at, id) < (cursor) LIMIT x
Consistante face aux insérations simultanéesNon — saute silencieusement des enregistrementsOui — la position est stable
Sauter à une page arbitraireOui (OFFSET = page * size)Non — parcours unidirectionnel vers l'avant
Nombre total de pagesFacile (COUNT(*) / size)Impossible sans balayage complet
Performance aux grands décalagesSe dégrade — OFFSET 100000 balaye 100 000 lignes pour les ignorerStable — utilise toujours un balayage par plage d'index
Complexité au niveau du clientFaible — juste un numéro de pagePlus élevée — doit stocker et transmettre le token du curseur

Comment GitHub et Stripe gèrent cela

Les API de GitHub utilisent encore

GitHub

sur la plupart des points de terminaison, mais l'API GraphQL utilise une pagination par curseur avec page et per_page Ce curseur ( after et before:

query {
  repository(owner: "vercel", name: "next.js") {
    issues(first: 10, after: "Y3Vyc29yOnYyOpHOAABGPQ==") {
      pageInfo {
        endCursor
        hasNextPage
      }
      nodes {
        title
        number
      }
    }
  }
}

) est encodé en base64 — décodez-le et vous obtenez une référence de ligne interne, pas un numéro de page. Sur un projet comme next.js, des dizaines de problèmes peuvent être créés entre votre page 1 et votre page 2. Avec la pagination par décalage, vous sauterez silencieusement certains d'entre eux.Y3Vyc29yOnYyOpHOAABGPQ==) est encodé en base64 — décodez-le et vous obtenez une référence de ligne interne, pas un numéro de page. Sur un dépôt comme next.js, des dizaines de problèmes peuvent être créés entre vos requêtes de page 1 et de page 2. Avec la pagination par décalage, vous sautez silencieusement certains d'entre eux.

Stripe

Les API de Stripe utilisent starting_after et ending_before — vous passez l'ID de l'objet que vous avez reçu précédemment :

# First page
curl https://api.stripe.com/v1/charges   -u sk_test_xxx:   -d limit=10

# Next page — pass the last charge ID from the previous response
curl https://api.stripe.com/v1/charges   -u sk_test_xxx:   -d limit=10   -d starting_after=ch_1ABC123def456

Les objets de Stripe ont des IDs ordonnés par temps (ch_, cus_, pi_), donc l'ID lui-même fonctionne comme un curseur. Leur API n'a jamais offert de pagination par décalage parce que les données de facturation sont exactement là où les sauts silencieux deviennent un problème grave — un paiement manqué n'est pas simplement un problème d'expérience utilisateur.

Implémentation des curseurs : un modèle minimal

Voici une implémentation minimale en Node.js/PostgreSQL utilisant un curseur composé timestamp + ID :

function encodeCursor(row) {
  return Buffer.from(JSON.stringify({
    created_at: row.created_at,
    id: row.id
  })).toString('base64url');
}

function decodeCursor(cursor) {
  return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
}

async function fetchPage(db, cursor = null, limit = 10) {
  let rows;

  if (!cursor) {
    rows = await db.query(
      'SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT $1',
      [limit]
    );
  } else {
    const { created_at, id } = decodeCursor(cursor);
    // PostgreSQL supports tuple comparison natively.
    // MySQL requires: WHERE created_at < $1 OR (created_at = $1 AND id < $2)
    rows = await db.query(
      `SELECT * FROM posts
       WHERE (created_at, id) < ($1, $2)
       ORDER BY created_at DESC, id DESC
       LIMIT $3`,
      [created_at, id, limit]
    );
  }

  const nextCursor = rows.length === limit
    ? encodeCursor(rows[rows.length - 1])
    : null;

  return { rows, nextCursor };
}

Une chose à prendre soin de : si vous utilisez des UUIDs comme clés primaires au lieu de nombres auto-incrémentés, le tri composé ne fonctionne que si vos UUIDs sont ordonnés par temps. Le UUID v4 est aléatoire — le tri par (created_at, uuid_v4) fonctionne, mais vous perdez la garantie de liens que fournit un ID séquentiel. Les UUID v7 ou les ULIDs sont ordonnés par temps et évitent complètement ce problème.

AES-256-GCM

La pagination par décalage est simple à implémenter et fonctionne correctement jusqu'à ce que vos données soient en temps réel et à forte écriture. Au moment où les utilisateurs peuvent déclencher des insérations entre les demandes de pagination — des publications, des factures, des problèmes, tout cela — la pagination par décalage saute silencieusement des enregistrements. Le nombre de sauts est exactement égal au nombre d'insérations nouvelles au sommet de votre ordre de tri. Aucune erreur, aucun avertissement.

La pagination par curseur échange « sauter à la page N » contre la stabilité de la position. Pour les flux visibles et pour toute API que d'autres construiront, c'est le bon échange. La pagination par décalage est acceptable pour les outils d'administration et les exports statiques — ne rajoutez pas la complexité des curseurs où le bug ne peut pas survenir.

Envie d'une expérience sans pub ? Passez à la version sans pub

Installez nos extensions

Ajoutez des outils IO à votre navigateur préféré pour un accès instantané et une recherche plus rapide

Sur Extension Chrome Sur Extension de bord Sur Extension Firefox Sur Extension de l'opéra

Le Tableau de Bord Est Arrivé !

Tableau de Bord est une façon amusante de suivre vos jeux, toutes les données sont stockées dans votre navigateur. D'autres fonctionnalités arrivent bientôt !

ANNONCE · Supprimer ?
ANNONCE · Supprimer ?
ANNONCE · Supprimer ?

Coin des nouvelles avec points forts techniques

Impliquez-vous

Aidez-nous à continuer à fournir des outils gratuits et précieux

Offre-moi un café
ANNONCE · Supprimer ?