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:
- Testes de carga que simulam o volume real de dados e medem uso de memória.
- Monitoramento de GC e heap com ferramentas como VisualVM, JFR e Prometheus.
- Considere consultas nativas com cursores se o driver/provider tiver limitações.
- Use pagination por chave (keyset pagination) para concorrência e retry seguros.
- 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
Rate limiting por usuário em Spring Boot com Redis (Token Bucket) — passo a passo
Aprenda a implementar rate limiting por usuário em Spring Boot usando Redis e Token Bucket. Mostro a abordagem errada (in-memory), a solução correta com…
Spring Boot 3: habilitando Virtual Threads para APIs REST bloqueantes
Guia prático para habilitar virtual threads em APIs REST bloqueantes com Spring Boot 3: configuração do executor, exemplos com CompletableFuture…
Virtual Threads no Spring Boot: crie uma API concorrente passo a passo
Aprenda a usar virtual threads com Spring Boot para criar APIs concorrentes sem reescrever tudo. Veja code-review: o jeito errado, o certo, configuração…
Virtual Threads no Spring Boot: migrar controllers bloqueantes, JPA e tuning do HikariCP
Guia prático para migrar controllers bloqueantes e JPA para virtual threads no Spring Boot, com exemplos reais, setup do HikariCP, armadilhas e padrões para…
Outbox transacional com Spring Boot, JPA e Kafka — implementação prática
Implementação prática do padrão Outbox com Spring Boot, JPA e Kafka: gravação atômica de eventos, poller confiável, idempotência do consumidor e técnicas para…
Migrando endpoints bloqueantes para Virtual Threads no Spring Boot (passo a passo)
Guia prático para migrar endpoints bloqueantes no Spring Boot para virtual threads (Java 21). Padrões, código funcional, tratamento de transações, segurança e…