|

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áriosPosts/usuárioComentários/usuárioLinhas sem produto cartesianoLinhas com produto cartesiano
1001051.5005.000
10050207.000100.000
1.000502070.0001.000.000
1.00010050150.0005.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.

Posts Similares

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *