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ário | SELECT * por request | Projeção por request | Reduçã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.