Cartesian Product Explosion: Quando o Hibernate Traz Milhares de Linhas para Carregar Dezenas de Objetos
Se você já abriu o log SQL do Hibernate e viu uma query com múltiplos JOINs retornando um número de linhas absurdamente maior do que o esperado — e a aplicação continuava funcionando, mas lenta — você encontrou o Cartesian Product Explosion. Diferente do N+1, que executa muitas queries pequenas, este problema executa poucas queries enormes. O resultado é o mesmo: performance destruída, mas a causa é mais sutil e mais difícil de perceber.
O que é um Produto Cartesiano
Em álgebra relacional, o produto cartesiano entre dois conjuntos A e B produz todas as combinações possíveis de elementos de A com elementos de B. Se A tem M elementos e B tem N elementos, o resultado tem M × N linhas.
No contexto de SQL e JOINs: quando você faz um JOIN entre duas tabelas sem uma condição de filtro adequada, ou quando você faz múltiplos JOINs em coleções independentes a partir de uma mesma entidade raiz, o banco de dados precisa materializar todas as combinações antes de filtrar.
sql
-- Um usuário com 10 posts e 10 comentários
-- JOIN em ambas as coleções ao mesmo tempo:
SELECT u.*, p.*, c.*
FROM user u
JOIN post p ON p.author_id = u.id
JOIN comment c ON c.author_id = u.id
-- Resultado: 10 posts × 10 comentários = 100 linhas para 1 usuário
-- Com 50 usuários: potencialmente 50.000 linhas para 500 objetos reais
O banco de dados não está errado — ele está fazendo exatamente o que foi pedido. O problema é que o Hibernate pediu mais do que deveria.
Como o Hibernate Causa o Problema
O Cartesian Product Explosion no Hibernate acontece principalmente em dois cenários: JOIN FETCH em múltiplas coleções e FetchType.EAGER em múltiplas coleções.
Cenário 1: JOIN FETCH em múltiplas coleções
A tentativa de resolver o N+1 com JOIN FETCH em mais de uma coleção @OneToMany ou @ManyToMany simultaneamente:
java
@Entity
public class User {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "author")
private List<Post> posts;
@OneToMany(mappedBy = "author")
private List<Comment> comments;
}
java
// Tentativa de resolver N+1 de uma vez só
List<User> users = entityManager
.createQuery("""
SELECT DISTINCT u FROM User u
JOIN FETCH u.posts
JOIN FETCH u.comments
""", User.class)
.getResultList();
O SQL gerado pelo Hibernate:
sql
SELECT DISTINCT u.id, u.name,
p.id, p.title, p.author_id,
c.id, c.content, c.author_id
FROM user u
INNER JOIN post p ON p.author_id = u.id
INNER JOIN comment c ON c.author_id = u.id
Se um usuário tem 20 posts e 30 comentários, essa query retorna 600 linhas para representar 50 objetos. Com 100 usuários de proporções similares, são 60.000 linhas trafegadas da rede para o Hibernate desduplicar em memória e montar 100 objetos User.
O Hibernate consegue montar os objetos corretamente a partir dessas linhas duplicadas — daí o DISTINCT. Mas o custo de transferência, parsing e desduplicação é pago integralmente.
Cenário 2: FetchType.EAGER em múltiplas coleções
Menos óbvio, mas igualmente perigoso. Quando duas ou mais coleções são mapeadas com FetchType.EAGER, o Hibernate gera o mesmo produto cartesiano automaticamente, sem que você escreva nenhum JOIN FETCH:
java
@Entity
public class Order {
@Id
@GeneratedValue
private Long id;
@OneToMany(mappedBy = "order", fetch = FetchType.EAGER) // perigo
private List<OrderItem> items;
@OneToMany(mappedBy = "order", fetch = FetchType.EAGER) // perigo
private List<Payment> payments;
}
java
// Parece inocente — não há JPQL explícito
Order order = entityManager.find(Order.class, 1L);
SQL gerado:
sql
SELECT o.*, i.*, p.*
FROM order o
LEFT JOIN order_item i ON i.order_id = o.id
LEFT JOIN payment p ON p.order_id = o.id
WHERE o.id = 1
Um pedido com 50 itens e 3 pagamentos retorna 150 linhas para montar um único objeto Order. Em uma lista de 1.000 pedidos, isso pode ser 150.000 linhas onde 53.000 seriam suficientes — e isso assumindo distribuição uniforme, o que raramente acontece.
Cenário 3: MultipleBagFetchException como sinal de alerta
Em coleções do tipo List (chamadas de “bags” no Hibernate), tentar fazer JOIN FETCH duplo lança uma exceção em tempo de execução:
org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [User.posts, User.comments]
Essa exceção é, paradoxalmente, um mecanismo de proteção. O Hibernate detecta que o produto cartesiano de dois List produziria resultados impossíveis de desduplicar de forma confiável (porque listas permitem duplicatas por definição), e se recusa a executar. Se você está vendo essa exceção, você está exatamente no território deste artigo.
A tentação imediata de mudar List para Set para fazer a exceção sumir é uma armadilha — você elimina o erro, mas não elimina o produto cartesiano. O Hibernate agora executa, materializa todas as combinações, e usa o Set para desduplicar em memória. O problema persiste, invisível.
Por que é Difícil de Perceber
Os dados são corretos
O Hibernate monta os objetos certos. user.getPosts() retorna exatamente os posts do usuário, sem duplicatas visíveis. A aplicação funciona. Os testes passam. Só a performance sofre.
O log SQL não mostra o volume de linhas
Você vê uma query. Parece razoável. Mas o log não mostra quantas linhas essa query retornou. Sem métricas de rows fetched, você não sabe que aquela query “simples” trouxe 50.000 linhas.
O problema escala com os dados, não com o código
Em desenvolvimento com fixtures pequenos, a query com produto cartesiano retorna 20 linhas. Em produção com dados reais, retorna 200.000. O código não mudou — só os dados.
O DISTINCT no JPQL dá falsa sensação de segurança
java
// O DISTINCT parece resolver a duplicação
SELECT DISTINCT u FROM User u JOIN FETCH u.posts JOIN FETCH u.comments
O DISTINCT no JPQL instrui o Hibernate a desduplicar os objetos Java resultantes — mas o banco de dados ainda materializa e transmite todas as linhas duplicadas antes disso. O custo de rede e memória já foi pago.
Quantificando o Problema
O crescimento é multiplicativo, não aditivo. Cada coleção adicional multiplica o número de linhas pelo tamanho médio dessa coleção.
| Usuários | Posts/usuário | Comentários/usuário | Linhas sem produto cartesiano | Linhas com produto cartesiano |
|---|---|---|---|---|
| 100 | 10 | 5 | 1.500 | 5.000 |
| 100 | 50 | 20 | 7.000 | 100.000 |
| 1.000 | 50 | 20 | 70.000 | 1.000.000 |
| 1.000 | 100 | 50 | 150.000 | 5.000.000 |
Com três coleções independentes, a fórmula é U × P × C × T onde U é usuários, P é posts médios, C é comentários médios e T é tags médias. O crescimento cúbico transforma um problema de megabytes em gigabytes silenciosamente.
As Soluções
A solução fundamental é sempre a mesma: nunca fazer JOIN FETCH em duas coleções independentes ao mesmo tempo a partir da mesma entidade raiz. As estratégias abaixo são as formas de aplicar esse princípio.
1. @BatchSize nas coleções (solução mais segura)
Troca o JOIN pelo carregamento em lote com WHERE id IN (...). Sem produto cartesiano, sem MultipleBagFetchException, funciona com paginação:
java
@Entity
public class User {
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
@BatchSize(size = 25)
private List<Post> posts;
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
@BatchSize(size = 25)
private List<Comment> comments;
}
java
// Query principal
List<User> users = entityManager
.createQuery("SELECT u FROM User u", User.class)
.getResultList();
// Ao acessar posts: 1 query por batch de 25
// SELECT * FROM post WHERE author_id IN (1,2,...,25)
// SELECT * FROM post WHERE author_id IN (26,27,...,50)
// ...
// Ao acessar comments: idem, separado
Total de queries: 1 + ceil(N/25) + ceil(N/25) em vez de 1 query monstruosa. Para 100 usuários com batch 25: 9 queries, cada uma trazendo apenas o que precisa, sem duplicação.
Configuração global como rede de segurança:
properties
# application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=25
2. Queries separadas por coleção
A estratégia mais explícita e controlável: uma query para a entidade raiz, queries separadas para cada coleção, montagem manual ou via Hibernate:
java
// Query 1: usuários
List<User> users = entityManager
.createQuery("SELECT u FROM User u WHERE u.active = true", User.class)
.getResultList();
// Query 2: posts de todos os usuários retornados
List<Long> userIds = users.stream().map(User::getId).collect(toList());
List<Post> posts = entityManager
.createQuery("SELECT p FROM Post p WHERE p.author.id IN :ids", Post.class)
.setParameter("ids", userIds)
.getResultList();
// Query 3: comentários
List<Comment> comments = entityManager
.createQuery("SELECT c FROM Comment c WHERE c.author.id IN :ids", Comment.class)
.setParameter("ids", userIds)
.getResultList();
// Montagem em memória: O(N) com HashMap
Map<Long, List<Post>> postsByUser = posts.stream()
.collect(groupingBy(p -> p.getAuthor().getId()));
Map<Long, List<Comment>> commentsByUser = comments.stream()
.collect(groupingBy(c -> c.getAuthor().getId()));
// Associar aos usuários
users.forEach(u -> {
u.setPosts(postsByUser.getOrDefault(u.getId(), emptyList()));
u.setComments(commentsByUser.getOrDefault(u.getId(), emptyList()));
});
Resultado: 3 queries limpas, cada uma com volume de linhas proporcional ao que precisa. Zero produto cartesiano.
3. @EntityGraph com uma única coleção + @BatchSize na outra
Quando você quer o benefício do JOIN FETCH para uma coleção crítica e batch para as demais:
java
public interface UserRepository extends JpaRepository<User, Long> {
// JOIN FETCH apenas nos posts (mais importantes / sempre acessados)
@EntityGraph(attributePaths = {"posts"})
List<User> findByActiveTrue();
}
java
@Entity
public class User {
@OneToMany(mappedBy = "author")
// posts: carregado via EntityGraph acima, sem anotação aqui
@OneToMany(mappedBy = "author")
@BatchSize(size = 25) // comments: batch quando acessados
private List<Comment> comments;
}
Dessa forma você garante que a coleção mais importante vem no JOIN (1 query), e as demais chegam em lote quando necessário.
4. Projeção DTO com queries específicas por necessidade
Para endpoints de leitura — que são a maioria em sistemas web — a solução mais eficiente frequentemente é não carregar as entidades com suas coleções, mas projetar exatamente o que a view precisa:
java
// DTO achatado: sem produto cartesiano possível
public class UserSummaryDTO {
private Long id;
private String name;
private long postCount;
private long commentCount;
public UserSummaryDTO(Long id, String name, long postCount, long commentCount) {
this.id = id;
this.name = name;
this.postCount = postCount;
this.commentCount = commentCount;
}
}
java
List<UserSummaryDTO> summaries = entityManager
.createQuery("""
SELECT new com.example.UserSummaryDTO(
u.id,
u.name,
COUNT(DISTINCT p.id),
COUNT(DISTINCT c.id)
)
FROM User u
LEFT JOIN u.posts p
LEFT JOIN u.comments c
GROUP BY u.id, u.name
""", UserSummaryDTO.class)
.getResultList();
SQL gerado: 1 query, sem duplicação, retornando exatamente 1 linha por usuário, independente de quantos posts ou comentários cada um tenha.
Se você precisa dos dados completos das coleções (não apenas contagens), use queries separadas por DTO:
java
// Query para dados dos posts quando necessário
List<PostDTO> postDTOs = entityManager
.createQuery("""
SELECT new com.example.PostDTO(p.id, p.title, p.author.id)
FROM Post p
WHERE p.author.id IN :userIds
""", PostDTO.class)
.setParameter("userIds", userIds)
.getResultList();
5. Subselect Fetching como alternativa ao BatchSize
@Fetch(FetchMode.SUBSELECT) carrega uma coleção inteira em uma segunda query usando a query original como subquery. Sem produto cartesiano, sempre 2 queries por coleção:
java
@Entity
public class User {
@OneToMany(mappedBy = "author")
@Fetch(FetchMode.SUBSELECT)
private List<Post> posts;
@OneToMany(mappedBy = "author")
@Fetch(FetchMode.SUBSELECT)
private List<Comment> comments;
}
SQL gerado ao acessar as coleções:
sql
-- Query 1: usuários
SELECT * FROM user WHERE active = true
-- Query 2: todos os posts dos usuários retornados
SELECT * FROM post WHERE author_id IN (
SELECT id FROM user WHERE active = true
)
-- Query 3: todos os comentários
SELECT * FROM comment WHERE author_id IN (
SELECT id FROM user WHERE active = true
)
Total: 3 queries, independente do número de usuários e do tamanho das coleções. A desvantagem é que a subquery replica a query original — se ela for complexa ou mudar dinamicamente, @BatchSize é mais flexível.
Detectando o Problema
Habilitando contagem de linhas no log
O log padrão do Hibernate mostra o SQL, mas não o volume de linhas retornadas. Para enxergar o problema, habilite as estatísticas:
properties
spring.jpa.properties.hibernate.generate_statistics=true
logging.level.org.hibernate.stat=DEBUG
A métrica-chave é EntityLoadCount versus o número de linhas que a query deveria retornar. Se você tem 100 usuários esperados e EntityLoadCount mostra 5.000 objetos instanciados, há produto cartesiano acontecendo.
p6spy com contagem de linhas
Configure o p6spy para logar o tempo de execução e suspeite de queries rápidas com resultado lento de serialização — isso indica volume alto de linhas sendo processado em memória:
properties
# spy.properties
customLogMessageFormat=%(executionTime)ms | rows: ? | %(sql)
Teste com assertSQLStatementCount + verificação de volume
java
@Test
void naoDeveGerarProdutoCartesiano() {
// Garante que são no máximo 3 queries (1 principal + 2 coleções)
assertSQLStatementCount(3, () -> {
List<User> users = userService.findActiveUsersWithPostsAndComments();
// Verifica que o número de objetos montados é razoável
long totalPosts = users.stream().mapToLong(u -> u.getPosts().size()).sum();
long totalComments = users.stream().mapToLong(u -> u.getComments().size()).sum();
// Se houvesse produto cartesiano, posts e comments estariam multiplicados
assertThat(totalPosts).isLessThan(totalComments * 10); // heurística
});
}
EXPLAIN ANALYZE no banco
Para queries que já estão em produção e parecem custosas, o plano de execução revela o volume:
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.*, p.*, c.*
FROM user u
JOIN post p ON p.author_id = u.id
JOIN comment c ON c.author_id = u.id
WHERE u.active = true;
Procure por rows= no plano. Se a estimativa ou o valor real de rows for muito maior do que o número de usuários × (posts + comentários), é produto cartesiano.
Relação com o N+1 e Como Escolher a Estratégia
O Cartesian Product Explosion e o N+1 são soluções opostas para o mesmo problema mal resolvido: carregar entidades com múltiplas coleções. A ironia é que a solução ingênua para o N+1 (adicionar JOIN FETCH) frequentemente introduz produto cartesiano.
N+1: 1 query raiz + N queries por coleção × número de coleções
Produto Cartesiano: 1 query com M × N × ... linhas
Solução correta: 1 query raiz + 1 query por coleção (via batch ou subselect)
A heurística para escolher a estratégia:
Use JOIN FETCH quando a entidade tem uma única coleção a carregar antecipadamente e não há paginação por registros raiz. É a solução mais eficiente nesse caso.
Use @BatchSize ou SUBSELECT quando há duas ou mais coleções, ou quando há paginação. São as soluções mais seguras como padrão geral.
Use projeção DTO quando o endpoint é de leitura pura e você não precisa das entidades gerenciadas — que é a maioria das APIs REST. Elimina o problema pela raiz.
Armadilhas
“Mas meus testes passam”
Sim. Os objetos são montados corretamente. O produto cartesiano é um problema de volume, não de correção. Testes com fixtures de 5 usuários com 3 posts e 2 comentários cada produzem 30 linhas — imperceptível. Os mesmos testes em produção com 1.000 usuários com médias reais produzem 500.000 linhas.
DISTINCT no SQL vs. no JPQL
SELECT DISTINCT no SQL opera antes de retornar linhas ao cliente — o banco filtra duplicatas. DISTINCT no JPQL opera no Hibernate, depois de receber todas as linhas. Para o banco e para a rede, o volume é o mesmo com ou sem DISTINCT no JPQL.
java
// O DISTINCT aqui é processado pelo Hibernate em memória,
// DEPOIS de receber todas as linhas duplicadas do banco
SELECT DISTINCT u FROM User u JOIN FETCH u.posts JOIN FETCH u.comments
Mudar List para Set não resolve
java
// Mudança que elimina a exceção mas mantém o problema
@OneToMany(mappedBy = "author")
private Set<Post> posts; // Set em vez de List
@OneToMany(mappedBy = "author")
private Set<Comment> comments;
Com Set, o Hibernate executa o JOIN FETCH duplo sem lançar exceção. O produto cartesiano ainda acontece no banco e na transmissão. O Set apenas desduiplica na memória da JVM mais eficientemente. O volume de dados trafegado é idêntico.
FetchType.EAGER como configuração padrão em entidades reutilizadas
Uma entidade com EAGER em duas coleções parece razoável quando você sempre precisa de ambas. Mas ela é usada em dezenas de contextos diferentes — alguns precisam das duas coleções, outros não precisam de nenhuma, outros precisam de uma. O EAGER global força o produto cartesiano em todos os contextos. Use LAZY como padrão e carregue antecipadamente por query quando necessário.
Conclusão
O Cartesian Product Explosion é o inverso simétrico do N+1: em vez de muitas queries pequenas, uma query enorme. Ambos têm a mesma raiz — carregar múltiplas coleções associadas sem pensar no formato dos dados que o banco precisa materializar.
A boa notícia é que o diagnóstico é claro: múltiplos JOIN FETCH em coleções independentes, ou múltiplas coleções com FetchType.EAGER, são sinais suficientes para agir preventivamente. Com @BatchSize como padrão global e JOIN FETCH reservado para casos de coleção única, a classe inteira de problemas desaparece.
O princípio que unifica N+1 e Cartesian Product é o mesmo: pense em como o banco representa os dados, não apenas em como o Java os estrutura. A distância entre essas duas representações é onde a maioria dos problemas de performance em ORMs vive.
Próximo da série: Connection Pool Exhaustion — quando as conexões com o banco viram o gargalo e a aplicação trava sob carga.