|

Under-fetching e Chatty Queries: Quando Sua Aplicação Conversa Demais com o Banco

O over-fetching traz dados demais. O under-fetching traz dados de menos — e paga o preço voltando ao banco repetidas vezes para completar o que precisava desde o início.

É um problema mais sutil que o N+1 porque não segue um padrão mecânico de loop. Ele aparece em código perfeitamente razoável: uma chamada de serviço que busca um pedido, outra que busca o cliente, outra que busca os itens, outra que verifica o estoque de cada item. Cada chamada individualmente faz sentido. O conjunto é um desastre de performance.

O termo “chatty” vem da metáfora certa: uma aplicação que conversa demais com o banco, trocando muitas mensagens pequenas onde poucas mensagens grandes resolveriam.


A Diferença entre Chatty Queries e N+1

Vale esclarecer o limite entre os dois problemas porque eles se confundem com frequência.

N+1 é estrutural e mecânico: resulta de iterar sobre uma coleção e acessar uma relação lazy por item. O padrão é sempre 1 query raiz + N queries idênticas com parâmetros diferentes. A causa é o ORM carregando relações sob demanda dentro de um loop.

Chatty queries são queries logicamente distintas, cada uma buscando uma coisa diferente, distribuídas pelo código de negócio — frequentemente em métodos de serviço separados, às vezes em camadas diferentes. Não há loop; há orquestração sequencial desnecessária.

java

// N+1: padrão mecânico de loop
for (Order order : orders) {
    order.getCustomer().getName(); // mesma query, N vezes
}

// Chatty: queries logicamente distintas, orquestração sequencial
Order order = orderRepository.findById(id);           // query 1
Customer customer = customerRepository.findById(      // query 2
    order.getCustomerId()
);
Address address = addressRepository.findByCustomerId( // query 3
    customer.getId()
);
List<OrderItem> items = itemRepository.findByOrderId( // query 4
    order.getId()
);
for (OrderItem item : items) {
    Product product = productRepository.findById(     // query 5..N
        item.getProductId()
    );
}

O código acima tem N+1 na parte final, mas as queries 1-4 são chatty — cada uma individualmente necessária, mas juntas representam uma orquestração ineficiente que poderia ser resolvida com uma ou duas queries bem escritas.


Anatomia do Problema

Cenário 1: Orquestração sequencial em camada de serviço

O padrão mais comum: um Service que coordena múltiplos Repository calls em sequência, onde cada chamada depende do resultado da anterior.

java

@Service
public class OrderSummaryService {

    public OrderSummaryDTO getOrderSummary(Long orderId) {

        // Query 1: busca o pedido
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));

        // Query 2: busca o cliente (com dados que poderiam ter vindo junto)
        Customer customer = customerRepository.findById(order.getCustomerId())
            .orElseThrow();

        // Query 3: busca o endereço de entrega
        Address deliveryAddress = addressRepository
            .findById(order.getDeliveryAddressId())
            .orElseThrow();

        // Query 4: busca os itens do pedido
        List<OrderItem> items = itemRepository.findByOrderId(orderId);

        // Query 5: busca o método de pagamento
        Payment payment = paymentRepository.findByOrderId(orderId)
            .orElseThrow();

        // Monta o DTO
        return new OrderSummaryDTO(order, customer, deliveryAddress, items, payment);
    }
}

5 queries para montar um objeto. Cada uma é um round-trip ao banco. Em produção com banco em rede separada (5-10ms por round-trip), isso é 25-50ms só de latência, antes de qualquer processamento.

Cenário 2: Verificações incrementais

Um padrão comum em validações: verificar cada condição com uma query separada antes de executar a operação.

java

public void processOrder(Long orderId) {

    // Query 1: o pedido existe?
    boolean orderExists = orderRepository.existsById(orderId);
    if (!orderExists) throw new OrderNotFoundException(orderId);

    // Query 2: o pedido está no status correto?
    String status = orderRepository.findStatusById(orderId);
    if (!"PENDING".equals(status)) throw new InvalidStatusException();

    // Query 3: o cliente está ativo?
    Long customerId = orderRepository.findCustomerIdById(orderId);
    boolean customerActive = customerRepository.isActive(customerId);
    if (!customerActive) throw new InactiveCustomerException();

    // Query 4: agora busca o pedido de fato para processar
    Order order = orderRepository.findById(orderId).orElseThrow();

    // processa...
}

4 queries para carregar e validar um pedido que poderia ser carregado uma vez com todas as informações necessárias.

Cenário 3: Aggregations em memória em vez de no banco

Computar aggregations (contagens, somas, médias) na aplicação em vez de no banco exige trazer todos os dados para a JVM:

java

// Traz todos os itens para somar em memória
List<OrderItem> items = itemRepository.findByOrderId(orderId);

BigDecimal total = items.stream()
    .map(item -> item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
    .reduce(BigDecimal.ZERO, BigDecimal::add);

long totalItems = items.stream()
    .mapToLong(OrderItem::getQuantity)
    .sum();

Em vez de:

sql

SELECT SUM(unit_price * quantity) AS total,
       SUM(quantity) AS total_items
FROM order_item
WHERE order_id = ?

Cenário 4: Existência verificada antes de busca

java

// 2 queries onde 1 seria suficiente
if (productRepository.existsBySku(sku)) {         // Query 1: EXISTS
    Product product = productRepository.findBySku(sku); // Query 2: SELECT
    return Optional.of(product);
}
return Optional.empty();

// 1 query
return productRepository.findBySku(sku); // retorna Optional vazio se não existir

Cenário 5: Queries dentro de streams e lambdas

O N+1 disfarçado de programação funcional:

java

// Cada map() dispara uma query — é N+1, mas parece código funcional
List<OrderDTO> dtos = orderIds.stream()
    .map(id -> orderRepository.findById(id).orElseThrow()) // query por id
    .map(order -> {
        Customer customer = customerRepository               // query por order
            .findById(order.getCustomerId())
            .orElseThrow();
        return new OrderDTO(order, customer);
    })
    .collect(toList());

Cenário 6: Paginação com count separado

O padrão de buscar a página de dados e depois fazer um COUNT para saber o total de páginas:

java

// 2 queries quando o banco poderia fazer em 1 passagem
List<Product> products = productRepository.findByActiveTrue(pageable);
long total = productRepository.countByActiveTrue(); // query extra

return new PageResponse<>(products, total, pageable);

Spring Data JPA com Page<T> faz isso automaticamente — e em alguns casos é inevitável. O ponto é: evite COUNT separado quando o banco já fez o trabalho e você pode inferir se há mais páginas.


As Soluções

1. JOIN e projeção DTO para orquestração sequencial

A solução fundamental para o Cenário 1: substituir N queries sequenciais por uma query com JOINs que traz tudo de uma vez.

java

// Repository com query que une tudo em uma passagem
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("""
        SELECT new com.example.dto.OrderSummaryDTO(
            o.id, o.status, o.createdAt,
            c.name, c.email,
            a.street, a.city, a.zipCode,
            p.method, p.status
        )
        FROM Order o
        JOIN Customer c ON c.id = o.customerId
        JOIN Address a ON a.id = o.deliveryAddressId
        JOIN Payment p ON p.orderId = o.id
        WHERE o.id = :orderId
    """)
    Optional<OrderSummaryDTO> findOrderSummary(@Param("orderId") Long orderId);
}

java

// Serviço: 1 query em vez de 5
public OrderSummaryDTO getOrderSummary(Long orderId) {
    return orderRepository.findOrderSummary(orderId)
        .orElseThrow(() -> new OrderNotFoundException(orderId));
}

Para os itens do pedido (coleção), uma segunda query com IN ou JOIN FETCH — não uma query por item:

java

@Query("""
    SELECT new com.example.dto.OrderItemDTO(
        i.id, i.quantity, i.unitPrice,
        prod.name, prod.sku
    )
    FROM OrderItem i
    JOIN Product prod ON prod.id = i.productId
    WHERE i.orderId = :orderId
""")
List<OrderItemDTO> findItemsByOrderId(@Param("orderId") Long orderId);

Total: 2 queries em vez de 5+N.

2. findById + orElseThrow combinado: elimine verificações incrementais

java

// Antes: 4 queries para validar + carregar
public void processOrder(Long orderId) {
    boolean exists = orderRepository.existsById(orderId);
    if (!exists) throw new OrderNotFoundException(orderId);

    String status = orderRepository.findStatusById(orderId);
    if (!"PENDING".equals(status)) throw new InvalidStatusException();

    Long customerId = orderRepository.findCustomerIdById(orderId);
    boolean active = customerRepository.isActive(customerId);
    if (!active) throw new InactiveCustomerException();

    Order order = orderRepository.findById(orderId).orElseThrow();
    // processa...
}

// Depois: 1 query, validação em memória
public void processOrder(Long orderId) {
    Order order = orderRepository.findByIdWithCustomer(orderId)
        .orElseThrow(() -> new OrderNotFoundException(orderId));

    if (!"PENDING".equals(order.getStatus())) {
        throw new InvalidStatusException();
    }
    if (!order.getCustomer().isActive()) {
        throw new InactiveCustomerException();
    }
    // processa...
}

java

@Query("""
    SELECT o FROM Order o
    JOIN FETCH o.customer
    WHERE o.id = :id
""")
Optional<Order> findByIdWithCustomer(@Param("id") Long id);

3. Aggregations no banco

Mova cálculos que operam sobre conjuntos de dados para onde os dados vivem:

java

// Interface de projeção para aggregation
public interface OrderTotals {
    BigDecimal getTotal();
    Long getTotalItems();
    Integer getDistinctProducts();
}

java

@Query("""
    SELECT
        SUM(i.unitPrice * i.quantity) AS total,
        SUM(i.quantity) AS totalItems,
        COUNT(DISTINCT i.productId) AS distinctProducts
    FROM OrderItem i
    WHERE i.orderId = :orderId
""")
OrderTotals calculateTotals(@Param("orderId") Long orderId);

1 query, sem trazer linhas para a JVM, sem alocação de objetos OrderItem que serão descartados após o cálculo.

Para aggregations mais complexas com agrupamento:

java

@Query("""
    SELECT new com.example.dto.CategoryRevenueDTO(
        p.categoryId,
        SUM(i.unitPrice * i.quantity),
        COUNT(DISTINCT o.id)
    )
    FROM OrderItem i
    JOIN Product p ON p.id = i.productId
    JOIN Order o ON o.id = i.orderId
    WHERE o.createdAt BETWEEN :start AND :end
    GROUP BY p.categoryId
    ORDER BY SUM(i.unitPrice * i.quantity) DESC
""")
List<CategoryRevenueDTO> revenueByCategory(
    @Param("start") LocalDateTime start,
    @Param("end") LocalDateTime end
);

4. Batch loading para IDs dispersos

Quando você tem uma lista de IDs vindos de fontes diferentes (não de um relacionamento Hibernate), use WHERE id IN em vez de queries individuais:

java

// Antes: query por ID
List<ProductDTO> products = productIds.stream()
    .map(id -> productRepository.findById(id).orElseThrow()) // N queries
    .map(ProductDTO::from)
    .collect(toList());

// Depois: 1 query com IN
List<Product> products = productRepository.findAllById(productIds); // 1 query
List<ProductDTO> dtos = products.stream()
    .map(ProductDTO::from)
    .collect(toList());

Para casos onde a ordem importa e você precisa preservar a ordem original dos IDs:

java

List<Product> products = productRepository.findAllById(productIds);

// Preserva a ordem original com um Map intermediário
Map<Long, Product> byId = products.stream()
    .collect(toMap(Product::getId, identity()));

List<ProductDTO> orderedDtos = productIds.stream()
    .map(id -> ProductDTO.from(byId.get(id)))
    .filter(Objects::nonNull)
    .collect(toList());

5. Multiquery com EntityManager para operações compostas

Quando você precisa de dados de múltiplas entidades não relacionadas para montar uma resposta, execute as queries em paralelo ou de forma composta em vez de sequencial:

java

// Antes: sequencial, cada query espera a anterior
public DashboardDTO getDashboard(Long userId) {
    UserStats stats = userStatsRepository.findByUserId(userId);
    List<RecentOrder> recentOrders = orderRepository.findRecentByUserId(userId, 5);
    List<Notification> notifications = notificationRepository.findUnreadByUserId(userId);
    BigDecimal balance = walletRepository.findBalanceByUserId(userId);

    return new DashboardDTO(stats, recentOrders, notifications, balance);
}

// Depois: paralelo com CompletableFuture
public DashboardDTO getDashboard(Long userId) {
    CompletableFuture<UserStats> statsFuture =
        CompletableFuture.supplyAsync(() -> userStatsRepository.findByUserId(userId));

    CompletableFuture<List<RecentOrder>> ordersFuture =
        CompletableFuture.supplyAsync(() -> orderRepository.findRecentByUserId(userId, 5));

    CompletableFuture<List<Notification>> notificationsFuture =
        CompletableFuture.supplyAsync(() -> notificationRepository.findUnreadByUserId(userId));

    CompletableFuture<BigDecimal> balanceFuture =
        CompletableFuture.supplyAsync(() -> walletRepository.findBalanceByUserId(userId));

    CompletableFuture.allOf(statsFuture, ordersFuture, notificationsFuture, balanceFuture)
        .join();

    return new DashboardDTO(
        statsFuture.join(),
        ordersFuture.join(),
        notificationsFuture.join(),
        balanceFuture.join()
    );
}

Atenção ao pool de conexões: queries paralelas consomem múltiplas conexões simultaneamente. Com HikariCP e um pool de 10 conexões, paralelizar 4 queries por request suporta apenas ~2-3 requests simultâneos antes de esgotar o pool. Calibre o pool de acordo — ou prefira a consolidação em JOINs quando possível.

Para Java 21+, virtual threads simplificam esse padrão sem o overhead de gerenciamento de CompletableFuture:

java

// Java 21+ com virtual threads via ExecutorService
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<UserStats> statsFuture = executor.submit(
        () -> userStatsRepository.findByUserId(userId)
    );
    Future<List<RecentOrder>> ordersFuture = executor.submit(
        () -> orderRepository.findRecentByUserId(userId, 5)
    );
    // ...
    return new DashboardDTO(statsFuture.get(), ordersFuture.get(), ...);
}

6. Stored Procedures e Native Queries para operações muito compostas

Quando a lógica de consolidação é complexa demais para JPQL ou quando você precisa de operações que o ORM não suporta bem, native queries e stored procedures são ferramentas legítimas:

java

@Query(
    value = """
        SELECT
            o.id,
            o.status,
            c.name AS customer_name,
            COUNT(i.id) AS item_count,
            SUM(i.unit_price * i.quantity) AS total,
            COALESCE(p.method, 'UNPAID') AS payment_method
        FROM orders o
        JOIN customers c ON c.id = o.customer_id
        LEFT JOIN order_items i ON i.order_id = o.id
        LEFT JOIN payments p ON p.order_id = o.id
        WHERE o.status = :status
          AND o.created_at >= :since
        GROUP BY o.id, o.status, c.name, p.method
        ORDER BY o.created_at DESC
        LIMIT :limit
    """,
    nativeQuery = true
)
List<Object[]> findOrderSummaries(
    @Param("status") String status,
    @Param("since") LocalDateTime since,
    @Param("limit") int limit
);

Para transformar Object[] em DTOs de forma type-safe, use @SqlResultSetMapping ou um RowMapper:

java

@Query(
    value = "...", // SQL acima
    nativeQuery = true
)
@SqlResultSetMapping(
    name = "OrderSummaryMapping",
    classes = @ConstructorResult(
        targetClass = OrderSummaryDTO.class,
        columns = {
            @ColumnResult(name = "id", type = Long.class),
            @ColumnResult(name = "status"),
            @ColumnResult(name = "customer_name"),
            @ColumnResult(name = "item_count", type = Long.class),
            @ColumnResult(name = "total", type = BigDecimal.class),
            @ColumnResult(name = "payment_method")
        }
    )
)

7. CQRS leve: repositórios de leitura separados

Para sistemas onde a separação entre leitura e escrita é clara, criar repositórios dedicados à leitura — com queries otimizadas para cada caso de uso — elimina a tensão entre o modelo de domínio (otimizado para escrita) e as queries de leitura (otimizadas para performance):

java

// Repositório de escrita: operações de domínio
public interface OrderRepository extends JpaRepository<Order, Long> {
    Optional<Order> findByIdWithCustomerAndItems(Long id); // para processar
}

// Repositório de leitura: queries otimizadas para cada view
@Repository
public interface OrderReadRepository {

    @Query("SELECT new ...OrderListDTO(...) FROM Order o JOIN ... WHERE ...")
    Page<OrderListDTO> findOrdersForList(OrderFilter filter, Pageable pageable);

    @Query("SELECT new ...OrderDetailDTO(...) FROM Order o JOIN ... WHERE o.id = :id")
    Optional<OrderDetailDTO> findOrderDetail(@Param("id") Long id);

    @Query("SELECT new ...OrderExportDTO(...) FROM Order o JOIN ... WHERE ...")
    List<OrderExportDTO> findOrdersForExport(DateRange range);
}

Isso não é CQRS completo com event sourcing — é simplesmente reconhecer que queries de leitura e operações de domínio têm requisitos diferentes e merecem código diferente.


Detectando Chatty Queries

Chatty queries são mais difíceis de detectar que N+1 porque não têm o padrão repetitivo óbvio no log. As ferramentas são as mesmas, mas a análise é diferente.

Contagem de queries por operação de negócio

Em vez de contar queries por request HTTP (que pode envolver múltiplas operações), conte por operação de negócio:

java

@SpringBootTest
class OrderSummaryServiceTest {

    @Autowired
    private OrderSummaryService service;

    @Test
    void getOrderSummaryDeveExecutarNoMaximo2Queries() {
        Long orderId = createTestOrderWithItemsAndPayment();

        assertSQLStatementCount(2, () -> {
            service.getOrderSummary(orderId);
        });
    }
}

Se o teste falha com 6 queries onde você esperava 2, você encontrou chatty queries.

Rastreamento de tempo por query vs. tempo total

Com p6spy logando o tempo de cada query, compare a soma dos tempos individuais com o tempo total da operação. Se você tem 10 queries de 2ms cada, o overhead mínimo é 20ms — independente da velocidade do banco.

properties

# spy.properties
customLogMessageFormat=%(executionTime)ms | %(sql)

Se o log mostra:

2ms | SELECT * FROM orders WHERE id = ?
2ms | SELECT * FROM customers WHERE id = ?
2ms | SELECT * FROM addresses WHERE id = ?
2ms | SELECT * FROM order_items WHERE order_id = ?
2ms | SELECT * FROM payments WHERE order_id = ?

Você tem 10ms de latência de rede pura para uma operação que poderia custar 3ms com JOINs.

APM: traces distribuídos

Em produção, ferramentas de APM como Datadog, New Relic e Elastic APM mostram o trace completo de um request com cada query como um span. Operações com 10+ spans de banco de dados, cada um pequeno, são candidatas a consolidação.


O Princípio Unificador

Chatty queries surgem da mesma raiz que o over-fetching: o código foi escrito pensando em objetos e operações individuais, não em conjuntos e operações compostas.

A mudança de mentalidade necessária é a mesma do N+1 e do produto cartesiano, mas aplicada a um nível mais alto de abstração. Não é “como busco esta entidade?” — é “o que essa operação de negócio precisa, e qual é o menor conjunto de queries que traz tudo isso?”.

Algumas perguntas que ajudam a guiar o design:

  • Quantas queries esse método de serviço executa para completar sua responsabilidade?
  • Há queries sequenciais onde a segunda usa apenas dados que vieram da primeira? Esse é um JOIN.
  • Há verificações de existência seguidas de busca do mesmo dado? Busque diretamente e trate o Optional.empty().
  • Há cálculos em memória sobre coleções de dados do banco? Mova o cálculo para o banco com GROUP BY e funções de agregação.
  • Há queries dentro de lambdas em streams? Provavelmente é N+1 disfarçado.

Armadilhas

Consolidação que cria produto cartesiano

Ao consolidar N queries em uma, é fácil criar o problema do artigo anterior. Juntar orders, order_items e payments em uma query com múltiplos JOINs pode resultar em produto cartesiano se order_items e payments são coleções independentes.

A regra: consolide queries sequenciais (onde a segunda depende da primeira), mas use queries separadas para coleções independentes. Dois problemas distintos, soluções distintas.

java

// Correto: JOIN para dados escalares (1:1 ou N:1)
// Query separada para coleções (1:N)
Optional<OrderHeaderDTO> header = orderRepository.findOrderHeader(orderId); // JOIN com customer, address, payment
List<OrderItemDTO> items = itemRepository.findByOrderId(orderId);           // query separada para coleção

Paralelismo sem dimensionamento do connection pool

Paralelizar 5 queries sem dimensionar o pool é trocar chatty queries por connection pool exhaustion — o próximo artigo desta série. Sempre que paralelizar queries, verifique se o maximumPoolSize do HikariCP comporta a concorrência planejada.

properties

# Se você vai executar 5 queries em paralelo por request,
# e quer suportar 20 requests simultâneos:
# pool mínimo = 5 × 20 = 100 conexões
spring.datasource.hikari.maximum-pool-size=100

Otimização prematura de queries simples

Nem toda sequência de queries é um problema. Uma operação que executa 3 queries para carregar dados de 3 domínios completamente independentes — sem relação entre si — pode ser perfeitamente razoável. O problema é quando queries são desnecessariamente sequenciais, não quando são múltiplas por necessidade legítima.


Conclusão

Chatty queries são o problema de performance mais comum em camadas de serviço Java bem-intencionadas. O código que as produz quase sempre foi escrito por alguém pensando claramente sobre o domínio — separando responsabilidades, respeitando encapsulamento, compondo serviços de forma limpa. O problema não é intenção ruim, é falta de visibilidade sobre o que acontece na fronteira com o banco.

A solução começa por tornar essa fronteira visível: assertSQLStatementCount em testes, log de queries em desenvolvimento, traces no APM em produção. Com visibilidade, o padrão fica óbvio — e na maioria dos casos, a correção é cirúrgica: um JOIN aqui, uma projeção lá, uma aggregation movida para o banco.

O princípio que une todos os problemas desta série — N+1, produto cartesiano, over-fetching, under-fetching — é o mesmo: o banco de dados é otimizado para operar em conjuntos, e cada viagem de ida e volta tem um custo fixo que independe do tamanho do resultado. Quanto mais você aproveita a capacidade do banco de retornar exatamente o que você precisa em uma única operação, mais eficiente sua aplicação se torna.


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 *