| | |

Evitando atualizações desnecessárias no DynamoDB com Conditional Writes, timestamp e versionamento incremental

Quem trabalha com pipelines de ingestão já passou por isso: o sistema recebe um evento, processa os dados e atualiza o DynamoDB. Pouco tempo depois, o mesmo evento chega novamente e uma nova tentativa de escrita é executada.

O problema é que, na maioria das vezes, nada mudou.

Em ambientes com baixo volume, isso costuma passar despercebido. Mas quando falamos de milhares ou milhões de eventos por dia, essas atualizações redundantes podem gerar um efeito cascata:

  • consumo desnecessário de capacidade;
  • novos registros no DynamoDB Streams;
  • execuções adicionais de funções Lambda;
  • atualizações em índices secundários;
  • incremento de versões sem necessidade;
  • aumento de tráfego entre os componentes do pipeline.

Neste artigo vou mostrar uma estratégia simples que venho utilizando para reduzir esse problema usando:

  • UpdateItem;
  • ConditionExpression;
  • hash de conteúdo;
  • timestamp do evento;
  • versionamento incremental.

A ideia é atualizar um item apenas quando houver uma mudança real no conteúdo e quando o evento recebido for mais recente do que o registro já armazenado.

Mas existe uma pegadinha importante que muita gente esquece: uma escrita condicional rejeitada continua consumindo capacidade no DynamoDB.


O que queremos resolver?

Imagine que sua aplicação receba os seguintes eventos:

EventoCategoriaPlataforma
1RPGPC
2RPGPC
3RPGPC

Do ponto de vista do negócio, apenas o primeiro evento traz uma informação nova.

Os demais representam exatamente o mesmo estado.

Mesmo assim, sem nenhum mecanismo de controle, todos podem gerar escritas no DynamoDB.

O objetivo da abordagem apresentada aqui é impedir que atualizações sem mudanças reais sejam aplicadas.


Antes de continuar: uma condição rejeitada consome capacidade?

Sim.

Quando uma ConditionExpression retorna false, o DynamoDB não altera o item. Porém a tentativa de escrita continua consumindo capacidade.

Esse detalhe é importante porque costuma gerar uma interpretação incorreta:

Evitar 50% das atualizações não significa economizar automaticamente 50% dos WRUs.

Em itens pequenos, principalmente abaixo de 1 KB, uma condição rejeitada pode consumir praticamente a mesma quantidade de unidades consumidas por uma atualização aceita.

Mesmo assim, a técnica continua trazendo benefícios importantes porque evita:

  • alterações desnecessárias;
  • eventos no Streams;
  • atualizações de índices;
  • processamento downstream;
  • mudanças de versão sem necessidade.

Como o DynamoDB calcula a capacidade de escrita?

O DynamoDB cobra operações de escrita em blocos de 1 KB.

Tamanho do itemWRUs
Até 1 KB1
Até 2 KB2
Até 3 KB3
10 KB10
50 KB50
100 KB100

O arredondamento sempre acontece para cima.

Por exemplo:

  • 1024 bytes → 1 WRU
  • 1025 bytes → 2 WRUs

Além disso, o cálculo considera o item inteiro, incluindo:

  • nomes dos atributos;
  • chaves;
  • hashes;
  • timestamps;
  • versão;
  • demais campos armazenados.

Por que usar UpdateItem?

Tanto PutItem quanto UpdateItem permitem utilizar ConditionExpression.

A diferença é que o comportamento não é o mesmo.

O PutItem substitui completamente o item existente.

Já o UpdateItem permite atualizar apenas os atributos necessários.

Neste cenário, o UpdateItem costuma ser a melhor escolha porque permite:

  • atualizar campos específicos;
  • validar condições antes da alteração;
  • controlar versões;
  • impedir alterações redundantes.

Quando a condição não é atendida, o DynamoDB retorna ConditionalCheckFailedException

Na prática, essa exceção significa apenas que nenhuma modificação foi aplicada.


Usando hash para identificar mudanças reais

Uma forma simples de descobrir se algo mudou é gerar um hash baseado nos campos que representam o estado do registro.

Neste exemplo:

var conteudo = String.join(
        "|",
        Objects.toString(entity.getCategoria(), ""),
        Objects.toString(entity.getPlataforma(), "")
);

var hash = UUID.nameUUIDFromBytes(
        conteudo.getBytes(StandardCharsets.UTF_8)
).toString();

entity.setHashControle(hash);

O valor gerado é armazenado no atributo hash_controle.

Pense nele como uma impressão digital do conteúdo.

Sempre que um novo evento chegar:

  1. o hash é recalculado;
  2. o valor é comparado com o hash armazenado;
  3. se forem iguais, o conteúdo não mudou.

Qual algoritmo usar?

O algoritmo utilizado para gerar o hash não é o ponto mais importante da solução.

O que realmente importa é que a mesma entrada produza sempre o mesmo resultado. Dessa forma, conseguimos identificar quando o conteúdo mudou ou permaneceu igual.

Algumas opções comuns são:

  • SHA-256;
  • SHA-512;
  • MD5;
  • SHA-1;
  • UUID determinístico;
  • qualquer função determinística que gere o mesmo valor para o mesmo conteúdo.

Pessoalmente, gosto bastante de utilizar UUID determinístico para esse tipo de controle.

Além de ser simples de implementar, ele gera um identificador compacto, legível e suficiente para cenários em que o objetivo não é segurança criptográfica, mas apenas detectar alterações no conteúdo.

Exemplo:

var conteudo = String.join(
        "|",
        Objects.toString(entity.getCategoria(), ""),
        Objects.toString(entity.getPlataforma(), "")
);

var hash = UUID.nameUUIDFromBytes(
        conteudo.getBytes(StandardCharsets.UTF_8)
).toString();

entity.setHashControle(hash);

Mais importante do que o algoritmo é escolher corretamente quais campos participam do cálculo.

Se um campo importante ficar de fora, o sistema pode interpretar uma alteração real como duplicata.


A entidade utilizada no exemplo

Além dos dados do jogo, a entidade possui alguns atributos de controle:

@DynamoDbBean
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class GameEntity implements Serializable {

    private static final long serialVersionUID = 3215647896541L;

    @Getter(onMethod_ = @__({@DynamoDbIgnoreNulls, @DynamoDbPartitionKey, @DynamoDbAttribute(value = "cod_ident_game")}))
    private String identificadorUnicoGame;
    @Getter(onMethod_ = @__({@DynamoDbIgnoreNulls, @DynamoDbSortKey, @DynamoDbAttribute(value = "status")}))
    private String state;
    @Getter(onMethod_ = @__({@DynamoDbIgnoreNulls, @DynamoDbAttribute(value = "nome")}))
    private String nome;
    @Getter(onMethod_ = @__({@DynamoDbIgnoreNulls, @DynamoDbAttribute(value = "categoria")}))
    private String categoria;
    @Getter(onMethod_ = @__({@DynamoDbIgnoreNulls, @DynamoDbAttribute(value = "plataforma")}))
    private String plataforma;
    @Getter(onMethod_ = @__({@DynamoDbIgnoreNulls, @DynamoDbAttribute(value = "data_processamento")}))
    private LocalDateTime timestamp;
    @Getter(onMethod_ = @__({@DynamoDbIgnoreNulls, @DynamoDbAttribute(value = "hash_controle")}))
    private String hashControle;
    @Getter(onMethod_ = @__({@DynamoDbAtomicCounter, @DynamoDbAttribute(value = "version")}))
    private Long version;

}

Os campos mais importantes são:

CampoFinalidade
hash_controleDetectar mudanças reais
data_processamentoComparar ordem dos eventos
versionContabilizar atualizações

Montando a expressão condicional

A solução é exigir que o conteúdo tenha mudado e que o evento seja mais recente.


            Expression conditionExpression = Expression.builder()
                    .expression("attribute_not_exists(hash_controle) OR (hash_controle <> :novoHash) OR (attribute_not_exists(data_processamento) OR data_processamento < :novoTimestamp)")
                    .expressionValues(Map.of(
                            ":novoHash", AttributeValue.builder().s(entity.getHashControle()).build(),
                            ":novoTimestamp", AttributeValue.builder().s(entity.getTimestamp().toString()).build()
                    ))
                    .build();

Aplicando a condição no UpdateItem

            UpdateItemEnhancedRequest<GameEntity> request = UpdateItemEnhancedRequest.builder(GameEntity.class)
                    .item(entity)
                    .conditionExpression(conditionExpression)
                    .build();

            gameTable.updateItem(request);

Com essa lógica, o DynamoDB atualiza o item quando:

  • ele ainda não existe;
  • o conteúdo mudou;
  • o evento recebido é mais novo.

E rejeita a atualização quando:

  • o conteúdo continua igual;
  • o evento é mais antigo;
  • um evento fora de ordem tenta sobrescrever um estado mais recente.

Como tratar ConditionalCheckFailedException

Nem toda exceção representa um erro.

Muitas vezes ela apenas indica que:

  • o evento era duplicado;
  • o conteúdo não mudou;
  • uma mensagem antiga chegou depois da mais recente.

Por isso, geralmente faz mais sentido registrar métricas do que gerar logs de erro.

try {

    gameTable.updateItem(request);

    metricas.registrarAtualizacaoAplicada();

} catch (ConditionalCheckFailedException exception) {

    metricas.registrarAtualizacaoIgnorada();
}

Quais métricas acompanhar?

Algumas métricas úteis:

  • atualizações tentadas;
  • atualizações aplicadas;
  • atualizações rejeitadas;
  • eventos antigos;
  • duplicatas;
  • taxa de rejeição;
  • capacidade consumida;
  • tempo de processamento.

Esses indicadores ajudam a entender quanto trabalho realmente está sendo evitado.


Timestamp do evento ou timestamp de processamento?

Essa diferença costuma gerar muitos problemas.

Considere a sequência abaixo:

EventoPlataformaHorário real
APC10:00
BPS510:05
A reprocessadoPC10:00

Se usarmos apenas o horário de processamento, o terceiro evento pode parecer mais recente.

Mas ele representa um estado antigo.

Sempre que possível, utilize o timestamp gerado pelo sistema de origem:

private Instant dataEvento;

E armazene em UTC:

2026-08-03T10:30:45.123Z

Assim você reduz problemas de ordenação e fuso horário.


Versionamento incremental

O campo anotado com:

@DynamoDbAtomicCounter

funciona como um contador de alterações.

Cada atualização aceita incrementa automaticamente esse valor.

Isso ajuda a:

  • acompanhar a evolução do registro;
  • entender quantas alterações foram persistidas;
  • facilitar auditorias;
  • investigar problemas;
  • correlacionar eventos downstream.

Mas vale lembrar:

Um contador de versão não é a mesma coisa que optimistic locking.

Para bloqueio otimista seria necessário validar explicitamente a versão esperada.


Existe economia de WRUs?

Depende.

Se os itens possuem até 1 KB, uma condição rejeitada normalmente consumirá a mesma unidade de escrita de uma atualização aceita.

Nesse cenário, a economia direta pode ser zero.

O benefício estará principalmente em evitar:

  • atualizações;
  • eventos downstream;
  • processamento adicional.

Já em itens maiores, a situação muda.

Imagine:

SituaçãoItem atualItem novo
Aceita5 KB8 KB
Rejeitada5 KB8 KB

Uma atualização aceita consumirá aproximadamente 8 WRUs.

Uma condição rejeitada consumirá algo próximo de 5 WRUs.

Nesse caso já existe uma redução de consumo.


E os índices secundários?

Quando a tabela possui GSIs, uma atualização pode disparar trabalho adicional:

  • atualização do item principal;
  • remoção da entrada anterior;
  • criação da nova entrada;
  • atualização dos atributos projetados.

Quando a condição falha, nada disso acontece.

Dependendo da quantidade e do tamanho dos índices, o ganho total pode ser maior do que o observado apenas na tabela principal.


E o DynamoDB Streams?

Uma atualização bem-sucedida gera um novo evento no Streams.

Esse evento pode acionar:

  • Lambdas;
  • consumidores;
  • filas;
  • integrações;
  • indexadores;
  • pipelines de auditoria.

Quando a atualização é rejeitada, nenhum novo registro é gerado.

Por isso, muitas vezes o maior benefício está fora da tabela principal.


Conditional Writes evitam throttling?

Não necessariamente.

Como a tentativa continua consumindo capacidade, um volume muito alto de operações rejeitadas ainda pode atingir os limites provisionados.

O benefício real é outro:

Conditional Writes evitam que atualizações desnecessárias sejam aplicadas.

Se o objetivo for eliminar completamente operações duplicadas, você precisará agir antes da escrita.


Como evitar a tentativa de escrita?

Algumas alternativas:

  • deduplicação na origem;
  • identificadores idempotentes;
  • cache local;
  • Redis ou ElastiCache;
  • compactação de eventos;
  • controle de estado no consumidor;
  • leitura prévia antes da escrita.

Cada estratégia possui seus próprios custos e trade-offs.

Mesmo assim, a condição no DynamoDB continua sendo uma excelente proteção final contra inconsistências.


Quando essa abordagem vale a pena?

Essa estratégia costuma fazer sentido quando:

  • existem eventos duplicados;
  • mensagens chegam fora de ordem;
  • há reprocessamentos frequentes;
  • o Streams dispara outras integrações;
  • existem GSIs;
  • os itens crescem ao longo do tempo;
  • múltiplos processos atualizam a mesma chave;
  • é importante evitar regressão de dados.

Considerações finais

Conditional Writes são uma ótima ferramenta para controlar quando uma atualização realmente deve acontecer.

Combinando:

  • hash para detectar mudanças;
  • timestamp para garantir ordenação;
  • versionamento para rastreabilidade;

é possível construir um fluxo muito mais seguro e previsível.

O ponto mais importante é entender que impedir uma atualização não significa eliminar o consumo da operação.

Mesmo assim, essa abordagem pode reduzir significativamente o trabalho desnecessário em todo o ecossistema ao redor do DynamoDB, incluindo Streams, Lambdas, GSIs e outros consumidores.

No fim, a melhor forma de avaliar o ganho é medir seu próprio pipeline.

Observe o volume de duplicatas, o tamanho dos itens, a quantidade de índices e o impacto downstream. Muitas vezes o maior benefício não está nos WRUs economizados, mas em tudo que deixa de acontecer depois da escrita.


Repositório

O código utilizado nos exemplos está disponível em:

👉 Projeto spring-dynamo-expression

Relatório dos testes realizados localmente em docker

O código utilizado nos exemplos está disponível em:

👉 Relatório de conditional writes

Posts Similares

Deixe um comentário

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