|

Over-fetching de Colunas: O Custo Invisível do SELECT *

Existe uma linha de código que aparece em tutoriais, exemplos de documentação e código de produção com igual frequência:

sql

SELECT * FROM orders

Ou sua versão em JPQL:

java

entityManager.createQuery("SELECT o FROM Order o", Order.class).getResultList()

Ela funciona. Retorna os dados. Os testes passam. E em muitos sistemas, ela é a origem de um desperdício silencioso e contínuo: trazer da rede, desserializar e alocar na heap dados que nenhum código vai usar.

Over-fetching de colunas não trava a aplicação. Não gera exceção. Não aparece no stack trace. Mas consome banda, memória, CPU de serialização e slots de connection pool — e escala com cada request.


O Problema em Números Concretos

Considere uma entidade Product razoavelmente complexa:

java

@Entity
public class Product {

    @Id
    @GeneratedValue
    private Long id;

    private String name;           // 50 bytes
    private String sku;            // 20 bytes
    private BigDecimal price;      // 8 bytes
    private String description;    // até 5.000 bytes
    private byte[] thumbnail;      // até 200.000 bytes (200KB)
    private String specJson;       // até 10.000 bytes (JSON de especificações)
    private Boolean active;        // 1 byte
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    private Long categoryId;
    private Long brandId;
    // ... mais 15 campos
}

Um endpoint de listagem de produtos para um e-commerce — aquele que mostra nome, preço e imagem thumbnail em grade — precisa de id, name, price, thumbnail e active. Isso é aproximadamente 200.080 bytes por produto.

Mas se a query busca a entidade completa, ela traz também description (5KB), specJson (10KB) e todos os outros campos. O custo real por produto é ~215.000 bytes — mais de 200KB por linha.

Com 50 produtos na página: SELECT * trafega ~10MB. A projeção correta trafega ~10MB apenas do thumbnail + ~5KB do restante. Para a maioria dos endpoints de listagem, SELECT * traz 50 a 200 vezes mais dados do que o necessário.

Multiplique por 1.000 requests por segundo e você tem um gargalo de rede e memória que cresce com o tráfego.


Como o Hibernate Agrava o Problema

O Hibernate introduz camadas adicionais de custo ao over-fetching que vão além do SQL:

ResultSet → Object mapping

Para cada linha retornada, o Hibernate lê cada coluna do ResultSet, converte para o tipo Java correspondente e popula o campo da entidade. Colunas que você não vai usar passam por todo esse processo — leitura do buffer JDBC, conversão de tipo, alocação de objeto, escrita no campo via reflection.

Dirty checking sobre campos desnecessários

Dentro de uma transação, o Hibernate rastreia o estado original de cada entidade gerenciada para detectar mudanças no flush. Esse snapshot inclui todos os campos carregados — incluindo os que você não vai usar. Para entidades com colunas grandes (textos, JSON, BLOBs), o custo de memória do snapshot é proporcional ao tamanho total da entidade, não ao que você acessou.

First-level cache

O Hibernate mantém as entidades carregadas no cache de primeiro nível (a Session/EntityManager atual). Entidades com campos grandes ocupam mais memória nesse cache — e permanecem lá até o fechamento da session.

Lazy loading de @Lob não é garantido

Colunas @Lob (textos muito longos, binários) são frequentemente esperadas como lazy pelo desenvolvedor, mas o comportamento padrão depende do driver JDBC e do banco. No PostgreSQL com o driver padrão, @Lob em String pode ser carregado eagerly mesmo com @Basic(fetch = FetchType.LAZY). Você pode achar que está protegido e não está.

java

@Entity
public class Article {

    @Lob
    @Basic(fetch = FetchType.LAZY) // Pode não funcionar no PostgreSQL
    private String content; // Carregado mesmo assim
}

As Soluções

1. Constructor Expression no JPQL

A abordagem mais direta: projetar diretamente para um DTO no JPQL, trazendo apenas as colunas necessárias.

java

public class ProductListDTO {
    private final Long id;
    private final String name;
    private final BigDecimal price;
    private final Boolean active;

    public ProductListDTO(Long id, String name, BigDecimal price, Boolean active) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.active = active;
    }
    // getters...
}

java

List<ProductListDTO> products = entityManager
    .createQuery("""
        SELECT new com.example.dto.ProductListDTO(
            p.id, p.name, p.price, p.active
        )
        FROM Product p
        WHERE p.active = true
        ORDER BY p.name
    """, ProductListDTO.class)
    .getResultList();

SQL gerado:

sql

SELECT p.id, p.name, p.price, p.active
FROM product p
WHERE p.active = true
ORDER BY p.name

Apenas as colunas necessárias. Sem dirty checking. Sem first-level cache de entidade gerenciada. Sem alocação de campos não usados.

2. Interface Projection no Spring Data JPA

Uma alternativa ao DTO concreto que não exige construtor explícito — o Spring Data JPA gera um proxy que implementa a interface:

java

// Interface de projeção
public interface ProductSummary {
    Long getId();
    String getName();
    BigDecimal getPrice();
    Boolean getActive();
}

java

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Spring Data deriva a query e projeta para a interface
    List<ProductSummary> findByActiveTrue();

    // Com query explícita para mais controle
    @Query("""
        SELECT p.id AS id, p.name AS name,
               p.price AS price, p.active AS active
        FROM Product p
        WHERE p.active = true AND p.categoryId = :categoryId
    """)
    List<ProductSummary> findSummariesByCategoryId(@Param("categoryId") Long categoryId);
}

Interface projections têm uma limitação: cada acesso a um campo dispara uma chamada no proxy. Para mapeamentos complexos ou transformações, o DTO concreto com constructor expression é mais previsível.

3. Tuple Query (sem DTO dedicado)

Para casos onde você quer flexibilidade sem criar um DTO por query, o JPA oferece Tuple — um container genérico para resultados parciais:

java

List<Tuple> results = entityManager
    .createQuery("""
        SELECT p.id AS id, p.name AS name, p.price AS price
        FROM Product p
        WHERE p.categoryId = :categoryId
    """, Tuple.class)
    .setParameter("categoryId", categoryId)
    .getResultList();

for (Tuple row : results) {
    Long id = row.get("id", Long.class);
    String name = row.get("name", String.class);
    BigDecimal price = row.get("price", BigDecimal.class);
}

Útil para queries dinâmicas ou exploratórias, mas DTO concreto é preferível em código de produção — mais type-safe, mais legível, mais fácil de testar.

4. @Column(insertable, updatable) + @Basic(fetch = LAZY) com Bytecode Enhancement

Para casos onde você quer manter a entidade completa mas evitar o carregamento de colunas específicas, o Hibernate oferece lazy loading por campo via bytecode enhancement.

xml

<!-- pom.xml: plugin de bytecode enhancement -->
<plugin>
    <groupId>org.hibernate.orm.tooling</groupId>
    <artifactId>hibernate-enhance-maven-plugin</artifactId>
    <version>${hibernate.version}</version>
    <executions>
        <execution>
            <goals>
                <goal>enhance</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <enableLazyInitialization>true</enableLazyInitialization>
    </configuration>
</plugin>

java

@Entity
public class Product {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
    private BigDecimal price;

    @Basic(fetch = FetchType.LAZY) // Requer bytecode enhancement para funcionar
    private String description;

    @Basic(fetch = FetchType.LAZY)
    @Column(name = "spec_json")
    private String specJson;

    @Lob
    @Basic(fetch = FetchType.LAZY)
    private byte[] thumbnail;
}

Com bytecode enhancement ativo, description, specJson e thumbnail são carregados apenas quando acessados — como o lazy loading de relações, mas para colunas simples.

A desvantagem é a complexidade de configuração e o risco de LazyInitializationException fora de sessão, o mesmo problema das relações lazy. Use com cautela e prefira projeções DTO quando possível.

5. Criteria API com seleção explícita de colunas

Para queries dinâmicas construídas programaticamente, a Criteria API permite selecionar campos específicos:

java

CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<ProductListDTO> cq = cb.createQuery(ProductListDTO.class);
Root<Product> product = cq.from(Product.class);

// Seleciona apenas os campos necessários
cq.select(cb.construct(
    ProductListDTO.class,
    product.get("id"),
    product.get("name"),
    product.get("price"),
    product.get("active")
));

// Filtros dinâmicos
List<Predicate> predicates = new ArrayList<>();
if (categoryId != null) {
    predicates.add(cb.equal(product.get("categoryId"), categoryId));
}
if (minPrice != null) {
    predicates.add(cb.greaterThanOrEqualTo(product.get("price"), minPrice));
}

cq.where(predicates.toArray(new Predicate[0]));

List<ProductListDTO> results = entityManager.createQuery(cq).getResultList();

6. @DynamicUpdate para escritas parciais

O problema de over-fetching existe também nas escritas. Por padrão, o Hibernate gera um UPDATE que inclui todas as colunas da entidade, mesmo que apenas uma tenha mudado:

sql

-- Você mudou apenas o price, mas Hibernate gera:
UPDATE product
SET name = ?, sku = ?, price = ?, description = ?, spec_json = ?,
    thumbnail = ?, active = ?, updated_at = ?, category_id = ?, brand_id = ?
WHERE id = ?

Para entidades com colunas grandes ou muitos campos, isso tem custo significativo — especialmente em tabelas com replicação, onde cada UPDATE é propagado integralmente.

java

@Entity
@DynamicUpdate // Hibernate inclui apenas as colunas que mudaram
public class Product {
    // ...
}

Com @DynamicUpdate, o Hibernate compara o snapshot do estado original com o estado atual no flush e inclui apenas as colunas que de fato mudaram:

sql

-- Apenas price mudou:
UPDATE product SET price = ?, updated_at = ? WHERE id = ?

A desvantagem: o @DynamicUpdate desabilita o cache de prepared statements para UPDATE nessa entidade, porque o SQL muda dependendo dos campos alterados. Em tabelas com alto volume de updates com padrões variados, avalie o trade-off.


Quando Usar Entidade Completa vs. Projeção

A regra prática é direta: use a entidade completa quando você vai escrever de volta no banco. Use projeção DTO quando você vai apenas ler.

Operação de leitura (API response, relatório, listagem)
→ DTO / Projection

Operação de escrita (update, validação complexa com múltiplos campos)
→ Entidade gerenciada

Operação mista (lê para exibir + permite editar)
→ Entidade para o formulário de edição, DTO para a listagem

Em sistemas web típicos, 70-80% das queries são leitura pura. Nesses casos, não há razão para carregar entidades gerenciadas com dirty checking, first-level cache e todos os campos — especialmente os que a view não vai exibir.


Detectando Over-fetching na Sua Aplicação

Habilitando log de SQL com colunas visíveis

properties

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.orm.jdbc.bind=TRACE

Revise cada query que aparece no log: quais colunas ela traz? Quais dessas colunas são usadas pelo código que chamou a query?

p6spy com tamanho de resposta

Configure o p6spy para logar o tempo de execução e identifique queries lentas que podem estar trazendo colunas desnecessárias:

properties

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

Queries rápidas de banco que causam alto consumo de memória ou lentidão na serialização JSON são candidatas a over-fetching.

Teste de volume de dados

java

@Test
void endpointDeListagemNaoDeveTrarzerColunasDesnecessarias() {
    // Captura o SQL executado
    List<String> executedQueries = new ArrayList<>();
    // (usando p6spy ou StatementInspector do Hibernate)

    mockMvc.perform(get("/api/products"))
        .andExpect(status().isOk());

    // Verifica que a query de listagem não seleciona 'description' ou 'spec_json'
    assertThat(executedQueries)
        .noneMatch(sql -> sql.contains("description") || sql.contains("spec_json"));
}

StatementInspector do Hibernate para testes

java

// Configuração do inspector em testes
public class QueryInspector implements StatementInspector {

    private final List<String> statements = new ArrayList<>();

    @Override
    public String inspect(String sql) {
        statements.add(sql);
        return sql;
    }

    public List<String> getStatements() {
        return Collections.unmodifiableList(statements);
    }
}

java

@Test
void listagemDeveUsarProjecao() {
    QueryInspector inspector = new QueryInspector();
    // Injeta o inspector via configuração de teste

    productService.findProductSummaries();

    String query = inspector.getStatements().get(0);

    // Deve selecionar apenas as colunas necessárias
    assertThat(query).contains("name", "price", "active");
    assertThat(query).doesNotContain("description", "spec_json", "thumbnail");
}

Over-fetching em APIs REST: o Problema de N Clientes

Em APIs REST consumidas por múltiplos clientes (web, mobile, parceiros), o over-fetching tem uma dimensão adicional: clientes diferentes precisam de subconjuntos diferentes dos dados.

O endpoint GET /products/{id} pode retornar 30 campos. O app mobile usa 8. O painel administrativo usa 25. A integração com parceiro usa 5 específicos. Cada cliente recebe 22-25 campos que não precisa.

Abordagens para esse cenário:

Endpoints específicos por caso de uso: GET /products/{id}/summary para listagem, GET /products/{id} para detalhe completo. Simples, explícito, sem overhead de abstração.

Field selection via query param: GET /products?fields=id,name,price. Requer implementação de projeção dinâmica, mas flexível.

GraphQL: o cliente especifica exatamente os campos que quer. Elimina over-fetching por design — mas introduz complexidade de infraestrutura e o próprio N+1 no resolver (resolvido com DataLoader).

Para a maioria dos sistemas, endpoints específicos por caso de uso são o trade-off correto: explícitos, testáveis, sem magia.


Quantificando o Ganho

O impacto do over-fetching é proporcional ao tamanho das colunas desnecessárias e ao volume de requests. Não segue a curva dramática do missing index, mas é um custo contínuo que escala linearmente com o tráfego.

CenárioSELECT * por requestProjeção por requestRedução de dados
Listagem de produtos (c/ thumbnail)~215KB~200KB~7% (thumbnail domina)
Listagem de produtos (sem thumbnail)~15KB~0.5KB~97%
Listagem de usuários (c/ avatar)~50KB~0.1KB~99.8%
Relatório de pedidos (c/ notes)~8KB~0.3KB~96%

Para o caso de listagem sem colunas binárias grandes, a redução é próxima de 97%. Em 1.000 requests por segundo, isso é a diferença entre 15MB/s e 0.5MB/s de dados saindo do banco — uma ordem de grandeza de redução em banda de rede interna, pressão no buffer pool do banco, e alocações na heap da JVM.


Armadilhas

DTO anêmico que cresce sem controle

O padrão de criar um ProductDTO para cada caso de uso rapidamente degenera em uma proliferação de classes. ProductListDTO, ProductDetailDTO, ProductAdminDTO, ProductReportDTO — cada uma com ligeiras variações. Com 50 entidades, isso vira 200 DTOs.

A solução não é voltar para SELECT * — é ser pragmático sobre granularidade. DTOs por domínio funcional (ProductSummary, ProductDetail) em vez de por endpoint. Interface projections eliminam a proliferação de classes concretas para casos simples.

Projeção que acessa a entidade

Interface projections com @Value ou métodos default que acessam a entidade completa anulam o benefício:

java

public interface ProductSummary {
    Long getId();
    String getName();

    // CUIDADO: carrega a entidade completa para computar isso
    @Value("#{target.description.substring(0, 100)}")
    String getDescriptionPreview();
}

Se você precisa de computação sobre campos não projetados, faça no construtor do DTO ou em um método de serviço após a projeção.

@Transactional(readOnly = true) não resolve over-fetching

Uma otimização real — readOnly = true desabilita dirty checking e flush automático — mas não impede que as colunas sejam buscadas do banco. Você ainda traz todos os dados; só economiza o processamento do dirty checking no flush.

java

@Transactional(readOnly = true) // Bom, mas não resolve over-fetching
public List<Product> findAllProducts() {
    return productRepository.findAll(); // Ainda SELECT *
}

@Transactional(readOnly = true) // Melhor: readOnly + projeção
public List<ProductSummary> findProductSummaries() {
    return productRepository.findProjectedBy();
}

Use ambos juntos: projeção para não trazer colunas desnecessárias, readOnly = true para não fazer dirty checking no que foi trazido.


Conclusão

Over-fetching de colunas é um problema de acumulação: individualmente, trazer 10KB a mais por query parece insignificante. Em 1.000 requests por segundo, são 10MB/s de dados desnecessários fluindo do banco para a aplicação, sendo desserializados, alocados na heap e descartados pelo GC.

A causa raiz é a conveniência do ORM — findAll() e SELECT p FROM Product p são tão simples que se tornam o caminho padrão, mesmo quando há colunas grandes na entidade. A solução é tornar a projeção o caminho padrão para operações de leitura: DTO com constructor expression ou interface projection para endpoints de listagem, entidade completa reservada para operações que precisam escrever de volta.

O investimento é pequeno — um DTO por caso de uso de leitura — e o retorno é proporcional ao volume de tráfego. Em sistemas com colunas grandes (textos, BLOBs, JSON), pode ser a diferença entre uma aplicação que aguenta o crescimento e uma que começa a engasgar exatamente quando mais importa.


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 *