O Problema Silencioso de Performance: Como resolver o Problema do N+1 no Spring Data JPA
Sua API roda super rápido na sua máquina local, mas fica lento em produção? O log do console rola infinitamente com dezenas de SELECTs seguidos? Parabéns, você acabou de encontrar o Problema do N+1. Neste post, vamos entender como o FetchType.LAZY pode se virar contra você em listagens e como resolver esse gargalo de forma elegante usando JOIN FETCH e @EntityGraph.
Na Máquina Local
O cenário do N+1 acontece quando estamos construindo listagens. Imagine que você tem uma entidade Autor e uma entidade Livro (relacionamento @OneToMany).
Na sua máquina local, o banco de dados tem 5 autores. Você cria um endpoint para listar todos os autores e a quantidade de livros que cada um escreveu. A requisição bate 50ms. Você aprova o PR e faz o deploy.
Seis meses depois, o banco de produção tem 5.000 autores. De repente, a mesma tela de listagem demora longos segundos(ou minutos) para carregar, a CPU do banco de dados vai a 100% de processamento e o servidor cai. O que aconteceu?
O que é o Problema do N+1?
O problema acontece quando o framework faz 1 query principal para buscar a lista de pais, e depois faz N queries adicionais para buscar os filhos de cada um dos pais.
Olhe para este código no seu Service:
List<Autor> autores = autorRepository.findAll(); // 1 Query para trazer 5000 autores
for (Autor autor : autores) {
// Para CADA autor, o Hibernate vai no banco buscar os livros
int qtdLivros = autor.getLivros().size(); // N Queries!
}
Neste cenário de 5.000 autores, o seu Spring Boot executou 5.001 queries SQL para carregar apenas uma única tela. Isso acaba com o pool de conexões do banco de dados e gera uma latência de rede insustentável.
O FetchType.LAZY
A regra número um de performance no Hibernate é: Nunca use FetchType.EAGER. Todos os seus relacionamentos (@ManyToOne, @OneToMany) devem ser marcados como LAZY (Carregamento Preguiçoso).
O LAZY garante que os Livros só sejam buscados no banco se você explicitamente chamar o getLivros().
O problema é que, ao fazer um findAll() seguido de um .map(DTO::new) para converter os dados para o Front-end, você inevitavelmente acessa a lista de livros, ativando o gatilho preguiçoso milhares de vezes dentro do loop.
Precisamos avisar ao Spring Data: "Ei, eu sei que a relação é LAZY, mas nesta consulta específica, eu VOU precisar dos livros. Traga todos eles de uma vez em uma única ida ao banco."
Temos duas formas excelentes de fazer isso.
Solução 1: O JOIN FETCH (Via JPQL)
A forma mais tradicional e explícita de resolver o problema é escrevendo uma query HQL/JPQL customizada na sua interface do Repository. O segredo está no FETCH.
@Repository
public interface AutorRepository extends JpaRepository<Autor, Long> {
//ERRADO: Vai gerar N+1 se acessar a lista depois
@Query("SELECT a FROM Autor a")
List<Autor> buscarTodosComProblema();
//CERTO: O FETCH avisa o Hibernate para trazer tudo em 1 única query
@Query("SELECT a FROM Autor a JOIN FETCH a.livros")
List<Autor> buscarTodosTrazendoLivros();
}
Quando usamos o JOIN FETCH, o Hibernate monta uma query SQL gigante fazendo um INNER JOIN real, trazendo os dados do Autor e dos Livros em uma única viagem ao banco de dados e populando os objetos Java na memória.
Resultado: Em vez de 5.001 queries, você executa apenas 1 query.
Solução 2: O @EntityGraph (Sem escrever SQL)
Muitos desenvolvedores detestam escrever strings de JPQL nos Repositories, especialmente para operações simples. Para manter o código limpo, o Spring Data JPA suporta os Entity Graphs.
O @EntityGraph permite que você sobrescreva o comportamento LAZY pontualmente para um método específico do repositório, apenas passando o nome do atributo que você quer forçar o carregamento.
@Repository
public interface AutorRepository extends JpaRepository<Autor, Long> {
//CERTO e ELEGANTE: 1 única query sem escrever JPQL
@EntityGraph(attributePaths = {"livros"})
List<Autor> findAll();
// Você também pode usar com buscas por parâmetros
@EntityGraph(attributePaths = {"livros"})
List<Autor> findByNomeContaining(String nome);
}
O Spring Data pega essa anotação e injeta o LEFT OUTER JOIN FETCH automaticamente na consulta gerada por baixo dos panos. É limpo, é à prova de erros de digitação e mantém o seu repository totalmente padronizado.
Dica de Ouro: Ligue o log do SQL
Você só sabe que tem um problema de N+1 se estiver vigiando o que a sua API conversa com o banco. Nunca desenvolva localmente sem estas duas propriedades ligadas no seu application-dev.yml:
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
Se ao acessar uma rota no seu Postman/Insomnia o seu console exibir dezenas de SELECTs rolando na tela, pare tudo. Você tem um vazamento de performance.
Conclusão
Resolver o problema do N+1 é um daqueles conhecimentos que separam os desenvolvedores Plenos/Sêniores dos Juniores. Bancos de dados são extremamente rápidos para retornar 10.000 linhas em uma única chamada, mas são incrivelmente lentos para responder 10.000 chamadas pedindo uma única linha cada.
Recommended book
Want to dive deeper into this topic? Check out the book that inspired this post.
View book →