Destino Dev

Blog

Paginação por cursor (keyset) com Spring Boot + JPA: evitar OFFSET passo a passo

Por John Pires · 16 de março de 2026

Paginação por cursor (keyset) com Spring Boot + JPA: evitar OFFSET passo a passo

Por que aprender isso em 2026

O problema do OFFSET em sistemas reais

Paginar resultados com OFFSET é simples: você diz ao banco para pular N linhas e retornar M. Para pequenas consultas e páginas iniciais isso funciona. Mas em tabelas grandes, com milhões de linhas, OFFSET obriga o banco a caminhar por todas as linhas puladas. O custo cresce com o deslocamento — é linear ou pior — e, em produção, causa latência e carga alta. Em 2026, com aplicações orientadas a microservices e consultas concorrentes, esse problema é mais visível.

Por que keyset (cursor) é preferível

Paginação por cursor, também chamada de keyset pagination, evita pular linhas. Em vez de calcular "OFFSET x LIMIT y", o servidor usa um marcador (cursor) que representa a última posição vista — por exemplo, o id ou um par (created_at, id). A consulta usa um WHERE com essa posição e um ORDER BY, retornando o próximo lote. Isso mantém a consulta index-friendly e é estável em grandes tabelas.

Analogia simples: imagine ler um livro com milhares de páginas. OFFSET é como pedir para pular 10.000 páginas antes de começar a ler — o bibliotecário ainda precisa folhear todas. Cursor é marcar a página que você parou e pedir "me mostre as próximas 20" — direto e eficiente.

Configuração do ambiente

Requisitos mínimos

Você precisará de:

  • Java 17+ (ou 21, conforme preferência)
  • Spring Boot 3.x
  • Spring Data JPA
  • Banco: PostgreSQL (exemplos usam SQL padrão, mas nativos são adaptáveis)
  • Maven ou Gradle

Exemplo de build (pom.xml mínimo)

Aqui está o bloco essencial do pom.xml. Ele configura Spring Boot e JPA. O arquivo permite rodar a aplicação de exemplo localmente.

<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.1.0</version>
  </parent>
  <dependencies>
    <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency>
    <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency>
    <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
  </dependencies>
</project>

Depois de incluir dependências, configure o application.properties com a URL do banco e habilite o log SQL se quiser ver as consultas geradas.

application.properties mínimo

Configuração útil para desenvolvimento local:

spring.datasource.url=jdbc:postgresql://localhost:5432/paginacao
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

Com isso você tem ambiente pronto para experimentar keyset pagination com Spring Boot + JPA.

Hello World progressivo

Modelo de dados: Entity simples

Vamos começar com uma entidade clara: Post. Ela terá id, title e createdAt. A abordagem keyset costuma usar uma coluna indexada e estável: id (PK) ou createdAt + id para ordenação determinística.

Contexto: o código abaixo declara a entidade, as colunas e cria índices automáticos a partir da PK. É ponto de partida para construir o repositório e a consulta keyset.

package dev.destino.model;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(name = "posts")
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private Instant createdAt;

    // getters e setters

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public Instant getCreatedAt() { return createdAt; }
    public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}

Depois de criar a entidade, crie o schema no banco ou deixe o Hibernate criar via ddl-auto=update (apenas para dev).

Repositório JPA com consulta keyset (nativo)

Contexto: Spring Data JPA não expõe keyset diretamente via Pageable sem OFFSET. A solução simples é um método personalizado com query nativa que usa WHERE e LIMIT. Abaixo, um repositório com método que busca posts com id > :cursor e limita por :size, ordenando por id asc.

package dev.destino.repository;

import dev.destino.model.Post;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import java.util.List;

public interface PostRepository extends CrudRepository<Post, Long> {

    @Query(value = "SELECT * FROM posts WHERE id > :cursor ORDER BY id ASC LIMIT :size", nativeQuery = true)
    List<Post> findNextPage(@Param("cursor") Long cursor, @Param("size") int size);

}

Depois do repositório, você pode chamar findNextPage(0L, 20) para a primeira página (assumindo cursor 0). O método usa a indexação por PK e evita varredura por OFFSET.

Mini-projeto prático

Plano do mini-projeto

Construiremos uma API REST simples que retorna listas paginadas por cursor. Endpoints:

  1. GET /posts?cursor={cursor}&size={size} — retorna lista de posts e próximo cursor
  2. POST /posts — cria posts de teste

Fluxo: front inicia com cursor=0 ou omitido. Servidor responde com items e nextCursor (id do último item). Cliente usa nextCursor para próxima requisição.

Service + Controller: lógica do cursor

Contexto: Service encapsula a lógica de cursor (se cursor nulo => 0). Controller monta a resposta com nextCursor se houver mais itens.

package dev.destino.service;

import dev.destino.model.Post;
import dev.destino.repository.PostRepository;
import org.springframework.stereotype.Service;
import java.util.List;

@Service
public class PostService {

    private final PostRepository repository;

    public PostService(PostRepository repository) { this.repository = repository; }

    public List<Post> nextPage(Long cursor, int size) {
        if (cursor == null) cursor = 0L;
        return repository.findNextPage(cursor, size);
    }
}

Agora o controller que entrega o JSON com dados e próximo cursor:

package dev.destino.web;

import dev.destino.model.Post;
import dev.destino.service.PostService;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/posts")
public class PostController {

    private final PostService service;

    public PostController(PostService service) { this.service = service; }

    @GetMapping
    public Map<String, Object> list(@RequestParam(required = false) Long cursor,
                                       @RequestParam(defaultValue = "20") int size) {
        List<Post> items = service.nextPage(cursor, size);
        Long nextCursor = items.isEmpty() ? null : items.get(items.size()-1).getId();
        Map<String, Object> response = new HashMap<>();
        response.put("items", items);
        response.put("nextCursor", nextCursor);
        return response;
    }
}

O comportamento: primeira chamada sem cursor retorna os primeiros N posts. nextCursor é id do último post. Chamadas subsequentes passam esse id para avançar. Essa estratégia é simples e eficiente para ordem ascendente por id.

Extensão: cursor composto (createdAt + id)

Contexto: quando a ordenação é por createdAt, múltiplos registros podem ter o mesmo timestamp. Para manter ordenação determinística e evitar pular/duplicar, use um cursor composto: (createdAt, id). A consulta precisa filtrar por createdAt < lastCreatedAt OR (createdAt = lastCreatedAt AND id > lastId), dependendo do ORDER.

Exemplo de JPQL/SQL nativo para cursor composto:

-- SQL (exemplo):
SELECT * FROM posts
WHERE (created_at < :cursorCreatedAt)
   OR (created_at = :cursorCreatedAt AND id > :cursorId)
ORDER BY created_at DESC, id ASC
LIMIT :size;

Isso garante que a página seguinte continue exatamente depois do último registro visto.

Erros comuns, armadilhas e dicas práticas

Tentar usar Pageable padrão do Spring Data

Erro: usar PageRequest.of(page, size) normalmente gera SQL com OFFSET. Para grandes deslocamentos, a consulta será lenta. Se você precisa de keyset, não use Pageable para navegar arbitrariamente por páginas numeradas.

Cursor inválido ou manipulação pelo cliente

Problema: aceitar qualquer cursor do cliente pode permitir saltos incorretos ou NPE. Valide o cursor: tipo correto, não-negativo, e trate casos de itens deletados (um cursor pode apontar para registro removido). Uma estratégia é ter nextCursor = último id do lote; se esse id for deletado depois, simplesmente prossiga usando o cursor — o banco retornará a próxima faixa automaticamente.

Tip: prefira cursors estáveis (id monotonicamente crescente) e inclua checks no servidor para casos de inconsistência.

Limites de tamanho e proteção contra scans

Dica prática: imponha um size máximo (ex.: 100). Não permita size arbitrário enviado pelo cliente para prevenir ataques ou consultas pesadas.

Erro de ordenação indeterminística

Se ORDER BY não for determinístico (ex.: apenas createdAt que pode ter empates), o cliente pode ver duplicatas/omissões. Solução: adicionar id como tiebreaker no ORDER BY e no cursor.

Recursos para continuar estudando

Leituras e documentação

Recomendo ler:

  • Documentação do PostgreSQL sobre índices e planos de execução
  • Artigos sobre keyset pagination (vários blogs técnicos explicam trade-offs)
  • Referência do Spring Data JPA — entender limitações do Pageable

Próximos passos práticos

Para aprofundar:

  1. Implemente cursor composto com createdAt + id para ordenações por data.
  2. Explore Querydsl ou Criteria API para montar filtros dinâmicos sem expor SQL nativo.
  3. Considere usar cursors opacos: encode (createdAt,id) em Base64 para segurança/abstração.

Use ferramentas como pgbadger, EXPLAIN ANALYZE e perf para comparar OFFSET vs keyset em sua carga.

Conclusão

Resumo dos pontos principais

Paginação por cursor reduz custo de consultas em tabelas grandes porque evita varredura interna causada por OFFSET. Em Spring Boot + JPA você pode implementar com consultas nativas simples ou com lógica mais abrangente usando Criteria/Querydsl. Para produção, prefira cursors estáveis e cuide da ordenação determinística.

Checklist prático antes de adotar

  • Defina a coluna de ordenação (id ou createdAt + id)
  • Implemente método com WHERE baseando-se no cursor
  • Retorne nextCursor no payload
  • Valide parâmetros e limite size

Conclusão prática: use keyset quando precisar de desempenho consistente em páginas profundas. Para páginas 'volta e meia' e pequenos datasets, OFFSET ainda é aceitável, mas entender cursor paga dividendos quando a escala aumenta.

Este artigo forneceu exemplos concretos de entidade, repositório, serviço e controller em Java. Reutilize os blocos de código e adapte para seu domínio, adicionando encoding do cursor e tratamento das bordas conforme necessário.

Se quiser, posso fornecer um exemplo completo com testes de carga comparando OFFSET vs keyset usando sua base de dados ou um dump de teste.

Assuntos: spring-boot, jpa, pagination, keyset, Java

Leia também