Anúncios incomodam? Ir Sem anúncios Hoje

Paginação com base em cursor versus paginação por offset — Por que page=2 exclui silenciosamente registros

Atualizado em

A paginação com offset tem um bug reproduzível: linhas novas inseridas entre as requisições de página ignoram silenciosamente registros. Aqui está o SQL que prova isso, e quando usar paginação com cursor em vez disso.

A paginação por offset tem um bug reprodutível: novas linhas inseridas entre as requisições de página silenciosamente omitem registros. Aqui está o SQL que prova isso, e quando usar paginação por cursor em vez disso.
ANUNCIADO Remover?

Você está construindo um feed. A página 1 carrega, o usuário rola para baixo e você recarrega a página 2. Mas alguns itens que existiam quando a página 1 foi carregada não aparecem na página 2. Não há erro, nem aviso — eles simplesmente não aparecem.

Este é o bug de paginação por offset e não é uma condição de corrida. É determinístico. Todo sistema que use LIMIT x OFFSET y um banco de dados em tempo real e com grande volume de escrita acabará enfrentando esse problema.

O SQL que causa isso

A paginação por offset se traduz diretamente para:

-- 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;

O banco de dados não tem memória do que estava na página 1 quando você solicita a página 2. Ele conta a partir da posição 0 sempre, contra as linhas que existem no momento.

A situação que quebra isso

Comece com 20 posts, IDs de 1 a 20, mais recentes primeiro. O usuário carrega a página 1 — recebe IDs de 20 a 11. Entre essa solicitação e a próxima, três novos posts são inseridos: IDs 21, 22 e 23.

Agora o usuário rola e recarrega a página 2:

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

Os posts 10, 9 e 8 — que o usuário ainda não viu — foram simplesmente empurrados além da janela do offset. Três novas inserções causaram exatamente três registros omitidos. A matemática é sempre exata.

O usuário não vê nada errado. Seus logs mostram nada errado. Os registros existem no banco de dados. Eles simplesmente são invisíveis para aquela sessão.

A paginação por cursor corrige isso

Em vez de contar posições, você ancla a próxima página ao último registro realmente retornado. A consulta se torna "dê-me as próximas 10 linhas após essa linha específica" em vez de "dê-me as linhas de 11 a 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;

Novas inserções não afetam isso em absoluto. Elas aparecem no topo da ordem de classificação, antes da posição do cursor. Tudo a partir do cursor é estável, independentemente de escritas concorrentes.

O que é realmente um cursor

Um cursor é a posição codificada do último registro retornado. Na prática, geralmente é um dos seguintes:

  • Um par de timestamp + ID codificado — a abordagem mais comum. Codifique {"created_at": "...", "id": 11} como uma string base64 e retorne como next_cursor.
  • Um UUID ou um ID de linha abstrato — alguns sistemas usam a chave primária diretamente quando a ordem é temporal (ULIDs, UUID v7). Um gerador de UUID é útil quando você está decidindo entre formatos de UUID para sua esquema — o v4 é aleatório e não ordena cronologicamente, o v7 é baseado em timestamp e sim.
  • Um token assinado — a abordagem do Stripe, impedindo que os clientes fabricam valores de cursor que pulem para posições arbitrárias.

Quando a paginação por offset ainda é adequada

Não todo caso de uso precisa de cursos. A paginação por offset é adequada quando:

  • O conjunto de dados é estático ou apenas acrescentado do final. Páginas de arquivamento, documentação, filas de exportação. Se nada for inserido no topo da ordem de classificação, o offset permanece estável.
  • Dashboards de administração com números de página explícitos. Os usuários esperam pular para "página 5" e verem aquela fatia específica. Cursos não conseguem fazer isso — não há conceito de "página N" quando seu ancoramento é uma linha, não um contador.
  • Dados pequenos sob seu controle. Paginando 200 registros em uma ferramenta interna onde os novos dados chegam por trabalhos diários? A complexidade dos cursos não vale a pena.
  • As inserções só acontecem no final da ordem de classificação. Se sua paginação é em ordem crescente por ID e novos registros recebem IDs progressivamente maiores, as páginas anteriores não são afetadas por novas inserções.

A pergunta decisiva: podem novas linhas ser inseridas antes na posição atual do cursor entre as solicitações? Se sim, a paginação por offset silenciosamente omitirá registros. Se não, está tudo bem.

Comparação

Paginação por offsetPaginação por cursor
SQLLIMIT x OFFSET yWHERE (created_at, id) < (cursor) LIMIT x
Consistente em inserções concorrentesNão — silenciosamente omite registrosSim — a posição é estável
Pular para uma página arbitráriaSim (OFFSET = page * size)Não — apenas navegação para frente
Total de páginasFácil (COUNT(*) / size)Não possível sem escaneamento completo
Desempenho em grandes offsetsDegrada — OFFSET 100000 escaneia 100k linhas para descartá-lasEstável — sempre usa um escaneamento por intervalo de índice
Complexidade no clienteBaixa — apenas um número de páginaMais alta — deve armazenar e transmitir o token do cursor

Como GitHub e Stripe lidam com isso

Ambos o API do GitHub ainda usa

GitHub

em a maioria dos endpoints, mas a API GraphQL usa paginação por cursor com page e per_page Esse cursor ( after e before:

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

) é codificado em base64 — decodifique-o e você obterá uma referência interna de linha, e não um número de página. Em um repositório como next.js, dezenas de issues podem ser criadas entre sua página 1 e página 2. Com paginação por offset, você silenciosamente omitirá algumas delas.Y3Vyc29yOnYyOpHOAABGPQ==Stripe's list APIs use

Stripe

— você passa o ID do último objeto recebido: starting_after e ending_before Os objetos do Stripe têm IDs ordenados no tempo (

# 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

), então o ID em si atua como o cursor. Sua API nunca ofereceu paginação por offset porque os dados de cobrança são exatamente onde os saltos silenciosos se tornam um problema sério — uma cobrança perdida não é apenas um problema de UX.ch_, cus_, pi_Implementando cursos: um padrão mínimo

Aqui está um exemplo mínimo de implementação em Node.js/PostgreSQL usando um cursor composto de timestamp + ID:

Uma coisa para ter cuidado: se você estiver usando UUIDs como chaves primárias em vez de inteiros auto-incrementados, o ordenamento composto só funciona se seus UUIDs estiverem ordenados no tempo. O UUID v4 é aleatório — ordenar por

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 };
}

funciona, mas você perde a garantia de empate que um ID sequencial oferece. Os UUID v7 ou ULIDs são ordenados no tempo e evitam esse problema completamente. (created_at, uuid_v4) A paginação por offset é simples de implementar e funciona bem até que seus dados sejam dinâmicos e com grande volume de escrita. O momento em que os usuários podem disparar inserções entre as solicitações de paginação — posts, cobranças, issues, qualquer coisa — a paginação por offset silenciosamente omite registros. O número de registros omitidos é exatamente igual ao número de novas inserções no topo da sua ordem de classificação. Sem erro, sem aviso.

A versão curta

A paginação por cursor troca "pular para página N" por estabilidade de posição. Para feeds visíveis e quaisquer APIs que outros desenvolvedores construam, isso é a troca correta. A paginação por offset é adequada para ferramentas de administração e exportações estáticas — não adicione complexidade de cursor onde o erro não pode ocorrer.

Paginação por Cursor versus Paginação por Offset — Por que a página 2 Silenciosamente Omite Registros 2

Quer eliminar anúncios? Fique sem anúncios hoje mesmo

Instale nossas extensões

Adicione ferramentas de IO ao seu navegador favorito para acesso instantâneo e pesquisa mais rápida

Ao Extensão do Chrome Ao Extensão de Borda Ao Extensão Firefox Ao Extensão Opera

O placar chegou!

Placar é uma forma divertida de acompanhar seus jogos, todos os dados são armazenados em seu navegador. Mais recursos serão lançados em breve!

ANUNCIADO Remover?
ANUNCIADO Remover?
ANUNCIADO Remover?

Notícias com destaques técnicos

Envolver-se

Ajude-nos a continuar fornecendo ferramentas gratuitas valiosas

Compre-me um café
ANUNCIADO Remover?