|

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:

  1. A entidade gerenciada — o objeto Java que você usa no código
  2. 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.dirty
  • org.hibernate.event.internal.DefaultFlushEntityEventListener.isUpdateNecessary
  • org.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.

Posts Similares

Deixe um comentário

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