Dirty Checking Overhead: O Custo Invisível de Gerenciar Entidades no Hibernate
O Hibernate oferece uma conveniência poderosa: você modifica um objeto Java dentro de uma transação, e as mudanças são persistidas automaticamente no banco no momento do flush, sem precisar chamar save() explicitamente. Isso funciona porque o Hibernate mantém um snapshot do estado original de cada entidade carregada e, no flush, compara o estado atual com o snapshot para detectar o que mudou.
Esse mecanismo se chama dirty checking. Ele é elegante, funciona corretamente, e tem um custo que escala de forma não óbvia com o número de entidades na sessão.
Como o Dirty Checking Funciona
Quando o Hibernate carrega uma entidade, ele armazena dois registros:
- A entidade gerenciada — o objeto Java que você usa no código
- O snapshot — uma cópia do estado original dos campos, armazenada em memória
No momento do flush (antes do commit, ou quando explicitamente chamado), o Hibernate percorre todas as entidades gerenciadas na sessão e compara campo por campo com o snapshot. Para cada entidade onde algum campo mudou, ele gera e executa um UPDATE.
java
@Transactional
public void updateProductDescription(Long id, String newDescription) {
Product product = productRepository.findById(id).orElseThrow();
// Hibernate armazena snapshot: {id: 1, name: "Widget", description: "Old desc", price: 9.99, ...}
product.setDescription(newDescription);
// Nenhuma query ainda — apenas o objeto Java foi modificado
} // Flush automático antes do commit:
// Hibernate compara produto atual com snapshot
// Detecta que 'description' mudou
// Gera: UPDATE product SET name=?, description=?, price=?, ... WHERE id=?
O problema não está no mecanismo em si — está no que acontece quando a sessão acumula muitas entidades.
O Problema de Escala
O dirty checking é O(N × M) onde N é o número de entidades na sessão e M é o número de campos por entidade. Cada flush percorre todas as entidades e compara todos os campos de cada uma.
java
@Transactional
public void generateMonthlyReport(int month, int year) {
// Carrega 5.000 pedidos para o relatório
List<Order> orders = orderRepository.findByMonthAndYear(month, year);
// Hibernate cria 5.000 snapshots — cada Order tem 15 campos
// = 75.000 valores armazenados em memória como snapshots
// Processa os dados para o relatório — leitura pura, sem modificar nada
ReportData report = orders.stream()
.map(order -> new ReportLine(order.getId(), order.getTotal()))
.collect(ReportData.collector());
// No flush implícito antes do commit:
// Hibernate compara 5.000 entidades × 15 campos = 75.000 comparações
// Para constatar que nenhuma mudou.
// Trabalho desnecessário: 75.000 comparações para 0 updates.
}
Com 5.000 entidades de 15 campos cada, o flush verifica 75.000 pares de valores — para gerar zero queries. Em entidades com campos byte[] ou String grandes, a comparação envolve equals() em objetos pesados.
Esse overhead não aparece no slow query log. Não é uma query lenta — é CPU e memória gastos antes de qualquer query ser emitida.
Cenários Problemáticos
Cenário 1: Leitura de grandes conjuntos de dados com @Transactional
java
@Transactional // Transação desnecessária para leitura pura
public DashboardData buildDashboard(Long userId) {
List<Order> recentOrders = orderRepository.findRecentByUser(userId, 200);
// 200 entidades × campos = snapshot desnecessário
List<Invoice> invoices = invoiceRepository.findByUser(userId);
// Mais entidades, mais snapshots
List<Product> purchasedProducts = productRepository.findPurchasedByUser(userId);
// Mais entidades, mais snapshots
// Tudo isso é leitura pura — não há nenhuma escrita
// O flush no commit vai comparar centenas de entidades para constatar que nada mudou
return DashboardData.from(recentOrders, invoices, purchasedProducts);
}
Cenário 2: Processamento em lote sem limpeza da sessão
java
@Transactional
public void sendMonthlyNewsletters(int month) {
List<User> users = userRepository.findActiveSubscribers(); // potencialmente milhares
for (User user : users) {
String content = templateEngine.render(user);
emailService.send(user.getEmail(), content);
user.setLastNewsletterSent(LocalDate.now()); // modifica a entidade
userRepository.save(user);
// Problema: todas as entidades anteriores ainda estão na sessão
// A sessão cresce indefinidamente durante o loop
// O flush periódico fica cada vez mais lento
}
}
Após processar 1.000 usuários, o flush compara 1.000 entidades — sendo que apenas a última foi modificada. Com 10.000 usuários, cada flush compara 10.000 entidades.
Cenário 3: Joins que trazem entidades desnecessárias para o contexto
java
@Transactional
public List<OrderSummaryDTO> getOrderSummaries(Long customerId) {
// Esta query faz JOIN e carrega entidades completas
// mesmo que o DTO use apenas 3 campos de cada
List<Order> orders = orderRepository.findByCustomerId(customerId);
return orders.stream()
.map(order -> new OrderSummaryDTO(
order.getId(),
order.getTotal(),
order.getStatus()
))
.collect(toList());
// Ao fazer flush: Hibernate compara todos os campos de cada Order
// incluindo os campos que o DTO nunca usou
}
Detectando o Overhead
Hibernate Statistics
properties
spring.jpa.properties.hibernate.generate_statistics=true
logging.level.org.hibernate.stat=DEBUG
Procure pelas seguintes métricas no log:
Session Metrics {
2003607 nanoseconds spent acquiring 1 JDBC connections;
1171372 nanoseconds spent releasing 1 JDBC connections;
13479 nanoseconds spent preparing 1 JDBC statements;
47394 nanoseconds spent executing 1 JDBC statements;
189,447,231 nanoseconds spent performing 5000 flushes; // <- problema
0 nanoseconds spent executing 0 JDBC statements;
}
189ms gastos em flushes com 0 JDBC statements executados é a assinatura do dirty checking overhead: tempo gasto comparando entidades que não mudaram.
Profiling com async-profiler
Para identificar exatamente onde o tempo está sendo gasto:
bash
# Attach ao processo Java em execução
./profiler.sh -d 30 -e cpu -f flamegraph.html <pid>
No flamegraph, procure por frames como:
org.hibernate.engine.internal.StatefulPersistenceContext.dirtyorg.hibernate.event.internal.DefaultFlushEntityEventListener.isUpdateNecessaryorg.hibernate.type.ComponentType.isDirty
Se esses frames dominam uma fração significativa do CPU, o dirty checking é o gargalo.
Teste de tempo por tamanho de sessão
java
@Test
void dirtyCheckingScaleTest() {
// Mede o tempo de flush para diferentes tamanhos de sessão
for (int size : List.of(10, 100, 500, 1000, 5000)) {
long start = System.nanoTime();
transactionTemplate.execute(status -> {
// Carrega N entidades (leitura pura, sem modificar)
productRepository.findAll(PageRequest.of(0, size));
// Força flush para medir o overhead
entityManager.flush();
return null;
});
long elapsed = System.nanoTime() - start;
System.out.printf("Size: %d | Flush time: %dms%n",
size, TimeUnit.NANOSECONDS.toMillis(elapsed));
}
}
Saída típica:
Size: 10 | Flush time: 2ms
Size: 100 | Flush time: 8ms
Size: 500 | Flush time: 35ms
Size: 1000 | Flush time: 68ms
Size: 5000 | Flush time: 312ms
O crescimento linear confirma O(N): 5.000 entidades custam ~155x mais que 10 entidades.
As Soluções
1. @Transactional(readOnly = true) para operações de leitura
A solução mais simples e de maior impacto para leitura pura. Com readOnly = true, o Hibernate não cria snapshots e não executa dirty checking no flush. As entidades são carregadas em modo read-only: mais rápidas de carregar, sem overhead de flush.
java
// Antes: cria snapshots, faz dirty checking
@Transactional
public DashboardData buildDashboard(Long userId) {
List<Order> orders = orderRepository.findRecentByUser(userId, 200);
// ...
}
// Depois: sem snapshots, sem dirty checking
@Transactional(readOnly = true)
public DashboardData buildDashboard(Long userId) {
List<Order> orders = orderRepository.findRecentByUser(userId, 200);
// Entidades carregadas sem snapshot — flush não as verifica
// ...
}
No Spring Data JPA, marque os métodos de leitura no repositório também:
java
public interface OrderRepository extends JpaRepository<Order, Long> {
// Sem anotação: herda do JpaRepository (não é readOnly por padrão)
List<Order> findByCustomerId(Long customerId);
// Com readOnly explícito: sem dirty checking, pode usar réplica de leitura
@Transactional(readOnly = true)
@Query("SELECT o FROM Order o WHERE o.customerId = :id")
List<Order> findByCustomerIdReadOnly(@Param("id") Long id);
}
O ganho secundário: alguns datasources roteiam transações readOnly para réplicas de leitura automaticamente — reduzindo carga no banco primário.
2. Projeções DTO em vez de entidades gerenciadas
Projeções DTO — discutidas no artigo sobre over-fetching — também eliminam o dirty checking porque DTOs não são entidades gerenciadas. O Hibernate não cria snapshot, não rastreia mudanças, não executa dirty checking.
java
// Entidade gerenciada: snapshot + dirty checking
@Transactional(readOnly = true) // Ainda melhor com readOnly
public List<Order> findOrders(Long customerId) {
return orderRepository.findByCustomerId(customerId);
}
// DTO: sem gerenciamento, sem snapshot, sem dirty checking
// (readOnly ou não, não há overhead de dirty checking)
public List<OrderSummaryDTO> findOrderSummaries(Long customerId) {
return orderRepository.findSummariesByCustomerId(customerId);
}
Para operações de leitura pesada, a combinação readOnly = true + projeção DTO é redundante mas inofensiva — e torna a intenção explícita.
3. StatelessSession para processamento em lote
Para jobs de processamento em lote que precisam de escrita mas não de dirty checking automático, o Hibernate oferece StatelessSession — uma sessão sem first-level cache e sem dirty checking:
java
@Service
public class BulkPriceUpdateService {
@PersistenceUnit
private EntityManagerFactory entityManagerFactory;
public void updatePrices(Map<Long, BigDecimal> priceMap) {
SessionFactory sessionFactory = entityManagerFactory
.unwrap(SessionFactory.class);
// StatelessSession: sem first-level cache, sem dirty checking
try (StatelessSession session = sessionFactory.openStatelessSession()) {
Transaction tx = session.beginTransaction();
try {
int count = 0;
for (Map.Entry<Long, BigDecimal> entry : priceMap.entrySet()) {
Product product = session.get(Product.class, entry.getKey());
if (product != null) {
product.setPrice(entry.getValue());
session.update(product); // UPDATE explícito — sem dirty checking
}
// Flush e clear periódico para controlar memória
if (++count % 100 == 0) {
tx.commit();
tx = session.beginTransaction();
}
}
tx.commit();
} catch (Exception e) {
tx.rollback();
throw e;
}
}
}
}
StatelessSession remove todas as mágicas do Hibernate — dirty checking, lazy loading, first-level cache, cascading. É JDBC com mapeamento de tipos. Para jobs de batch onde você controla explicitamente cada operação, é a ferramenta certa.
4. Flush e Clear explícitos em processamento em lote com Session padrão
Quando StatelessSession não é viável — por exemplo, quando você precisa de cascading ou de lifecycle callbacks — gerencie o tamanho da sessão manualmente:
java
@Transactional
public void processLargeDataset(List<Long> productIds) {
int batchSize = 50;
int count = 0;
for (Long productId : productIds) {
Product product = entityManager.find(Product.class, productId);
product.setUpdatedAt(LocalDateTime.now());
// Sem save() explícito — dirty checking vai detectar
if (++count % batchSize == 0) {
entityManager.flush(); // Executa os UPDATEs acumulados
entityManager.clear(); // Remove TODAS as entidades da sessão
// Agora a sessão está vazia — próximo flush é O(batchSize), não O(count)
}
}
// Flush final para os restantes
entityManager.flush();
}
entityManager.clear() remove todas as entidades do first-level cache. Depois do clear, a sessão está vazia — o próximo flush só precisa checar as entidades carregadas após o clear. Sem o clear, a sessão cresce indefinidamente e cada flush é O(total de entidades carregadas desde o início da transação).
Atenção: após entityManager.clear(), entidades carregadas antes do clear se tornam detached. Acessar relações lazy nelas lança LazyInitializationException. Recarregue entidades necessárias com entityManager.merge() ou entityManager.find().
5. @DynamicUpdate para reduzir o custo do UPDATE gerado
Mesmo quando o dirty checking detecta mudanças corretamente, o UPDATE padrão do Hibernate inclui todas as colunas — não apenas as que mudaram. Com @DynamicUpdate, o Hibernate gera um UPDATE com apenas as colunas que efetivamente mudaram:
java
@Entity
@DynamicUpdate // UPDATE inclui apenas colunas modificadas
public class Product {
private Long id;
private String name;
private BigDecimal price;
private String description; // 5KB de texto
private byte[] thumbnail; // 200KB de imagem
// ...
}
sql
-- Sem @DynamicUpdate: todas as colunas, incluindo description e thumbnail
UPDATE product
SET name=?, price=?, description=?, thumbnail=?, ...
WHERE id=?
-- Com @DynamicUpdate: apenas o que mudou
UPDATE product
SET price=?
WHERE id=?
Para entidades com colunas grandes, o impacto na banda de rede e no I/O do banco é significativo. A desvantagem é que @DynamicUpdate desabilita o cache de prepared statements para o UPDATE dessa entidade.
6. FlushModeType para controlar quando o flush ocorre
Por padrão, o Hibernate faz flush automaticamente antes de queries e antes do commit. Você pode mudar esse comportamento:
java
// FlushMode.COMMIT: flush apenas antes do commit, não antes de queries
// Útil quando queries dentro da transação não precisam ver mudanças pendentes
entityManager.setFlushMode(FlushModeType.COMMIT);
// FlushMode.MANUAL: flush apenas quando explicitamente chamado
// Máximo controle, mas requer disciplina: esquecer de chamar flush = dados não persistidos
entityManager.setFlushMode(FlushModeType.AUTO); // padrão
Para sessões de leitura intensiva com escritas pontuais, FlushModeType.COMMIT reduz flushes intermediários desnecessários. Use com cuidado: queries dentro da transação não verão as mudanças pendentes até o commit.
Combinando as Soluções
Na prática, as soluções se combinam naturalmente:
Para endpoints de leitura:
java
@Transactional(readOnly = true) // sem snapshots, sem dirty checking
public List<ProductSummaryDTO> listProducts(ProductFilter filter) {
return productRepository.findSummaries(filter); // DTO: sem gerenciamento de entidade
}
Para escritas simples:
java
@Transactional // dirty checking ativo — mas sessão tem apenas 1 entidade
public Product updatePrice(Long id, BigDecimal newPrice) {
Product product = productRepository.findById(id).orElseThrow();
product.setPrice(newPrice);
return product; // flush detecta 1 entidade modificada — custo negligenciável
}
Para processamento em lote:
java
public void processBatch(List<Long> ids) {
// StatelessSession: sem dirty checking, controle explícito
try (StatelessSession session = sessionFactory.openStatelessSession()) {
// ... processamento com update() explícito
}
}
Para leitura com escrita eventual:
java
@Transactional
public void processAndAudit(Long orderId) {
// Leitura da entidade que será modificada
Order order = orderRepository.findById(orderId).orElseThrow();
// Leitura de dados auxiliares como DTO: sem snapshot, sem gerenciamento
List<OrderItemSummary> items = itemRepository.findSummariesByOrderId(orderId);
CustomerProfile profile = customerRepository.findProfileById(order.getCustomerId());
// Processa
AuditResult result = auditEngine.evaluate(order, items, profile);
// Modifica apenas a entidade que precisa ser atualizada
order.setAuditStatus(result.getStatus());
// Dirty checking detecta apenas 'order' — não os DTOs
}
O Que o @Transactional(readOnly = true) Realmente Faz
Vale detalhar o que muda com readOnly = true no nível do Hibernate e do banco, porque é a solução mais impactante e mais subutilizada:
No Hibernate:
- Entidades são carregadas sem snapshot
- Flush automático é desabilitado (nenhum dirty checking no commit)
- First-level cache ainda funciona (evita queries duplicadas dentro da sessão)
- Writes lançam exceção se tentadas
No driver JDBC:
connection.setReadOnly(true)é chamado- Alguns drivers otimizam o cursor de leitura
No banco de dados:
- PostgreSQL e MySQL podem evitar overhead de controle de concorrência em reads
- Com datasource configurado para routing: conexão pode ser direcionada para réplica de leitura
O que NÃO muda:
- Ainda é uma transação real no banco
- Ainda abre e fecha uma conexão do pool
- Ainda respeita o nível de isolamento configurado
Conclusão
Dirty checking é um dos mecanismos mais elegantes do Hibernate — e um dos mais fáceis de abusar por omissão. Adicionar @Transactional em um método de serviço que faz leitura pura de 1.000 entidades não parece errado. O método funciona. Os dados chegam corretos. Mas o Hibernate silenciosamente cria 1.000 snapshots, compara 1.000 × N campos no flush, e descarta todo esse trabalho porque nada mudou.
O antídoto é tornar a intenção explícita: @Transactional(readOnly = true) para leitura, projeções DTO quando não precisar de entidades gerenciadas, flush + clear em intervalos regulares em processamento de lote, e StatelessSession quando você quer JDBC com mapeamento de tipos sem nenhuma das mágicas do ORM.
A regra simples: se o método não escreve no banco, não deveria ter o custo de dirty checking. readOnly = true é a forma de deixar isso explícito — e o Hibernate elimina o overhead correspondente.
Próximo da série: Pagination com OFFSET Alto — por que OFFSET 10000 LIMIT 20 é mais lento do que parece, e como cursor-based pagination resolve o problema.