Blog

Paginação por offset em tabela grande: quando a página 500 derruba o banco

A listagem de pedidos do painel sempre respondeu em quarenta milissegundos, e ninguém tinha motivo para olhar para ela. Numa quinta-feira à tarde, o banco principal chegou a noventa e cinco por cento de CPU, a latência de todas as rotas triplicou e o checkout começou a expirar. A causa não era tráfego novo de clientes: era um script de exportação de um parceiro percorrendo a mesma listagem página por página, com cinquenta itens por vez, e estava na página quatro mil. Cada chamada fazia o banco ler e descartar duzentas mil linhas para devolver cinquenta, e o script fazia várias por segundo. A consulta era a mesma de sempre, com o mesmo índice e o mesmo plano. O que mudou foi o número da página. Este artigo explica por que a paginação por OFFSET tem custo proporcional à profundidade e não ao tamanho da página, quais outros problemas ela esconde além da lentidão, como funciona a paginação por chave e qual índice ela exige, como expor isso na API com um cursor opaco que não quebra com precisão de data, como migrar clientes e telas que dependem de número de página, e em quais casos o OFFSET continua sendo uma escolha razoável.

2026-09-25 / Arquitetura / 16 min

01

Por que a página 500 custa quinhentas páginas

OFFSET não é um salto. O banco não tem como saber onde começa a linha de número 24.951 de um resultado ordenado sem percorrer as 24.950 anteriores, porque a posição de uma linha no resultado depende do filtro, da ordenação e do estado da tabela naquele instante. Mesmo com o índice perfeito para a consulta, o executor lê as entradas do índice em ordem, busca cada linha correspondente na tabela, conta e descarta até atingir o deslocamento pedido, e só então começa a devolver o que o cliente queria. O trabalho da página N é proporcional a N vezes o tamanho da página.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, criado_em, status, total
FROM pedidos
WHERE loja_id = 42
ORDER BY criado_em DESC, id DESC
LIMIT 50 OFFSET 24950;

 Limit  (cost=21418.52..21461.44 rows=50 width=32)
        (actual time=1874.221..1874.402 rows=50 loops=1)
   Buffers: shared hit=3121 read=21877
   ->  Index Scan using pedidos_loja_criado_id_idx on pedidos
         (cost=0.56..412936.11 rows=481120 width=32)
         (actual time=0.041..1872.930 rows=25000 loops=1)
         Index Cond: (loja_id = 42)
         Buffers: shared hit=3121 read=21877
 Planning Time: 0.162 ms
 Execution Time: 1874.455 ms

O plano mostra o problema em duas linhas. O nó de índice produziu vinte e cinco mil linhas para que o Limit devolvesse cinquenta, e para isso tocou quase vinte e cinco mil páginas de dados, a maior parte lida do disco porque linhas antigas de uma loja raramente estão em cache. O índice está sendo usado, o plano é o melhor possível para essa forma de consulta, e mesmo assim a execução levou quase dois segundos. Nenhum ajuste de índice resolve isso, porque o custo não vem de um plano ruim: vem de pedir ao banco uma posição que ele só consegue encontrar contando.

Página (50 itens)Linhas lidas com OFFSETTempo com OFFSETTempo com paginação por chave
1500,3 ms0,2 ms
502.5009 ms0,2 ms
50025.0001,9 s (páginas frias)0,2 ms
4.000200.00014 s (páginas frias)0,3 ms

É por isso que o problema demora a aparecer. Pessoas navegando pela interface quase nunca passam da página cinco, e em desenvolvimento a tabela tem poucos milhares de linhas. O custo só se manifesta quando alguém automatiza a navegação: um script de exportação, uma integração que sincroniza o histórico inteiro, um robô de busca seguindo o link de próxima página, ou um usuário que descobriu que pode trocar o número na URL. E como o custo total de percorrer N páginas é quadrático, uma exportação completa que parecia inofensiva lê, no total, dezenas de bilhões de linhas.

02

O que o OFFSET esconde além da lentidão

A lentidão é o sintoma que derruba o banco, mas não é o único defeito da paginação por deslocamento. Existem outros três, e eles afetam a correção dos dados e a saúde do banco mesmo quando ninguém chega à página 500.

ProblemaComo aconteceConsequência
Itens duplicados entre páginasUm pedido novo entra no topo enquanto o cliente está na página 3, e todos os itens descem uma posiçãoO último item da página 3 aparece de novo no início da página 4, e a exportação grava duas vezes
Itens puladosUm pedido da página 1 é excluído ou muda de filtro, e todos os itens sobem uma posiçãoO primeiro item da página 4 nunca é lido, e a sincronização perde um registro sem erro
Contagem total caraA interface mostra "página 3 de 9.622", o que exige COUNT(*) sobre todo o filtro a cada chamadaA contagem custa mais que a própria página e percorre o mesmo volume da página mais profunda
Cache do banco contaminadoVarreduras profundas trazem para a memória páginas antigas que só aquele cliente usaConsultas quentes de outras rotas passam a ler do disco, e a latência sobe para todo mundo

Os dois primeiros itens são os mais traiçoeiros, porque não geram erro nem alerta. Uma sincronização que pagina por deslocamento sobre uma tabela que recebe escrita o tempo todo vai, com certeza estatística, perder e duplicar registros, e o time de dados vai descobrir isso meses depois comparando totais que não batem. O quarto item explica por que o incidente do início afetou o checkout, que não tinha nada a ver com a listagem: a exportação expulsou do cache as páginas que o checkout usava.

03

Paginação por chave: continuar de onde parou

A alternativa é parar de pedir uma posição e passar a pedir uma continuação. Em vez de "pule 24.950 linhas", a consulta diz "me dê as próximas 50 depois deste item", usando os valores da ordenação do último item da página anterior como ponto de partida. Com um índice na mesma ordem, o banco desce direto na árvore até esse ponto e lê só as cinquenta linhas seguintes. O custo da página 4.000 passa a ser igual ao da página 1.

-- Indice na mesma ordem da listagem: filtro de igualdade primeiro,
-- depois a ordenacao, depois o desempate unico.
CREATE INDEX CONCURRENTLY pedidos_loja_criado_id_idx
  ON pedidos (loja_id, criado_em DESC, id DESC);

-- Primeira pagina: sem cursor.
SELECT id, criado_em::text AS criado_em_cursor, status, total
FROM pedidos
WHERE loja_id = $1
ORDER BY criado_em DESC, id DESC
LIMIT 51;  -- um a mais que o tamanho da pagina, para saber se existe proxima

-- Paginas seguintes: continua depois do ultimo item devolvido.
SELECT id, criado_em::text AS criado_em_cursor, status, total
FROM pedidos
WHERE loja_id = $1
  AND (criado_em, id) < ($3::timestamptz, $4::bigint)
ORDER BY criado_em DESC, id DESC
LIMIT $2;

Três detalhes fazem essa consulta funcionar, e errar qualquer um deles produz resultados sutilmente errados. O primeiro é o desempate: criado_em sozinho não é único, e dois pedidos criados no mesmo microssegundo, comuns em importações em lote, fariam o cursor pular ou repetir itens. Acrescentar o id à ordenação e à comparação torna a ordem total e determinística. O segundo é a comparação de linha, (criado_em, id) < (valor, valor), que significa "criado_em menor, ou igual e id menor". Escrever isso à mão com OR costuma impedir o uso do índice, enquanto a forma de linha o PostgreSQL usa diretamente como condição de índice. O terceiro é o índice com as colunas na mesma ordem e direção da cláusula ORDER BY, com a coluna de igualdade do filtro na frente.

 Limit  (cost=0.56..44.21 rows=50 width=32)
        (actual time=0.052..0.198 rows=50 loops=1)
   Buffers: shared hit=54
   ->  Index Scan using pedidos_loja_criado_id_idx on pedidos
         (actual time=0.050..0.187 rows=50 loops=1)
         Index Cond: ((loja_id = 42) AND
           (ROW(criado_em, id) < ROW('2026-03-14 18:22:07.481213-03'::timestamptz, 88123410)))
         Buffers: shared hit=54
 Execution Time: 0.231 ms

A linha Index Cond é a confirmação que importa: a comparação de linha aparece dentro da condição do índice, e não como filtro aplicado depois. Se ela aparecer em uma linha Filter, o banco está lendo o índice desde o início e descartando, e o ganho desaparece. Isso acontece quando a direção de alguma coluna do índice não bate com a ordenação, quando a consulta ordena por uma expressão diferente da indexada, ou quando existe um filtro adicional de igualdade que não está no começo do índice. Cada combinação de filtro e ordenação que a interface oferece precisa de um índice compatível, e esse é o custo real da técnica.

04

O cursor na API: opaco, assinado e sem perder microssegundos

Expor os valores da ordenação diretamente na URL, como ?depois_de_data=...&depois_de_id=..., funciona, mas amarra o contrato da API à implementação. Se a ordenação mudar, se um novo desempate for necessário ou se a listagem passar a aceitar outra ordem, todos os clientes quebram. O padrão mais durável é um cursor opaco: uma string que o servidor produz, o cliente devolve sem interpretar, e que carrega uma versão para permitir mudanças futuras.

// Cursor opaco e assinado para paginacao por chave (Express + node-postgres).
import { createHmac, timingSafeEqual } from 'node:crypto';

const SEGREDO_CURSOR = process.env.SEGREDO_CURSOR;
const LIMITE_PADRAO = 50;
const LIMITE_MAXIMO = 200;

const assinar = (dados) =>
  createHmac('sha256', SEGREDO_CURSOR).update(dados).digest('base64url').slice(0, 22);

export function codificarCursor({ criadoEm, id }) {
  const dados = Buffer.from(JSON.stringify({ v: 1, c: criadoEm, i: id })).toString('base64url');
  return dados + '.' + assinar(dados);
}

export function decodificarCursor(cursor) {
  const [dados, assinatura] = String(cursor).split('.');
  if (!dados || !assinatura) return null;
  const esperada = Buffer.from(assinar(dados));
  const recebida = Buffer.from(assinatura);
  if (esperada.length !== recebida.length || !timingSafeEqual(esperada, recebida)) return null;
  const { v, c, i } = JSON.parse(Buffer.from(dados, 'base64url').toString('utf8'));
  return v === 1 ? { criadoEm: c, id: i } : null;
}

export async function listarPedidos(req, res) {
  const limite = Math.min(Number(req.query.limite) || LIMITE_PADRAO, LIMITE_MAXIMO);
  const cursor = req.query.cursor ? decodificarCursor(req.query.cursor) : null;
  if (req.query.cursor && !cursor) {
    return res.status(400).json({ erro: 'cursor_invalido' });
  }

  const parametros = [req.lojaId, limite + 1];
  let continuacao = '';
  if (cursor) {
    parametros.push(cursor.criadoEm, cursor.id);
    continuacao = 'AND (criado_em, id) < ($3::timestamptz, $4::bigint)';
  }

  const { rows } = await db.query(
    'SELECT id, criado_em::text AS criado_em_cursor, status, total ' +
      'FROM pedidos WHERE loja_id = $1 ' + continuacao +
      ' ORDER BY criado_em DESC, id DESC LIMIT $2',
    parametros,
  );

  const temProxima = rows.length > limite;
  const itens = temProxima ? rows.slice(0, limite) : rows;
  const ultimo = itens[itens.length - 1];

  return res.json({
    itens: itens.map(({ criado_em_cursor, ...pedido }) => ({ ...pedido, criado_em: criado_em_cursor })),
    proximo_cursor: temProxima
      ? codificarCursor({ criadoEm: ultimo.criado_em_cursor, id: ultimo.id })
      : null,
  });
}

O detalhe que mais produz defeito em produção está na coluna criado_em_cursor. O timestamptz do PostgreSQL tem precisão de microssegundos, e o Date do JavaScript só guarda milissegundos. Se o cursor for montado a partir do valor convertido para Date, o ponto de continuação perde os três últimos dígitos, e itens criados dentro do mesmo milissegundo que o último da página são pulados ou repetidos, de forma intermitente e quase impossível de reproduzir. Selecionar a coluna como texto preserva a precisão completa, e o cast de volta para timestamptz na consulta seguinte reconstrói exatamente o mesmo valor. Pela mesma razão o id vai como texto: o node-postgres devolve bigint como string para não perder precisão acima de dois elevado a cinquenta e três.

  • A assinatura impede que o cliente fabrique cursores para pular direto para qualquer ponto da tabela, o que reabriria parte do problema e permitiria sondar dados por valor de ordenação.
  • O campo de versão permite trocar o formato do cursor no futuro aceitando o antigo por um período, sem quebrar clientes no meio de uma paginação.
  • Buscar um item a mais que o limite responde se existe próxima página sem uma segunda consulta e sem COUNT.
  • O limite máximo por página é parte da proteção: sem ele, um cliente pede dez mil itens por página e reproduz o custo por outro caminho.

05

Migrar clientes e telas que dependem de número de página

A consulta nova é a parte fácil. A parte difícil é que a API já tem clientes que mandam ?pagina=4000, e a interface tem um paginador com números e um botão de última página. Nenhum dos dois pode ser trocado de um dia para o outro, e a paginação por chave não oferece o que eles pedem: não existe forma barata de pular para a página 4.000 sem percorrer as anteriores, porque essa é justamente a operação cara.

  1. Instrumente a rota antiga com a profundidade pedida e o cliente que pediu, para descobrir quem realmente navega além das primeiras páginas. Em quase todos os casos são dois ou três clientes automatizados, e não pessoas.
  2. Publique o parâmetro cursor ao lado de pagina, devolvendo proximo_cursor em todas as respostas, inclusive nas que foram pedidas por número de página, para que um cliente possa começar por número e continuar por cursor.
  3. Imponha um teto de profundidade para o parâmetro antigo, por exemplo dez mil itens, e acima dele responda 400 com uma mensagem que aponta o parâmetro cursor e a documentação. Esse teto sozinho elimina o incidente, mesmo antes de qualquer cliente migrar.
  4. Ofereça uma exportação assíncrona para quem precisa do histórico inteiro: o cliente pede, um job percorre a tabela por chave em ritmo controlado, gera o arquivo e avisa quando está pronto. Scripts de exportação por paginação são o maior consumidor de páginas profundas, e esse é o caminho certo para eles.
  5. Na interface, troque o paginador numérico por "carregar mais" ou por anterior e próxima, e substitua o salto para a página N por filtros que as pessoas realmente usam, como intervalo de datas, status e busca.
  6. Remova o parâmetro antigo quando a métrica mostrar que ninguém mais passa do teto, com data comunicada aos clientes que ainda o usam.

O terceiro passo merece ênfase porque é o que resolve o risco imediato com uma mudança de poucas linhas. Mecanismos de busca como o Elasticsearch fazem exatamente isso por padrão, recusando deslocamentos acima de dez mil resultados, e a razão é a mesma. O teto transforma um custo ilimitado em um custo conhecido, e o erro com instrução de como migrar faz os clientes automatizados aparecerem sozinhos, em vez de continuarem invisíveis até o próximo incidente.

Para a navegação para trás, a mesma técnica funciona com a comparação invertida: a consulta usa maior que em vez de menor que, ordena em ordem crescente, e o servidor inverte o resultado antes de devolver. O cursor carrega a direção junto com os valores, e a resposta passa a ter anterior_cursor e proximo_cursor. Não é preciso guardar estado no servidor para isso.

06

Quando o OFFSET ainda serve, e o que fazer com a contagem total

OFFSET não é proibido. Ele é a ferramenta errada quando a profundidade não tem limite e a tabela é grande, e continua sendo razoável quando alguma das duas coisas não é verdade. Uma tela administrativa sobre uma tabela de configuração com três mil linhas, uma listagem em que o filtro sempre reduz o resultado a algumas centenas de itens, ou um relatório interno que ninguém automatiza podem usar número de página sem risco, e a simplicidade de implementar e de pular para uma página específica tem valor real nesses casos.

SituaçãoTécnica adequadaPor quê
Tabela pequena ou filtro que sempre limita o resultadoOFFSETO custo máximo é baixo e conhecido
Feed, histórico ou listagem de API públicaPaginação por chave com cursorProfundidade ilimitada e escrita concorrente
Exportação ou sincronização completaJob assíncrono percorrendo por chavePrecisa ler tudo sem duplicar nem pular
Busca textual com relevânciaLimite de profundidade no motor de buscaNinguém lê o resultado 20.000 de uma busca

A contagem total é o outro custo que costuma sobreviver à migração. "Mostrando 1 a 50 de 481.120" exige COUNT(*) sobre o filtro inteiro, que no PostgreSQL percorre todas as linhas visíveis, e isso acontece em toda chamada. Na maioria das interfaces, o número exato não é usado para nada além de exibição, e pode ser substituído por uma contagem com teto, que para de contar ao passar de um limite, ou por uma estimativa do planejador quando a ordem de grandeza basta.

-- Contagem com teto: para de contar ao passar de 10.000 linhas.
-- A interface mostra "10.000+" quando o resultado atinge o teto.
SELECT count(*) AS total
FROM (
  SELECT 1
  FROM pedidos
  WHERE loja_id = $1
  LIMIT 10001
) AS amostra;

-- Estimativa da tabela inteira, sem percorrer nada, a partir das
-- estatisticas mantidas pelo ANALYZE e pelo autovacuum.
SELECT reltuples::bigint AS estimativa
FROM pg_class
WHERE oid = 'pedidos'::regclass;

A contagem com teto custa no máximo a leitura de dez mil entradas do índice, independentemente do tamanho da loja, e resolve o caso de uso real da interface, que é dizer ao usuário se o resultado é pequeno ou grande. Quando o produto exige o número exato, por exemplo em um relatório financeiro, o lugar certo para ele é uma contagem mantida por gatilho ou por agregação periódica, e não um COUNT a cada abertura da tela.

FAQ

Perguntas frequentes

Um índice melhor não resolve a paginação por OFFSET?

Não, e essa é a confusão mais comum. O índice certo é necessário para qualquer técnica de paginação, porque sem ele o banco ordena o resultado inteiro antes de devolver a primeira página. Mas com OFFSET, mesmo o índice perfeito só permite que o banco percorra as linhas em ordem, e ele ainda precisa ler e descartar todas as linhas anteriores ao deslocamento pedido. O plano de uma página profunda com OFFSET já usa o índice, e o tempo continua crescendo linearmente com o número da página. Um índice de cobertura, que inclui todas as colunas selecionadas e permite uma varredura somente no índice, reduz o custo por linha porque evita a visita à tabela, mas não muda a proporção: a página 4.000 continua lendo duzentas mil entradas. O que muda o custo de proporcional à profundidade para constante é trocar a pergunta, de posição para continuação, e isso só a paginação por chave faz.

E se a ordenação for por uma coluna que muda, como status ou pontuação?

A paginação por chave continua funcionando, mas com uma semântica que precisa ser entendida. O cursor guarda os valores de ordenação do último item visto, e a próxima página começa depois desses valores no estado atual da tabela. Se um item muda de pontuação enquanto o cliente pagina, ele pode aparecer de novo em uma página posterior ou não aparecer mais, porque saiu da região que ainda não foi lida. Isso não é pior que o OFFSET, que tem o mesmo problema e ainda acrescenta pulos e duplicatas causados por inserções. Para interfaces, esse comportamento costuma ser aceitável. Para sincronização, em que nenhum item pode ser perdido, a ordenação deve ser por uma coluna que só cresce, como um identificador sequencial ou uma coluna atualizado_em com desempate por id, e o cliente precisa aceitar receber o mesmo item mais de uma vez e deduplicar pela chave. Quando a interface precisa de um resultado estável por uma ordenação volátil, a solução é materializar o resultado em uma tabela temporária ou em um snapshot com identificador e paginar sobre ele.

Como oferecer "ir para a página N" sem OFFSET?

Na maioria dos casos, a melhor resposta é perguntar para que o usuário quer ir para a página N. Quase sempre é para chegar a um período, a uma letra do alfabeto ou a um status, e um filtro por data, uma busca ou um salto por letra atendem a essa intenção com uma consulta por chave barata, começando direto no ponto desejado. Quando o salto por número é realmente necessário, existem compromissos razoáveis. Um deles é limitar os saltos às primeiras dezenas de páginas, onde o OFFSET é barato, e usar apenas anterior e próxima a partir daí. Outro é manter uma tabela auxiliar com os valores de ordenação a cada mil itens, atualizada periodicamente, o que permite saltar para perto da página pedida por chave e completar com um OFFSET pequeno. O que não deve existir é um botão de última página sobre uma tabela de milhões de linhas, porque ele é, literalmente, a consulta mais cara que a tela consegue produzir, e costuma ser clicado mais do que se imagina.

Paginar por posição é pedir ao banco para contar, e contar não escala

A paginação por OFFSET funciona em desenvolvimento e nas primeiras páginas, e por isso passa despercebida até que um script, uma integração ou um robô de busca navegue fundo o suficiente para transformar uma listagem inocente no maior consumidor do banco. Além da lentidão, ela duplica e pula registros quando a tabela recebe escrita e contamina o cache que as outras rotas usam. A paginação por chave, com desempate único, comparação de linha e índice na mesma ordem, torna o custo de qualquer página igual ao da primeira, e um cursor opaco, assinado e com precisão completa torna essa técnica um contrato de API durável. Um teto de profundidade no parâmetro antigo resolve o risco imediato enquanto os clientes migram. Posso revisar as listagens da sua API e do seu painel, identificar quais consultas crescem com a profundidade e planejar a migração para cursor sem quebrar os clientes que já existem.