Destino Dev

Blog

Processando grandes consultas JPA sem OOM: Stream, fetchSize e batches

Por John Pires · 16 de março de 2026

Processando grandes consultas JPA sem OOM: Stream, fetchSize e batches

O que é e por que importa

Quando sua aplicação Java consulta milhões de linhas via JPA e tenta carregar tudo em memória, o resultado mais comum é um OutOfMemoryError (OOM). Isso acontece porque o JPA/Hibernate mantém entidades no persistence context e a JVM acaba sem memória para gerenciar objetos. Em microsserviços, a consequência é ainda pior: instâncias ficam indisponíveis, latência dispara e restarts são frequentes.

Este artigo mostra, do zero e com analogias simples, como processar grandes consultas sem OOM usando três alavancas principais: streaming (obter resultados gradualmente), fetchSize (controlar quantas linhas o driver traz por vez) e batch processing com limpeza do persistence context. A abordagem é prática e adequada para quem está começando com Spring Boot e JPA.

Você verá configurações, exemplos de código comentado e um exemplo prático completo que roda em um serviço Spring Boot. Cada trecho de código virá com contexto antes e explicação depois.

Conceitos fundamentais com analogias

Streaming vs carregar tudo na memória

Analogia: imagine que você precisa ler um arquivo gigante. Carregar tudo em memória é como abrir o arquivo inteiro e copiar cada página para uma mesa pequena — a mesa enche e cai. Fazer streaming é ler página por página e processar cada uma, jogando fora o que já processou. Assim você mantém apenas uma quantidade limitada de informação em memória.

No JPA, métodos que retornam List<Entity> fazem esse "carregar tudo" por padrão, enquanto usar java.util.Stream com cuidado permite processar resultados linha a linha sem materializar toda a lista.

fetchSize: quantas linhas o driver traz por vez

fetchSize é uma dica para o driver JDBC sobre quantas linhas devem ser transferidas do banco em cada lote da rede. Não é exatamente o mesmo que batch de escrita, mas reduz a pressão de memória e I/O. Se você tiver fetchSize=100, o driver fará múltiplos fetches de 100 linhas até consumir todo o resultset.

Importante: a eficácia do fetchSize depende do driver JDBC e do modo como o provider (Hibernate) envia a consulta. Alguns drivers ignoram o fetchSize ou exigem configurações adicionais (ex.: ResultSet.TYPE_FORWARD_ONLY).

Passo a passo com codigo comentado

1) Configurar fetchSize no Hibernate / JDBC

Contexto: antes de qualquer código que consome muitas linhas, defina um fetchSize sensato. No Spring Boot, você pode configurar via properties ou programaticamente no EntityManager/Statement.

Exemplo de configuração simples em application.properties (contexto: define fetch_size padrão para o Hibernate):

spring.jpa.properties.hibernate.jdbc.fetch_size=200
spring.jpa.properties.hibernate.default_batch_fetch_size=50

O que isso faz: instrui o Hibernate e o driver a tentarem trazer 200 linhas por fetch e a usar batch fetching para associações. Nem todos os drivers obedecem exatamente, mas é um começo útil.

2) Usar Stream com Spring Data JPA e transação

Contexto: Spring Data JPA permite devolver java.util.Stream<Entity>. É necessário garantir que a transação e o EntityManager permaneçam abertos enquanto o stream for consumido. Um padrão comum é abrir uma transação no service e fechar o stream dentro do try-with-resources.

Exemplo de Repository com método stream:

public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query("select o from Order o where o.status = :status")
    Stream<Order> streamAllByStatus(@Param("status") String status);
}

O que acontece: esse método retorna um Stream conectado ao ResultSet. Enquanto você consumir o Stream dentro da mesma transação, o JDBC/driver manterá o cursor e trará resultados conforme o fetchSize.

3) Processar em batches e limpar Persistence Context

Contexto: mesmo com streaming, o EntityManager pode acumular entidades no persistence context se você não limpá-lo periodicamente. Para evitar crescimento de memória, processe em lotes (batches) e chame EntityManager.clear() ou detach.

Exemplo de service que processa em batches com limpeza do persistence context:

@Service
public class OrderProcessingService {

    @Autowired
    private OrderRepository orderRepository;

    @PersistenceContext
    private EntityManager em;

    @Transactional(readOnly = true)
    public void processByStatus(String status) {
        int batchSize = 100;
        int count = 0;

        try (Stream<Order> stream = orderRepository.streamAllByStatus(status)) {
            Iterator<Order> it = stream.iterator();
            while (it.hasNext()) {
                Order order = it.next();
                // processa a entidade
                doWork(order);

                if (++count % batchSize == 0) {
                    em.flush(); // garante que alterações saiam para o DB
                    em.clear(); // limpa o persistence context para liberar memória
                }
            }
        }
    }

    private void doWork(Order order) {
        // lógica de negocio: calculos, chamadas HTTP, grava em outro sistema, etc.
    }
}

O que acontece: o stream lê as linhas aos poucos; a cada batchSize processados, chamamos flush/clear para liberar memória. Se fizer atualizações, é importante chamar flush antes do clear.

Erros comuns e armadilhas

Contexto: iniciantes frequentemente cometem erros que anulam o streaming ou o fetchSize. Listo os mais comuns e como evitá-los.

  • Consumir o Stream fora da transação: o Stream fica ligado ao ResultSet, que é fechado ao final da transação. Certifique-se de usar try-with-resources dentro de um método @Transactional.
  • Usar List para carregar tudo: retornar List<Entity> para grandes consultas causa OOM. Prefira Stream, páginação ou consultas nativas com cursor.
  • Esquecer de limpar o persistence context: sem em.clear(), o persistence context cresce e causa OOM.
  • Ignorar driver JDBC: nem todo driver respeita fetchSize. Consulte a documentação do driver (PostgreSQL, MySQL, Oracle têm comportamentos diferentes).
  • Fazer operações que forçam materialização: chamar stream.collect(Collectors.toList()) destrói o propósito do stream.
Tip: se usar Hibernate 5+, habilite o modo de scroll do ResultSet com fetchSize e ResultSet.TYPE_FORWARD_ONLY para reduzir sobrecarga de memória em alguns drivers.

Exemplo pratico completo

Contexto: abaixo temos um exemplo mínimo de um serviço Spring Boot com uma entidade Order, repository que devolve Stream, service que processa em batches e properties para fetchSize. É um exemplo que você pode copiar, ajustar o datasource e rodar localmente.

1) application.properties (contexto: configurações JDBC e Hibernate)

spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=myuser
spring.datasource.password=mypassword
spring.jpa.properties.hibernate.jdbc.fetch_size=200
spring.jpa.properties.hibernate.default_batch_fetch_size=50
spring.jpa.open-in-view=false

O que acontece: define fetch_size para o Hibernate e desliga open-in-view para evitar que o EntityManager fique aberto além do necessário, forçando uso consciente de transações.

2) Entidade Order (contexto: entidade simples com alguns campos relevantes)

@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String status;

    private BigDecimal total;

    // getters e setters
}

O que acontece: entidade mapeada para a tabela orders. Em exemplos reais, campos extras e relacionamentos podem existir — esses exigem atenção ao batch fetching.

3) Repository e Service já apresentados — integração completa

@RestController
public class BatchController {

    @Autowired
    private OrderProcessingService processingService;

    @PostMapping("/process/{status}")
    public ResponseEntity<String> process(@PathVariable String status) {
        processingService.processByStatus(status);
        return ResponseEntity.ok("Started");
    }
}

O que acontece: endpoint HTTP que dispara o processamento. Note que o processamento ocorre dentro da chamada do método service; se o conjunto for muito grande e você quiser assíncrono, use um executor ou filas, mas mantenha a transação e o stream no mesmo thread onde ela foi iniciada.

Dica prática sobre concorrência e virtual threads

Contexto: se você vai paralelizar o processamento, a abordagem mais segura é dividir a query em ranges ou usar paginação por chave estável e processar cada página em paralelo. Virtual threads (Project Loom) podem ajudar para muitas tarefas I/O-bound, mas não resolvem o problema do persistence context — cada thread precisa de sua própria transação.

Exemplo simplificado (contexto: não é completo, mostra apenas ideia de divisão por id):

IntStream.range(0, ranges)
  .parallel()
  .forEach(chunk -> service.processRange(startFor(chunk), endFor(chunk)));

O que acontece: isso paraleliza por ranges de ids. Cada chamada processRange precisa abrir sua própria transação e usar streaming/local batch para evitar contention e OOM.

Proximos passos para aprofundar

Depois de domar OOM nas consultas, avance para tópicos que aumentam robustez e observabilidade:

  1. Testes de carga que simulam o volume real de dados e medem uso de memória.
  2. Monitoramento de GC e heap com ferramentas como VisualVM, JFR e Prometheus.
  3. Considere consultas nativas com cursores se o driver/provider tiver limitações.
  4. Use pagination por chave (keyset pagination) para concorrência e retry seguros.
  5. Explore soluções reativas (R2DBC) se quiser backpressure nativo e modelos não blocantes.

Também revise a configuração do banco: índices, tamanho de row, e tuning do servidor DB impactam diretamente o tempo e uso de memória do lado do cliente.

Conclusão pratica

Resumo direto: para processar grandes consultas sem OOM, combine três práticas:

  • Use Stream (ou cursor em consultas nativas) para não materializar tudo.
  • Configure fetchSize para reduzir pressão de I/O e memória.
  • Processe em batches, chamando em.flush() e em.clear() para liberar o persistence context.

Implementando essas técnicas você evita OOM, melhora estabilidade e reduz latência em serviços que precisam processar muitos dados. Teste com volumes reais e monitore memória e GC; pequenas mudanças no batchSize ou fetchSize podem ter impacto grande dependendo do driver e do workload.

Se estiver começando, copie os exemplos, ajuste batchSize/fetchSize e rode em um ambiente de teste com dados representativos. A prática e a medição são o caminho mais rápido para resultados sólidos.

Bom proveito: processe com cuidado, monitore e prefira explicitar o ciclo de vida do EntityManager em processos de longa duração.

Assuntos: Java, spring-boot, jpa, hibernate, Performance, microservices

Leia também