04 - Snapshot (Otimização)
video: https://youtu.be/... no frontmatter da nota.Snapshot: Otimização de Carga de Agregados
Snapshot é uma fotografia do estado de um agregado em uma versão específica do stream, gravada para acelerar a reidratação. Em vez de aplicar milhares de eventos a cada comando, o sistema carrega o snapshot e aplica apenas os eventos posteriores a ele. A ideia é uma otimização de leitura pura: o snapshot nunca substitui o log, nunca altera o comportamento do sistema e pode ser apagado sem risco, porque tudo que ele guarda pode ser reconstruído por replay. Em produção, a decisão de usar snapshots não é estética, é de custo: streams longos transformam comandos simples em leituras caras, e a fotografia periódica é a resposta padrão para esse degrau de latência.
O problema do stream longo
O custo de um comando cresce com o tamanho do stream do agregado. Uma conta movimentada a cada hora acumula dezenas de milhares de eventos em poucos anos, e cada operação de saque ou crédito relê toda essa história para chegar ao saldo atual. A reidratação é linear, e o pior caso aparece exatamente nas entidades mais valiosas, que são as mais movimentadas.
O efeito em produção é característico: latência p95 dos comandos sobe devagar durante anos, os grafos mostram pico de leitura no store correlacionado com os agregados mais antigos, e nenhum deploy resolve, porque o problema é estrutural. Adicionar réplica ajuda no throughput do store, mas não remove o trabalho de aplicar cem mil eventos por comando. O time precisa decidir entre reduzir o que é lido, com snapshot, ou reformatar o agregado, decisão raramente viável em sistemas maduros.
Mecânica do snapshot
O funcionamento segue três passos. Na gravação, após acrescentar eventos ao stream, o sistema decide se vale a pena fotografar o estado, tipicamente a cada N eventos. Na carga, o sistema busca o snapshot mais recente, restaura o estado a partir dele e aplica apenas os eventos com sequência maior que a versão fotografada. Na leitura de consultas, nada muda, porque consultas já deveriam ir para projeções.
// Carga otimizada: snapshot mais eventos posteriores
public Conta carregar(String contaId) {
Snapshot snap = snapshotStore.buscarMaisRecente("conta-" + contaId);
Conta conta = (snap != null)
? Conta.doSnapshot(snap) // estado congelado na versão N
: new Conta(contaId); // agregado sem foto ainda
long versaoInicial = (snap != null) ? snap.versao() : 0;
List<EventoEnvolto> restantes = eventStore.lerStreamAPartirDe(
"conta-" + contaId, versaoInicial + 1); // só o que veio depois da foto
restantes.forEach(e -> conta.aplicar(upcasters.converter(e)));
return conta;
}
// Gravação: eventos primeiro, snapshot depois
public void salvar(Conta conta) {
long novaVersao = eventStore.acrescentar(
"conta-" + conta.id(), conta.versaoEsperada(), conta.novosEventos());
long desdeUltimaFoto = novaVersao - conta.versaoDoUltimoSnapshot();
if (desdeUltimaFoto >= INTERVALO_SNAPSHOT) { // por exemplo, 100 eventos
snapshotStore.gravar(conta.paraSnapshot(novaVersao));
}
}
O intervalo de N eventos é um ajuste de custo, não de correção. Valores pequenos geram mais escritas de snapshot e pouco ganho; valores grandes reduzem escritas e aumentam a cauda de eventos aplicados na carga. Times comuns começam com 50 a 200 eventos por agregado e refinam com medição de latência, porque o tamanho médio dos eventos e a taxa de comandos mudam o ponto ótimo por sistema.
O que vai dentro do snapshot
O conteúdo correto é o estado puro do agregado, e nada além dele. Entram os campos que a função de aplicação mantém, como saldo, status e listas relevantes, junto da versão do stream em que a foto foi tirada. Saem tudo que é derivável por outros meios, tudo que é contexto de infraestrutura e tudo que muda de formato com frequência.
- Estado consolidado das propriedades de negócio, como saldo, status e contadores que a lógica de comando consulta.
- Versão do stream no momento da foto, que é a referência usada na carga para buscar eventos posteriores.
- Identidade do agregado e do tipo, para defesa contra fotos restauradas no stream errado.
- Metadado de versão do formato interno, que permite evoluir a serialização sem quebrar fotos antigas.
Não entram eventos pendentes não gravados, porque o snapshot só é produzido após o append confirmado; não entram dados de outras entidades, porque o snapshot é limitado à fronteira do agregado; e não entram valores computados para exibição, porque isso pertence às projeções. Manter o conteúdo mínimo é o que preserva a propriedade mais valiosa da otimização: qualquer foto pode ser jogada fora e reconstruída por replay sem perda de informação.
{
"streamId": "conta-7c41",
"versao": 4213,
"formato": 2,
"estado": {
"status": "ATIVA",
"saldo": 1520.00,
"limiteDisponivel": 500.00,
"abertaEm": "2021-11-03T09:12:00Z"
}
}
O campo formato controla a versão da serialização e é o que permite evoluir o estado interno sem quebrar fotos antigas. O campo versao é a âncora no stream, e toda a lógica de carga se apoia nele para decidir o que aplicar depois da foto.
Versionamento do formato do snapshot
O snapshot tem um problema que o log não tem: ele é reescrito com o tempo, e seu formato evolui junto com o código. A resposta é versionar a serialização com a mesma disciplina dos eventos. Cada foto grava qual versão do formato usa, e a leitura converte versões antigas para o formato atual em memória, o mesmo mecanismo de upcasting aplicado a um artefato menor e mais barato de converter.
A diferença prática é que o snapshot pode simplesmente ser regenerado. Quando a conversão de uma versão antiga se torna inviável, o time invalida as fotos afetadas e deixa o replay recriá-las no formato novo, com custo de leitura pontual. Essa válvula de escape não existe para eventos, que são imutáveis por definição, e é uma das razões para manter o snapshot como estrutura derivada e descartável, e nunca como segunda fonte de verdade.
Consistência entre snapshot e stream
A regra de ouro é: o snapshot referencia uma versão exata do stream, e a carga usa essa versão para buscar apenas eventos posteriores. Se o snapshot diz versão 4.213, o sistema aplica do evento 4.214 em diante. Isso significa que o snapshot precisa ser gravado somente depois dos eventos correspondentes; um snapshot de uma versão que ainda não existe no stream faz a carga pular eventos e produz estado corrompido.
A boa notícia é que o risco é contido por construção. Se o processo cai depois de gravar os eventos e antes de gravar o snapshot, nada quebra: a próxima carga simplesmente não encontra foto nova e reidrata normalmente, com custo maior naquela uma vez. Snapshots são descartáveis, e essa propriedade é o que torna a otimização segura. Falha na gravação do snapshot deve ser logada e alertada, mas jamais derrubar o comando de negócio, porque a operação principal, que é o append do evento, já foi concluída com sucesso.
A versão do stream dentro do snapshot também é a defesa contra agregados mutáveis entre processos. Dois processos carregando versões diferentes produzem fotos de versões diferentes, e cada carga escolhe sempre a mais recente disponível. Não existe disputa, porque o critério é a versão, e versões são monotônicas por stream.
Invalidação e reconstrução
Snapshots podem precisar ser descartados em duas situações. A primeira é o bug de serialização: o estado fotografado foi escrito errado, por um defeito no formato ou na conversão, e as cargas seguintes partem de um estado inválido. A segunda é a correção de dados: um evento de compensação alterou o passado lógico da entidade, e o time quer garantir que nenhuma carga parta de um estado pré-correção.
Em ambos os casos, o procedimento é o mesmo e é seguro por definição: apagar os snapshots afetados e deixar o replay reconstruir o estado do zero. Nenhum dado é perdido, porque o log é a verdade. A operação tem custo de leitura temporário, e por isso times grandes executam a invalidação em lote, com concorrência limitada, para não derrubar o store. Manter a versão do snapshot gravada junto do estado é o que permite esse direcionamento cirúrgico, invalidando apenas as fotos de entidades afetadas.
Onde armazenar snapshots
| Opção | Vantagens | Desvantagens | Cenário típico |
|---|---|---|---|
| Mesma tabela do store, tipo próprio | Transação única, pouca infraestrutura | Tabela de eventos cresce com fotos; permissões confundem papéis | Implementações no PostgreSQL com volume moderado |
| Tabela ou coleção separada | Isolamento de schema e de permissões, limpeza fácil | Duas gravações sem transação comum; exige ordenação por versão | Padrão mais comum, independente da tecnologia |
| Cache distribuído em memória | Leitura em microssegundos para agregados quentes | Volatilidade; serve de acelerador, não de armazenamento | Complemento à camada persistida para agregados quentes |
| Armazenamento de objetos | Custo baixo para fotos grandes, retenção longa | Latência maior por busca; menos adequado para caminho de comando | Fotos volumosas, agregados com estado extenso |
A escolha mais comum em produção é a tabela separada, porque mantém o store limpo, simplifica permissões e permite políticas de retenção próprias para fotos antigas. O cache em memória aparece como camada adicional em agregados de altíssima taxa, como contas de alto volume, com invalidação por versão no stream. O que não se recomenda é gravar snapshots dentro do stream de eventos, misturando fatos com otimização; consumidores de eventos não devem precisar filtrar fotos.
Quando dispensar snapshots
Nem todo agregado precisa da otimização, e decidir sem necessidade adiciona código e operação à toa. Os casos em que a resposta costuma ser dispensar são claros.
- Streams naturalmente curtos, como cadastros que recebem poucas dezenas de eventos ao longo da vida; a reidratação já é rápida o bastante.
- Entidades imutáveis ou quase, como registros fiscais que só recebem eventos de criação e correção pontual.
- Sistemas com tráfego baixo, onde mesmo um stream de dez mil eventos reidrata em tempo aceitável dentro do orçamento de latência.
- Agregados que já vivem quase inteiramente em projeções, com poucos comandos por segundo e leitura de estado rara.
O cálculo de decisão é direto: meça a latência p95 da reidratação por faixa de tamanho de stream, estime o crescimento e compare com o orçamento de latência do comando. Se o pior agregado previsto para daqui a dois anos reidrata em alguns milissegundos, snapshot é complexidade sem retorno. Se o pior caso estoura o orçamento hoje, a otimização entra com prioridade naquele tipo de agregado, e não necessariamente em todos.
Há ainda a alternativa estrutural ao snapshot: reduzir o próprio agregado. Muitas entidades viram streams gigantes porque carregam tudo em uma única fronteira, quando o domínio permitiria separar histórico de estado corrente, como dividir cartão da conta ou contrato de cliente. Antes de adicionar a otimização, avalie se a fronteira do agregado está certa; snapshot sobre um agregado mal delimitado apenas adia a correção.
Erros comuns e boas práticas
- Tratar o snapshot como parte do modelo de eventos; ele é um artefato de carga interna e não deve aparecer em assinaturas, contratos ou projeções.
- Gravar o snapshot antes dos eventos; a carga pularia eventos ainda não existentes. Eventos primeiro, foto depois, sempre.
- Serializar o estado completo sem controle de versão; o formato do snapshot precisa de versionamento próprio com upcasting, senão a correção de schema quebra todas as fotos antigas.
- Aplicar snapshot em todos os agregados por padrão; a otimização deve seguir medição, não moda.
- Esquecer de limpar fotos órfãs de entidades encerradas; o custo cresce sem valor, e a limpeza vira projeto.
- Confiar no snapshot como se fosse fonte de verdade; quando foto e log divergem, o log vence, e a foto precisa ser invalidada.
- Tirar foto dentro da mesma transação dos eventos em sistemas de alto volume; a escrita extra no caminho crítico degrada o p95 dos comandos, e o risco de perda é nulo, pois a foto pode ser refeita depois.
- Medir o ganho com agregados típicos e decidir com agregados extremos; o pior caso é o que justifica a otimização, não a média.
Práticas que sustentam a otimização: monitore a distribuição de eventos aplicados por carga, que revela o ganho real do snapshot; teste a carga com e sem foto nos testes de integração, garantindo que os dois caminhos produzem o mesmo estado; e mantenha o formato do snapshot mínimo, guardando apenas o necessário para reproduzir o estado, porque fotos gordas custam leitura, escrita e confusão de manutenção. Complemente com alerta de idade máxima das fotos por agregado, já que fotos muito antigas sinalizam cauda de eventos crescendo além do previsto, e com roteiro de regeneração em massa, executável com concorrência limitada quando uma correção de formato exigir refazer tudo.
| Sintoma em produção | Causa provável | Mitigação |
|---|---|---|
| Comandos em contas antigas falham com estado estranho | Snapshot gravado de versão inexistente no stream | Gravar foto só após confirmar a versão no store; invalidar afetadas |
| Cargas lentas mesmo com snapshot | Foto desatualizada e cauda de eventos enorme | Reduzir o intervalo de N e monitorar eventos aplicados por carga |
| Erro de desserialização após deploy novo | Formato de snapshot sem versionamento | Versionar formato, aplicar upcasting e regenerar fotos antigas |
| Store degradado em pico | Gravação de foto em massa competindo com eventos | Limitar concorrência de fotos e mover para armazenamento separado |