Home Comunidade Eventos YouTube Networks Desafios // produtos Academy News CTI | CVE Contato

04 - Snapshot (Otimização)

#agregado#otimizacao#replay#snapshot
video explicativo
Video explicativoEspaco reservado para o video desta anotação — adicione 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