Programação Por Equipe DevBras

Capa do artigo: Paginação por cursor: sem item repetido e sem OFFSET

Paginação por cursor: sem item repetido e sem OFFSET

Paginação por cursor testada em 300 mil linhas: por que o OFFSET repete item, quanto custa a página funda e a armadilha da comparação de linha no MySQL 8.4.

Paginação com LIMIT e OFFSET parece certa em todo teste feito com a tabela parada. O problema só aparece quando alguém insere ou apaga uma linha entre uma página e outra, e aí a lista repete ou esconde itens sem erro nenhum na tela. Em vez de explicar isso em abstrato, este guia mostra o que acontece numa tabela de 300 mil pedidos no PostgreSQL 18.6 e no MySQL 8.4.13, e como a paginação por cursor (também chamada de keyset) resolve as duas coisas: a lista que mente e a página funda que fica lenta.

O teste: 300 mil pedidos e uma inserção no meio

A tabela tem três pedidos por minuto, de propósito: datas repetidas são o caso real de qualquer sistema com movimento, e é nelas que paginação mal feita quebra.

CREATE TABLE pedidos (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  cliente    text NOT NULL,
  criado_em  timestamptz NOT NULL
);
INSERT INTO pedidos (cliente, criado_em)
SELECT 'cliente ' || g, timestamptz '2026-01-01' + (g / 3) * interval '1 minute'
FROM generate_series(1, 300000) g;  -- 3 pedidos por minuto: datas repetidas de propósito
CREATE INDEX pedidos_criado_id ON pedidos (criado_em DESC, id DESC);

-- página 1
SELECT id FROM pedidos ORDER BY criado_em DESC, id DESC LIMIT 5;
-- alguém cria um pedido enquanto a pessoa lê
INSERT INTO pedidos (cliente, criado_em) VALUES ('novo', now());
-- página 2 com OFFSET
SELECT id FROM pedidos ORDER BY criado_em DESC, id DESC LIMIT 5 OFFSET 5;
página 1:             300000, 299999, 299998, 299997, 299996
página 2 com OFFSET:  299996, 299995, 299994, 299993, 299992

O pedido 299996, último da página 1, abriu a página 2. OFFSET 5 quer dizer "descarte as 5 primeiras linhas da ordem atual", e a ordem atual ganhou uma linha no topo. Se em vez de inserir alguém tivesse cancelado um pedido da página 1, o efeito seria o contrário: um item subiria para a página já vista e a pessoa nunca o encontraria. A documentação do PostgreSQL sobre LIMIT e OFFSET lembra ainda outro ponto: sem um ORDER BY que dê uma ordem única, as páginas podem sair inconsistentes até com a tabela parada. Por isso o id entra como desempate em toda ordenação abaixo.

A mesma página 2 com cursor

Com cursor, a página seguinte não depende de posição, e sim do último item mostrado. A consulta pede "os pedidos que vêm depois deste na ordem escolhida":

-- página 2 por cursor: "os 5 que vêm depois do último que a pessoa viu"
SELECT id FROM pedidos
WHERE (criado_em, id) < ('2026-03-11 10:38:00+00', 299996)
ORDER BY criado_em DESC, id DESC
LIMIT 5;
página 2 por cursor:  299995, 299994, 299993, 299992, 299991

O pedido novo no topo não altera nada, porque a referência é o par (data, id) do 299996. A comparação (criado_em, id) < (...) é uma comparação de linha: compara a data e, se ela empatar, o id. Sem o id no cursor, os outros pedidos do mesmo minuto seriam pulados.

Quanto custa uma página funda

O segundo ganho é de desempenho. Para entregar 20 linhas depois de pular 250 mil, o banco precisa percorrer as 250 mil:

EXPLAIN (ANALYZE, COSTS OFF)
SELECT id FROM pedidos ORDER BY criado_em DESC, id DESC LIMIT 20 OFFSET 250000;

 Limit (actual time=39.440..39.444 rows=20.00 loops=1)
   ->  Index Only Scan using pedidos_criado_id on pedidos
         (actual time=0.008..30.336 rows=250020.00 loops=1)
 Execution Time: 39.460 ms

Com cursor apontando para o mesmo lugar da lista, o índice vai direto ao ponto:

EXPLAIN (ANALYZE, COSTS OFF)
SELECT id FROM pedidos
WHERE (criado_em, id) < ('2026-01-12 13:47:00+00', 50002)
ORDER BY criado_em DESC, id DESC LIMIT 20;

 Limit (actual time=0.044..0.047 rows=20.00 loops=1)
   ->  Index Only Scan using pedidos_criado_id on pedidos
         (actual time=0.044..0.045 rows=20.00 loops=1)
         Index Cond: (ROW(criado_em, id) < ROW('2026-01-12 13:47:00+00'::timestamp with time zone, 50002))
 Execution Time: 0.063 ms

São 250.020 linhas lidas contra 20, e 39 ms contra 0,06 ms. Numa página só, 39 ms parecem pouco. Num robô que exporta a tabela de 100 em 100, ou numa API pública em que alguém pagina até o fim, o custo do

OFFSET cresce a cada página, e o do cursor fica constante.

No MySQL, a comparação de linha engana

O MySQL aceita a mesma sintaxe (criado_em, id) < (?, ?) e devolve o resultado certo. Só que no teste com MySQL 8.4.13, mesma tabela e mesmo índice composto, o plano mostrou outra história:

-- forma de linha: o MySQL 8.4.13 filtra DEPOIS de varrer o índice
-> Filter: ((pedidos.criado_em,pedidos.id) < ('2026-01-12 13:47:00',50002))
    -> Covering index scan on pedidos using pedidos_criado_id (reverse)
       (actual time=0.0135..32.7 rows=250019 loops=1)

-- forma expandida: vira busca por faixa no índice
-> Covering index range scan on pedidos using pedidos_criado_id over
   (criado_em < '2026-01-12 13:47:00.000000') OR
   (criado_em = '2026-01-12 13:47:00.000000' AND id < 50002) (reverse)
   (actual time=0.105..0.17 rows=20 loops=1)

Com a comparação de linha, ele varreu 250 mil entradas do índice e filtrou depois, o mesmo custo do OFFSET. Com a forma expandida, fez busca por faixa e leu 20. Então, no MySQL, escreva assim e confira o seu caso com EXPLAIN ANALYZE:

SELECT id, cliente, criado_em
FROM pedidos
WHERE criado_em < ?
   OR (criado_em = ? AND id < ?)
ORDER BY criado_em DESC, id DESC
LIMIT 21;

Os três parâmetros são a data do último item, a mesma data de novo e o id dele.

Voltar uma página

Cursor anda para a frente com naturalidade. Para voltar, inverta a comparação e a ordem, pegue os itens anteriores ao primeiro da página atual e desinverta o resultado antes de mostrar:

-- página anterior: inverta a comparação e a ordem, depois desinverta
SELECT * FROM (
  SELECT id, cliente, criado_em FROM pedidos
  WHERE (criado_em, id) > ($1, $2)       -- primeiro item da página atual
  ORDER BY criado_em ASC, id ASC
  LIMIT 21
) anterior
ORDER BY criado_em DESC, id DESC;

O que o cursor não faz é pular para "página 37". Se a tela precisa de números de página, ou a lista é pequena e quase parada (um relatório fechado, uma tela administrativa com algumas centenas de linhas), OFFSET com desempate pelo id continua sendo a escolha simples. Para filtrar e limitar a consulta em si, o guia de SELECT com LIMIT, filtro e índice cobre o básico.

O cursor que vai para o cliente

Na API, o cursor vira um texto opaco que o cliente devolve na próxima chamada. Este módulo usa o driver pg 8.23.1 e foi testado contra a tabela acima:

const { Pool } = require('pg');
const pool = new Pool(); // PGHOST, PGUSER, PGPASSWORD e PGDATABASE vêm do ambiente

const ORDENS = { recentes: 'recentes' }; // cada ordenação aceita tem nome próprio

function criarCursor(item, ordem) {
  const obj = { o: ordem, c: item.cursor_criado, i: item.id };
  return Buffer.from(JSON.stringify(obj)).toString('base64url');
}

function lerCursor(texto, ordem) {
  try {
    const obj = JSON.parse(Buffer.from(texto, 'base64url').toString('utf8'));
    if (obj.o !== ordem || typeof obj.c !== 'string' || !/^\d+$/.test(obj.i)) return null;
    if (Number.isNaN(Date.parse(obj.c))) return null;
    return obj;
  } catch {
    return null;
  }
}

async function listarPedidos(textoCursor, tamanho = 20) {
  const params = [];
  let sql = 'SELECT id, cliente, criado_em, criado_em::text AS cursor_criado FROM pedidos';
  if (textoCursor) {
    const cur = lerCursor(textoCursor, ORDENS.recentes);
    if (!cur) { const e = new Error('cursor inválido'); e.status = 400; throw e; }
    params.push(cur.c, cur.i);
    sql += ' WHERE (criado_em, id) < ($1::timestamptz, $2::bigint)';
  }
  params.push(tamanho + 1);
  sql += ' ORDER BY criado_em DESC, id DESC LIMIT $' + params.length;
  const { rows } = await pool.query(sql, params);
  const temMais = rows.length > tamanho;
  const itens = temMais ? rows.slice(0, tamanho) : rows;
  return { itens, proximo: temMais ? criarCursor(itens.at(-1), ORDENS.recentes) : null };
}

module.exports = { listarPedidos, pool };
const { listarPedidos, pool } = require('./cursor');
(async () => {
  const p1 = await listarPedidos(null, 3);
  console.log('página 1:', p1.itens.map((r) => r.id).join(','), 'cursor:', p1.proximo);
  const p2 = await listarPedidos(p1.proximo, 3);
  console.log('página 2:', p2.itens.map((r) => r.id).join(','));
  try { await listarPedidos('bGl4bw', 3); } catch (e) { console.log('cursor adulterado ->', e.status, e.message); }
  await pool.end();
})();

Para o teste, metade dos pedidos ganhou 123456 microssegundos na data. A saída:

página 1: 300001,300000,299998 cursor: eyJvIjoicmVjZW50ZXMiLCJjIjoiMjAyNi0wMy0xMSAxMDozOTowMC4xMjM0NTYrMDAiLCJpIjoiMjk5OTk4In0
página 2: 299999,299997,299996
cursor adulterado -> 400 cursor inválido

Três detalhes do código fazem diferença:

  • A data vai como texto do próprio banco (criado_em::text). O timestamptz guarda microssegundos e o Date do JavaScript guarda milissegundos. O cursor acima carrega 10:39:00.123456; montado a partir do Date, viraria .123 e a próxima página poderia repetir ou pular pedidos do mesmo instante.
  • O id fica como string. O pg devolve bigint como texto porque ele pode passar do maior inteiro seguro do JavaScript.
  • O cursor registra a ordenação. Se a tela permite ordenar por valor ou por cliente, cada opção tem as suas colunas (sempre com o id no fim) e o seu índice, e um cursor de uma ordenação não pode ser aceito em outra. Cursor que não confere responde 400, em vez de voltar em silêncio para a primeira página.

Base64url não é criptografia: qualquer pessoa decodifica o cursor. Não coloque nele nada que o usuário não possa ver, e trate o conteúdo como entrada externa, que é o que ele é. Evite também ordenar por coluna que aceita NULL, porque NULL não é maior nem menor que nada e a comparação passa a precisar de casos especiais.

Na tela: rolagem infinita sem pular item

O cursor combina com rolagem infinita: o front guarda o proximo da última resposta e pede a página seguinte quando um elemento sentinela no fim da lista entra na tela. A parte de detectar esse momento, com rootMargin para carregar antes de a pessoa chegar ao fim, está no guia de IntersectionObserver. E para que a lista infinita não esconda o rodapé, veja rolagem infinita sem prender o rodapé.

Pedir 21 itens para mostrar 20, como nas consultas acima, também evita o COUNT(*) a cada página: se veio o 21º, existe próxima. Contar a tabela inteira só para habilitar um botão costuma ser a consulta mais cara da tela.

Avalie este artigo

Referência: https://www.postgresql.org/docs/current/queries-limit.html