23 de set. de 2025·5 min de leitura

Ordenação estável na paginação: pare os itens de se embaralharem

Aprenda como manter resultados de listas consistentes na paginação: adicione quebras de empates, escolha chaves seguras de ordenação e faça checagens rápidas para evitar o embaralhamento.

Ordenação estável na paginação: pare os itens de se embaralharem

Como o “embaralhamento” de páginas de lista se manifesta em apps reais

Você abre uma lista, vai para a página 2 e vê um item que jura que estava na página 1 um segundo atrás. Atualiza a página e a ordem muda de novo. Às vezes um item aparece em ambas as páginas. Às vezes some até você ajustar um filtro.

Isso é o “embaralhamento de páginas de lista”. O app está paginando, mas a ordem de classificação não está totalmente fixada, então o banco de dados (ou a API) retorna registros em uma ordem ligeiramente diferente entre requisições.

Parece um problema pequeno, mas derruba a confiança rapidamente. Usuários acham que falta dado, notificações não são confiáveis ou alguém alterou os registros. Em telas administrativas e relatórios, pode causar erros reais: revisar a mesma linha duas vezes e pular outra.

Você percebe mais em tabelas administrativas, feeds de atividade, resultados de busca, logs de auditoria e catálogos de produtos.

“Estável” não significa que a lista nunca muda. Significa ordenação estável com paginação: se as entradas forem as mesmas (mesmos filtros, mesma opção de ordenação, mesmo snapshot de dados), você obtém a mesma ordem toda vez.

Uma lista estável tem três características:

  • Empates são quebrados da mesma forma sempre (sem ordem “aleatória” para registros que têm o mesmo valor de ordenação).
  • As fronteiras de página são previsíveis (um item está ou na página 1 ou na página 2, não em ambas).
  • Atualizar não reembaralha itens, a menos que os dados subjacentes tenham mudado.

Um gatilho comum é ordenar por um campo que costuma ter duplicatas, como created_at, status ou score. Se dez itens compartilham a mesma timestamp ou score, o banco de dados pode devolver esses dez em qualquer ordem a menos que você diga como quebrar o empate.

Por que a paginação quebra quando a ordenação não é determinística

A paginação depende de uma suposição simples: executar a mesma query duas vezes retorna a mesma ordem.

Quando a ordem não é garantida, sua “página 2” deixa de ser realmente a página 2. Usuários veem repetições, itens faltando ou linhas que parecem saltar.

A causa usual são empates. Sua query ordena por algo como created_at, score ou name, e várias linhas compartilham o mesmo valor. Quando isso acontece, o banco pode retornar as linhas empatadas em qualquer ordem a menos que você adicione uma regra clara para resolver o empate. Esse “qualquer ordem” pode mudar entre requisições.

A paginação por offset torna isso ainda mais visível porque offsets contam posições, não linhas específicas. Se as posições mudam, os offsets apontam para um trecho diferente da lista.

Mesmo que seus dados não mudem, empates ainda podem inverter a ordem por razões fora do seu controle: um índice diferente pode ser usado, o plano de consulta muda após atualização de estatísticas, ou execução paralela retorna lotes em sequência diferente. Isso é comportamento normal quando você não pede uma ordem determinística.

Escolha uma regra de ordenação estável (chave primária + quebra de empate)

Para parar o embaralhamento, você precisa de uma regra de ordenação que nunca deixe empates sem resolução.

Escolha a ordenação que os usuários esperam

Comece pelo campo que bate com a expectativa das pessoas.

  • Feed de atividade: mais novo primeiro.
  • Ranking: maior pontuação primeiro.
  • Diretório: nome A a Z.

Depois, presuma que empates vão acontecer. Muitos eventos compartilham a mesma timestamp, muitos usuários têm o mesmo sobrenome e muitos itens compartilham a mesma pontuação arredondada.

Adicione uma quebra de empate que nunca mude

Acrescente uma segunda chave de ordenação que seja única e estável. A maioria das aplicações já tem uma: a chave primária como id. A quebra de empate não deve mudar ao longo do tempo e deve existir em todas as linhas.

Seja explícito sobre a direção de cada chave. Se sua ordenação principal for DESC (mais novo primeiro), usar DESC para a quebra de empate geralmente mantém o comportamento intuitivo.

Uma regra simples que você pode reaplicar em vários endpoints:

  • Ordene pelo campo visível ao usuário primeiro.
  • Ordene por id em segundo para quebrar empates.
  • Defina direção para ambos os campos.
  • Use exatamente a mesma regra em qualquer lugar que sirva essa lista.

Escreva isso em uma frase, por exemplo: “Mostrar itens mais novos primeiro; se os tempos forem iguais, mostrar id maior primeiro.” Isso evita deriva, onde um endpoint usa uma ordem e outro usa outra bem parecida.

Passo a passo: adicione quebras de empates às suas queries

A correção mais rápida é quase sempre a mesma: mantenha sua ordenação principal e acrescente uma quebra de empate única.

Comece pela query exata que sua API executa. Encontre o ORDER BY atual e pergunte: duas linhas podem ter os mesmos valores nesses campos de ordenação? Se sim, você tem um empate, e o banco pode retornar as linhas empatadas em ordens diferentes.

Um padrão comum para “mais novo primeiro” fica assim:

SELECT id, created_at, title
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;

Se você ordena por um campo não único como created_at, price ou score, inclua sempre id (ou outra coluna única) como a ordenação final.

Um detalhe prático a checar: o índice. Para o exemplo acima, um índice composto como (status, created_at, id) (a direção depende do seu banco) frequentemente evita sorts lentos e mantém a performance previsível.

Offset vs cursor pagination: o que muda para ordenação estável

Corrija tabelas administrativas que se embaralham rápido
Reparamos consultas de lista geradas por IA para que a página 1 permaneça página 1.

A paginação por offset é o clássico “page=3”: ordenar, pular as primeiras N linhas e pegar o próximo bloco. É fácil de implementar, mas pressupõe que a ordem permaneça estável.

A paginação por cursor é o “after=item_123”: em vez de pular linhas, você busca itens depois do último item que já tem. Ela costuma ter melhor desempenho e evita alguns casos problemáticos, mas só funciona bem se sua ordem for estável e única.

A paginação por cursor torna o requisito explícito: o cursor precisa descrever uma posição precisa em uma ordem determinística. Isso normalmente significa usar uma ordenação composta e um cursor composto.

Por exemplo: ordene por created_at DESC, id DESC, e faça com que seu cursor transporte ambos os valores. Se você armazenar só created_at no cursor, a fronteira é ambígua sempre que várias linhas compartilham a mesma timestamp.

Regras práticas:

  • Inclua sempre uma quebra de empate única no ORDER BY.
  • Faça o cursor corresponder ao ORDER BY completo (mesmos campos, mesmas direções).
  • Se os usuários mudarem filtros ou opções de ordenação, trate como uma query nova e comece com um cursor novo.

Dados novos e atualizações: manter resultados consistentes ao longo do tempo

Mesmo com um ORDER BY perfeito, páginas ainda podem parecer instáveis se os dados subjacentes mudarem entre requisições. A ordenação é determinística, mas o conjunto de dados está em movimento.

Itens novos são o problema clássico com paginação por offset. Se você busca a página 1 (itens 1–20) e depois chega um item novo no topo, sua próxima requisição para a página 2 (offset 20) começa no que costumava ser o item 21, mas tudo se deslocou. Usuários veem duplicatas ou perdem itens.

Edições podem ser piores. Se seu campo de ordenação muda (por exemplo, updated_at), uma linha existente pode pular entre páginas.

A correção começa por uma decisão de produto: essa lista deve ser “ao vivo” ou consistente durante uma sessão?

Se você quer consistência, prenda os resultados a um ponto de snapshot. Abordagens comuns:

  • Ancorar a um timestamp fixo (mostrar apenas itens com created_at <= o horário do primeiro carregamento).
  • Ancorar a uma fronteira de cursor (mostrar apenas itens abaixo do cursor superior da primeira página).
  • Evitar ordenar feeds por updated_at a menos que seja exatamente isso que os usuários esperam.
  • Mostrar uma faixa de “Itens novos” e deixar o usuário atualizar intencionalmente.

Exemplo: um feed de atividade ordenado por updated_at DESC. Um usuário abre a página 1, alguém edita um registro antigo, atualizando updated_at e o movendo para o topo. Quando o usuário carrega a página 2, vê uma entrada que já leu e outra está faltando. Ancorar ao horário do primeiro carregamento, ou mudar o feed para created_at, remove a instabilidade.

Erros comuns que fazem itens pular entre páginas

A maioria dos bugs de “itens se movendo” são problemas de ordenação.

Culpados comuns:

  • Ordenar apenas por um campo não único como created_at, status ou name sem uma quebra de empate.
  • Usar ordenação aleatória (ou uma pontuação que muda) e tratar como lista estável.
  • Paginar no SQL e depois ordenar no código da aplicação.
  • Endpoints diferentes usando ordenações padrão diferentes para a mesma lista.
  • Esquecer de definir onde valores NULL devem ficar.

Versões sutis do mesmo problema aparecem quando rótulos da UI não batem com a ordenação real (por exemplo, mostrar “Última atualização” mas ordenar por “Created”). Usuários percebem isso como embaralhamento porque a lista não se comporta como a tela afirma.

Checagens rápidas antes de enviar para produção

Limpe consultas de IA copiadas
Substituímos trechos frágeis por helpers de lista testados e compartilhados.

Esses bugs costumam escapar porque a query parece correta num banco de desenvolvimento pequeno e depois falha com volume real de dados e edições reais.

Uma lista de verificação curta:

  • Termine sua ordenação com um campo único (frequentemente id) para que duas linhas nunca empatem.
  • Especifique ASC/DESC para cada campo ordenado.
  • Aplique a ordenação na query do banco antes de LIMIT/OFFSET (ou antes do filtro do cursor), não depois de buscar os dados.
  • Se usar paginação por cursor, inclua todos os campos de ordenação no cursor.
  • Confirme que há um índice que corresponde aos seus filtros usuais mais a ordenação.

Um teste prático: carregue a página 1 e a página 2, então insira uma nova linha que empate no campo de ordenação principal (mesma timestamp de segundo, mesma pontuação etc.). Recarregue. Se itens trocarem de lugar ou aparecerem em ambas as páginas, você ainda tem um empate não resolvido.

Exemplo: corrigindo um feed de atividade que embaralha

Um time lança um feed de atividade construído com uma ferramenta de IA. Parece ok em testes, mas usuários reclamam: “Vi um item na página 2, atualizei e ele foi para a página 1.” O time culpa cache, mas o real problema é a ordenação.

O feed ordena apenas por created_at DESC. Em produção, muitas linhas compartilham a mesma timestamp (inserções em lote, jobs de background ou precisão baixa de timestamp). Quando vários itens têm o mesmo created_at, o banco pode retorná-los em qualquer ordem, então as fronteiras de página ficam instáveis.

Antes:

SELECT *
FROM activities
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 20 OFFSET 20;

Depois (determinístico):

SELECT *
FROM activities
WHERE user_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20;

Se você usa paginação por cursor, atualize o cursor para transportar ambos os campos. Em vez de “último created_at visto”, use um cursor composto como (created_at, id) e busque itens “menores que” esse par mantendo a mesma ordenação.

Como testar e monitorar estabilidade em produção

Fortaleça endpoints de lista para produção
Refatoramos consultas, índices e questões de segurança em código gerado por IA.

A definição mais simples de sucesso: a mesma requisição, feita duas vezes seguidas, retorna os mesmos IDs de itens na mesma ordem (a menos que você permita explicitamente a entrada de itens novos).

Bons testes:

  • Buscar a página 1 duas vezes com os mesmos parâmetros e comparar os IDs retornados na ordem.
  • Buscar a página 2 e depois a página 1 de novo, verificando que a página 1 não mudou.
  • Assegurar que cada ID de item apareça no máximo uma vez entre página 1 e página 2.
  • Verificar a ordenação checando que (campo_de_ordenacao, quebra_de_empate) é estritamente monotônico.

Quando usuários reportam embaralhamento, registre o suficiente para reproduzir: filtros, limit/offset ou valores do cursor e os campos de ordenação completos (incluindo a quebra de empate).

Depois de mudar ordenação ou índices, monitore latência das queries (especialmente p95) e logs de queries lentas. Se a performance cair, normalmente é problema de índice, não motivo para abrir mão da ordenação determinística.

Próximos passos: tornar a ordenação consistente por todo o app

Depois de consertar uma tela, o próximo bug costuma aparecer em outro lugar porque a mesma lógica de lista existe em múltiplos pontos.

Faça um inventário rápido de onde você retorna listas: telas para usuários, tabelas administrativas, buscas, exports e jobs em background que percorrem dados. Então aplique uma política compartilhada: toda lista tem um ORDER BY determinístico que termina com uma quebra de empate única, e todo endpoint o utiliza.

Se você herdou uma base de código gerada por IA onde queries de lista foram copiadas e ajustadas por tela, uma auditoria focada nas ordenações pode render ganhos rápidos. Times às vezes trazem esse tipo de problema para FixMyMess (fixmymess.ai) quando feeds e tabelas administrativas continuam embaralhando em produção, porque muitas vezes se resume a quebras de empates faltantes e regras de ordenação inconsistentes entre endpoints.