Trilha de Kotlin · parte 48 de 59
JPA, Hibernate e N+1
Aula da fase 7 da trilha de Kotlin: Quando o carregamento lazy causa N+1, e como `fetch join` e `@EntityGraph` resolvem o problema?
intermediárioPublicada em · 15 min de leitura
Imagine um restaurante que, para servir uma mesa, pede ao garçom os dados de cada prato separadamente. Ele pergunta o nome do pedido, depois vai ao caixa buscar o cliente, depois volta à cozinha para cada item, um por um. Funciona, mas um pedido de dez mesas vira dezenas de idas e vindas. O JPA, com o Hibernate por baixo, faz algo parecido quando você lê uma lista de entidades e depois percorre um relacionamento dentro dela. Cada relacionamento que ainda não foi carregado vira uma consulta extra, e o problema tem nome: N+1, uma consulta para a lista e mais uma para cada item.
Esta aula explica o mecanismo que produz essas consultas extras, as três formas de evitá-las que a documentação oferece e o que acontece quando o carregamento tardio tenta acontecer fora da transação. A pergunta central do tema é quando o carregamento lazy causa N+1, e a resposta exige entender onde fica a sessão de persistência, que é o lugar em que o Hibernate guarda as entidades carregadas e decide quando buscar o restante.
o mapa da aula
A aula começa pelo que é uma entidade JPA e por que um relacionamento pode ser carregado depois. Em seguida mostra o N+1 com um exemplo que gerou quatorze consultas no banco, com as consultas reais. A terceira parte compara join fetch e @EntityGraph, com a contagem de consultas de cada caso. Depois vem o carregamento fora da transação, que gera uma exceção conhecida. A última parte trata das boas práticas e do que ficou sem verificação.
Versões desta aula: Spring Boot 4.1.1, Spring Framework 7.0.9, Spring Data JPA 4.1.1, Hibernate ORM 7.4.5.Final, Kotlin 2.3.21 vindo do BOM. A página de Kotlin do Spring Boot indica Kotlin 2.2.x como mínimo. O banco é o PostgreSQL 17.11 em container. A aplicação de teste foi compilada com Maven e executada com o HikariCP 7.0.2 que o próprio starter traz. O esquema foi criado pelo Hibernate com ddl-auto=create, e o Liquibase foi desligado com spring.liquibase.enabled=false, porque ele só entra na aula Migrations com Liquibase.
1. entidade, sessão e carregamento lazy
Uma entidade JPA é uma classe Kotlin que o Hibernate sabe ler do banco e gravar de volta. Ela é anotada com @Entity e tem um identificador marcado com @Id. A parte que causa o problema é o relacionamento: um Pedido tem uma lista de ItemPedido, e a forma como essa lista é carregada define quanto o banco trabalha.
Quando um relacionamento é LAZY, o Hibernate não o carrega junto com o pedido. Ele coloca no lugar uma coleção vazia que, na primeira vez em que alguém a lê, dispara uma consulta. Essa coleção só consegue fazer isso enquanto a sessão de persistência estiver aberta. A sessão é o contexto em que o Hibernate guarda as entidades carregadas e enxerga as mudanças feitas nelas, e, em uma aplicação Spring, ela costuma durar o tempo de um método anotado com @Transactional.
Na linguagem do Hibernate, essa espera é o carregamento lazy. Quando o relacionamento é EAGER, a consulta que carrega o pedido já traz os dados do relacionamento, por join ou por consulta separada, e nada fica para depois. Os defaults vêm da API de persistência, e eles não são iguais para todos os tipos de relacionamento. Esta aula conferiu os padrões lendo as anotações no jar da API de persistência, com um pequeno programa de verificação:
ManyToOne.fetch default=EAGER
OneToMany.fetch default=LAZY
Ou seja, um @ManyToOne é carregado junto com a entidade que o contém, por padrão, e um @OneToMany fica para depois. A consequência prática é que o padrão de @ManyToOne também pode gerar consultas extras, como o experimento mostra adiante.
2. o N+1 em um exemplo real
O exemplo tem três entidades: Cliente, Pedido e ItemPedido. Cada pedido tem um cliente, em uma relação @ManyToOne com o padrão EAGER, e uma lista de itens, em uma relação @OneToMany com LAZY explícito. O conjunto de dados tem 3 clientes e 10 pedidos, com 3 itens em cada pedido, totalizando 30 itens.
A função medida lê todos os pedidos e soma o número de itens de cada um:
@Transactional(readOnly = true)
fun somaComN1(): Int = pedidos.findAll().sumOf { it.itens.size }
O código parece inocente, porque lê uma lista e percorre um campo. A figura mostra as consultas que essa leitura disparou. A coluna da esquerda é o padrão. A do meio é o join fetch. A da direita é o @EntityGraph. Os três casos têm o mesmo resultado, 30 itens, mas custos diferentes.
A figura da esquerda conta as consultas do caminho padrão. Primeiro o findAll busca os pedidos. Depois o Hibernate carrega os clientes, porque o @ManyToOne é EAGER, e aparecem três consultas, uma por cliente distinto. Por fim, cada um dos dez pedidos dispara uma consulta própria para os itens, na primeira vez em que o código lê it.itens. Somando, são 1 + 3 + 10 = 14 consultas, e é nas dez consultas de itens que o N+1 aparece de verdade.
O log SQL real mostra a sequência, com a parte relevante cortada:
select p1_0.id,p1_0.cliente_id from pedido p1_0
select c1_0.id,c1_0.nome from cliente c1_0 where c1_0.id=?
select c1_0.id,c1_0.nome from cliente c1_0 where c1_0.id=?
select c1_0.id,c1_0.nome from cliente c1_0 where c1_0.id=?
select i1_0.pedido_id,i1_0.id,i1_0.produto,i1_0.quantidade from item_pedido i1_0 where i1_0.pedido_id=?
... (mais nove consultas iguais, uma por pedido)
resultado: 30
statements preparados: 14
Note que a contagem de 14 vem do próprio Hibernate, pela estatística prepareStatementCount, e não de uma estimativa. Para que essa estatística seja registrada, a propriedade hibernate.generate_statistics precisa estar ligada. Sem ela, o contador fica em zero, mesmo com as consultas acontecendo. O número cresce com a quantidade de pedidos, e isso é a essência do problema: um pedido a mais gera uma consulta a mais.
3. as três saídas: join fetch, entity graph e a leitura do custo
A primeira saída é o join fetch em JPQL. A consulta pede ao banco que traga o pedido junto com os itens, em um único join:
@Query("select p from Pedido p join fetch p.itens")
fun comItensFetch(): List<Pedido>
No experimento, esse método gerou uma consulta com join para os itens, seguida das três consultas de clientes, que continuam por causa do EAGER do @ManyToOne. O total foi 4 consultas. Um detalhe que surpreendeu: a lista retornada tinha 10 linhas, e não 30. Como o join repete o pedido uma vez por item, a expectativa inicial é uma linha por item. O guia de migração do Hibernate 6 explica o que acontece: “The returning duplicates of entities are now always filtered by Hibernate.” Ou seja, o Hibernate filtra as repetições de entidades em consultas com join fetch, e por isso o distinct deixou de ser necessário para esse fim. A função somaComFetch confirma o resultado: 10 pedidos distintos, com 30 itens somados.
A segunda saída é o @EntityGraph. Ele diz ao Hibernate quais atributos carregar junto com a consulta, sem reescrever o JPQL. A documentação do Spring Data descreve assim: “The JPA 2.1 specification introduced support for specifying Fetch- and LoadGraphs that we also support with the @EntityGraph annotation, which lets you reference a @NamedEntityGraph definition.” Para um caso simples, pode-se declarar os atributos direto na anotação:
@EntityGraph(attributePaths = ["itens"])
@Query("select p from Pedido p")
fun comGrafoDeItens(): List<Pedido>
O resultado foi uma única consulta, com left join para os itens, e o log não mostrou consultas para os clientes. A explicação está no tipo do grafo. Quando se usa attributePaths, o Spring Data cria um grafo do tipo FETCH, que é o padrão da anotação. Num grafo FETCH, só os atributos listados entram na consulta, e o @ManyToOne do cliente, que é EAGER no mapeamento, fica de fora. A especificação do JPA descreve a regra no tutorial de entity graphs da Jakarta EE: “A fetch graph consists of only the fields explicitly specified in the EntityGraph instance”. O cliente, então, chega como proxy não inicializado. Um diagnóstico com as mesmas entidades mostrou isso:
[grafo itens] classe do cliente=diag62.Cliente$HibernateProxy inicializado=false
A consulta dos clientes só existiria se o código lesse o nome do cliente. A soma de itens não lê, por isso o log não tem nenhuma consulta extra. A correção é listar o cliente no grafo:
@EntityGraph(attributePaths = ["itens", "cliente"])
@Query("select p from Pedido p")
fun comGrafoDeItensECliente(): List<Pedido>
Com essa mudança, o cliente vem no mesmo left join e o total continua em uma consulta:
select p1_0.id,c1_0.id,c1_0.nome,i1_0.pedido_id,i1_0.id,i1_0.produto,i1_0.quantidade from pedido p1_0 left join cliente c1_0 on c1_0.id=p1_0.cliente_id left join item_pedido i1_0 on p1_0.id=i1_0.pedido_id
resultado: 30
statements preparados: 1
Os dois @EntityGraph do experimento deram statements preparados: 1. O que muda entre eles é o objeto cliente: no primeiro é um proxy que ainda não foi carregado, e no segundo é o objeto real.
A figura resume os três caminhos. O ponto para guardar é que o custo não depende só da forma da consulta, mas de quais relacionamentos ela inclui. Trazer tudo com join pode ser caro quando a lista é grande, e a aula não mediu esse custo em um volume maior. Trazer só o que a tela precisa é quase sempre a decisão correta, e a figura mostra onde cada decisão cai.
Pergunta de entrevistaO que é o problema N+1 e como você resolveria?
N+1 acontece quando uma consulta traz a lista, e depois uma consulta extra é feita para cada item, por causa de um relacionamento lazy. O banco recebe dezenas de consultas onde poderia receber uma. Resolvo com join fetch na consulta, ou com entity graph quando o caso é específico. Antes de otimizar, leio o log de SQL ou as métricas, para confirmar o padrão. E evito acessar relacionamentos fora da transação.
4. o carregamento fora da transação
Um erro comum surge quando o código retorna a entidade para fora do método transacional e tenta ler o relacionamento depois. O experimento fez exatamente isso:
fun lazyForaDaTransacao(): String = try {
pedidos.findAll().first().itens.size.toString()
} catch (e: Exception) {
"${e::class.simpleName}: ${e.message?.lineSequence()?.first()}"
}
O método não tem @Transactional. O findAll abre e fecha a própria sessão, e quando o código chega a itens, não há sessão para buscar a coleção. O resultado real foi:
resultado: LazyInitializationException: Cannot lazily initialize collection of role 'aula62.Pedido.itens' with key '1' (no session)
statements preparados: 4
As quatro consultas são as do findAll e dos três clientes, que o EAGER carregou. Os itens nunca chegaram a ser buscados, e a exceção apareceu no momento da leitura, não na consulta. Esse é o caso que faz muitos desenvolvedores acharem que o Hibernate “perdeu” os dados. Ele não perdeu nada: a sessão terminou antes da leitura.
Há duas formas corretas de resolver. A primeira é ler o relacionamento dentro da mesma transação, em um método anotado com @Transactional que devolve o resultado já pronto, como faz o somaComN1. A segunda é carregar o que a tela precisa antes de sair da transação, com join fetch ou @EntityGraph, de modo que a leitura posterior não dependa da sessão. Há ainda a opção de manter a sessão aberta até o fim da requisição web. Esta aula não a testou, porque o experimento não subiu um servidor web.
A figura resume a sequência de uma sessão. A leitura dentro da transação dispara o SELECT e funciona. A leitura depois do fim da transação bate na sessão fechada.
5. erros comuns e boas práticas
O primeiro erro é usar findAll e depois percorrer relacionamentos em um laço sem pensar no custo. O sintoma costuma aparecer só em produção, quando a lista cresce, porque em desenvolvimento os dados são poucos. A prática recomendada é ligar o log de consultas, ou a estatística do Hibernate, em um teste de integração e falhar o teste quando a contagem passar do esperado. A aula sugere o mesmo caminho descrito no currículo, com um teste que conta consultas.
O segundo erro é deixar um @ManyToOne no padrão EAGER sem perceber. O experimento mostrou três consultas de clientes que ninguém pediu explicitamente. A alternativa é declarar fetch = FetchType.LAZY no relacionamento e carregar o cliente quando for usado. Isso não elimina o problema de forma automática, porque uma leitura posterior ainda pode disparar consultas, mas torna o custo visível no código.
O terceiro erro é usar join fetch em duas coleções ao mesmo tempo. Dois join sobre listas diferentes tendem a multiplicar as linhas e a gerar uma consulta cara. Esta aula não testou esse caso, então a afirmação é uma recomendação, não um resultado medido. A regra prática é trazer uma coleção por consulta, e carregar a outra em uma etapa separada, quando ela for realmente necessária.
O quarto erro é tratar a LazyInitializationException como um problema do framework. A exceção diz que a sessão não existe mais. A correção está no desenho do fluxo: onde a transação começa, onde termina e o que sai dela.
A boa prática, em resumo, é declarar os relacionamentos como LAZY por padrão, carregar com join fetch ou @EntityGraph o que a operação realmente usa e medir a quantidade de consultas em um teste. O @Transactional deve envolver a leitura inteira, e a entidade que sai da camada de persistência deve chegar ao resto do sistema já com os dados de que precisa. A próxima aula mostra que o tipo da entidade também muda esse quadro, quando ela é uma data class.
6. exercícios
- Acrescente um quarto cliente e dez novos pedidos, e rode o
somaComN1de novo. Anote quantas consultas foram disparadas e explique a relação com o número de pedidos. - Remova o
fetch = FetchType.LAZYdoItemPedido.pedidoe observe se o número de consultas muda. Explique o motivo com base na seção 1. - Escreva um teste de integração que falha se
statements preparadospassar de 2 para o métodosomaComGrafo. Diga qual anotação você usou para isolar a transação do teste e por que ela importa aqui. - Explique por que o
join fetchdo experimento devolveu 10 linhas, e não 30, e diga o que você precisaria ler na documentação para confirmar a causa.
7. glossário
Entidade JPA: classe mapeada pelo JPA para uma tabela, com um identificador marcado com @Id.
Sessão de persistência: contexto em que o Hibernate guarda as entidades carregadas e decide quando buscar relacionamentos. Em Spring, costuma durar uma transação.
Carregamento lazy: estratégia em que um relacionamento só é buscado quando é lido pela primeira vez, dentro de uma sessão aberta.
Carregamento eager: estratégia em que o relacionamento é buscado junto com a entidade que o contém.
N+1: problema em que uma consulta para a lista é seguida de uma consulta por item para carregar um relacionamento, de modo que o total cresce com o número de itens.
join fetch: cláusula JPQL que carrega o relacionamento na mesma consulta, por join.
@EntityGraph: anotação do Spring Data que declara quais atributos carregar junto com a consulta.
LazyInitializationException: exceção lançada quando um relacionamento lazy é lido sem uma sessão aberta.
fontes
Consultadas em 20/10/2026:
- Spring Data JPA, consulta com
@EntityGraph, com a citação sobre o suporte a fetch e load graphs e o exemplo comattributePaths: https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html - Spring Data JPA, Javadoc de
@EntityGraph, com o valor padrãoFETCHdo atributotype: https://docs.spring.io/spring-data/jpa/docs/current/api/org/springframework/data/jpa/repository/EntityGraph.html - Jakarta EE, tutorial de entity graphs, com a citação sobre fetch graph: https://jakarta.ee/learn/jakartaee-tutorial/9.1/persist/persistence-entitygraphs/persistence-entitygraphs.html
- Hibernate ORM 6.0, guia de migração, seção “DISTINCT”, com a citação sobre a filtragem de duplicatas: https://docs.hibernate.org/orm/6.0/migration-guide/migration-guide.html
- Spring Boot, requisito de Kotlin 2.2.x: https://docs.spring.io/spring-boot/reference/features/kotlin.html
Consultadas em 20/10/2026. As citações foram lidas com WebFetch nas URLs acima.
Verificações locais, feitas em 20/10/2026:
- Os padrões de
fetchde@ManyToOne(EAGER) e@OneToMany(LAZY) foram lidos das anotações da API de persistência (jakarta.persistence-api3.2.0), com reflexão. - O experimento completo, com as quatro variantes e o carregamento fora da transação, foi executado contra um PostgreSQL 17.11, com
hibernate.generate_statistics=true. As contagensprepareStatementCount(14, 4, 1, 1 e 4) e o SQL são os impressos na execução. - O diagnóstico do
@EntityGraphusou as mesmas entidades em outro pacote, com uma linha extra que imprime a classe do cliente eHibernate.isInitialized.
Código do experimento (arquivo aula62/Main.kt):
package aula62
import jakarta.persistence.Entity
import jakarta.persistence.EntityManagerFactory
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import jakarta.persistence.CascadeType
import org.hibernate.SessionFactory
import org.springframework.boot.CommandLineRunner
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.data.jpa.repository.EntityGraph
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Entity
open class Cliente(
@Id @GeneratedValue open var id: Long? = null,
open var nome: String = ""
)
@Entity
open class Pedido(
@Id @GeneratedValue open var id: Long? = null,
@ManyToOne open var cliente: Cliente? = null,
@OneToMany(mappedBy = "pedido", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
open var itens: MutableList<ItemPedido> = mutableListOf()
) {
fun adicionar(item: ItemPedido) {
itens.add(item)
}
}
@Entity
open class ItemPedido(
@Id @GeneratedValue open var id: Long? = null,
open var produto: String = "",
open var quantidade: Int = 0,
@ManyToOne(fetch = FetchType.LAZY) open var pedido: Pedido? = null
)
interface ClienteRepository : JpaRepository<Cliente, Long>
interface PedidoRepository : JpaRepository<Pedido, Long> {
@Query("select p from Pedido p join fetch p.itens")
fun comItensFetch(): List<Pedido>
@EntityGraph(attributePaths = ["itens"])
@Query("select p from Pedido p")
fun comGrafoDeItens(): List<Pedido>
@EntityGraph(attributePaths = ["itens", "cliente"])
@Query("select p from Pedido p")
fun comGrafoDeItensECliente(): List<Pedido>
}
@Service
class Relatorio(
private val clientes: ClienteRepository,
private val pedidos: PedidoRepository
) {
@Transactional
fun semear() {
val lista = (1..3).map { clientes.save(Cliente(nome = "cliente $it")) }
(1..10).forEach { n ->
val pedido = Pedido(cliente = lista[n % 3])
repeat(3) { k -> pedido.adicionar(ItemPedido(produto = "produto $n-$k", quantidade = k + 1, pedido = pedido)) }
pedidos.save(pedido)
}
}
@Transactional(readOnly = true)
fun somaComN1(): Int = pedidos.findAll().sumOf { it.itens.size }
@Transactional(readOnly = true)
fun somaComFetch(): String {
val linhas = pedidos.comItensFetch()
return "linhas retornadas=${linhas.size}, pedidos distintos=${linhas.distinct().size}, itens=${linhas.distinct().sumOf { it.itens.size }}"
}
@Transactional(readOnly = true)
fun somaComGrafo(): Int = pedidos.comGrafoDeItens().sumOf { it.itens.size }
@Transactional(readOnly = true)
fun somaComGrafoECliente(): Int = pedidos.comGrafoDeItensECliente().sumOf { it.itens.size }
fun lazyForaDaTransacao(): String = try {
pedidos.findAll().first().itens.size.toString()
} catch (e: Exception) {
"${e::class.simpleName}: ${e.message?.lineSequence()?.first()}"
}
}
@SpringBootApplication
class AulaJpaApplication {
@Bean
fun cenarios(relatorio: Relatorio, emf: EntityManagerFactory) = CommandLineRunner {
val stats = emf.unwrap(SessionFactory::class.java).statistics
relatorio.semear()
fun medir(nome: String, acao: () -> Any) {
stats.clear()
println("=== $nome")
val resultado = acao()
println("resultado: $resultado")
println("statements preparados: ${stats.prepareStatementCount}")
}
medir("N+1: findAll e depois itens lazy") { relatorio.somaComN1() }
medir("join fetch") { relatorio.somaComFetch() }
medir("@EntityGraph") { relatorio.somaComGrafo() }
medir("@EntityGraph com cliente") { relatorio.somaComGrafoECliente() }
medir("lazy fora da transacao") { relatorio.lazyForaDaTransacao() }
}
}
fun main() {
runApplication<AulaJpaApplication>().close()
}
Não verificado nesta aula: o custo do join fetch e do @EntityGraph com volumes grandes, a comparação entre eles em tamanho de lista real, e a opção de manter a sessão aberta por requisição web, que exige um servidor HTTP e não foi testada.
relacionados
- Trilha de Kotlin
- Curriculo de um Desenvolvedor Kotlin
- HikariCP e limites do banco
- data class como entidade
- Null safety e tipos nuláveis
- Spring Boot com controllers suspend
- GORM na Prática