Pular para o conteúdo

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

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

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.

Três formas de carregar pedidos e itens. select pedidos → select clientes (cliente_id); select pedidos → select itens (pedido_id, x10); select clientes → soma…
figura 1 · Três formas de carregar pedidos e itens

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.

Sessão e carregamento lazy. @Transactional começa → findAll(); findAll() → pedido.itens na transação (sessão aberta)…
figura 2 · Sessão e carregamento lazy

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

  1. Acrescente um quarto cliente e dez novos pedidos, e rode o somaComN1 de novo. Anote quantas consultas foram disparadas e explique a relação com o número de pedidos.
  2. Remova o fetch = FetchType.LAZY do ItemPedido.pedido e observe se o número de consultas muda. Explique o motivo com base na seção 1.
  3. Escreva um teste de integração que falha se statements preparados passar de 2 para o método somaComGrafo. Diga qual anotação você usou para isolar a transação do teste e por que ela importa aqui.
  4. Explique por que o join fetch do 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:

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 fetch de @ManyToOne (EAGER) e @OneToMany (LAZY) foram lidos das anotações da API de persistência (jakarta.persistence-api 3.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 contagens prepareStatementCount (14, 4, 1, 1 e 4) e o SQL são os impressos na execução.
  • O diagnóstico do @EntityGraph usou as mesmas entidades em outro pacote, com uma linha extra que imprime a classe do cliente e Hibernate.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

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?