Arquitetura
Ledger: consistência e rastreabilidade sem sobrescrever o estado
Como registros cronológicos, imutáveis e auditáveis ajudam a manter consistência em sistemas distribuídos.
By Vinícius Amélio
O problema
Se você já trabalhou com sistemas que exigem integridade e consistência críticas, ou já fez alguma etapa técnica para um banco digital, por exemplo, provavelmente já se deparou com o risco de duplicação em operações de escrita numa base de dados.
Em aplicações pequenas, nas quais possuímos um único banco de dados e uma única instância de uma API, o uso de algo óbvio, como uma transaction no banco ou um mutex em algum recurso de memória, pode resolver o cenário. Mas o que acontece quando a escala aumenta e temos múltiplas instâncias, bases de dados distribuídas com sharding e um bombardeio de requisições simultâneas?
Soluções que causam algum tipo de lock deixam rapidamente de ser viáveis por causa do aumento no tempo de resposta e na latência. E agora?
A solução fora do desenvolvimento de software
Perceba que utilizei explicitamente o exemplo do banco digital no tópico anterior. Na realidade, qualquer sistema que dependa criticamente de consistência sofre desse mesmo problema. Controle de estoque, sistemas de pontos, logística e sistemas de saúde são alguns exemplos. Todos dependem bastante de um histórico rastreável e auditável.
Em finanças, possuímos o conceito de livro-razão, um controle de movimentações que permite auditoria. Um sistema de logística possui o histórico de movimentação de produtos. Um sistema de saúde precisa armazenar alterações em prontuários.
Se esses exemplos lembram algo como logs, você está, de certa forma, no caminho correto.
Ledger
Ledger é um conceito de registro de eventos ou movimentações que normalmente:
- É cronológico
- É imutável ou permite somente operações append-only
- É auditável
- É capaz de reconstruir o estado atual a partir das movimentações anteriores
Esse conceito pode ser especialmente útil quando precisamos responder perguntas como:
- Como chegamos a este estado?
- Quem realizou esta alteração?
- Precisamos comprovar essas movimentações em uma auditoria?
A principal mudança de pensamento ao utilizarmos esse conceito está na forma como lidamos com o estado. Numa aplicação convencional, você faria uma sobreposição do estado com algo como um UPDATE num banco de dados SQL.
Com Ledger, você registra as alterações que produziram o estado atual.
Eventuais problemas de performance
É natural se questionar sobre os possíveis problemas de performance de uma abordagem em que o estado atual é reconstruído a partir de todas as movimentações anteriores. Na prática, essa reconstrução não precisa ser feita sempre que você consulta o seu saldo, por exemplo.
Algumas abordagens que podemos utilizar para evitar leituras enormes durante a reconstrução do estado são:
Projeções
Projeções são muito comuns em sistemas que utilizam Event Sourcing e/ou CQRS. O fluxo pode ser representado por algo como:
Comando
↓
Ledger
↓
Evento publicado
↓
Consumers atualizam projeções
↓
API consulta projeçõesNesse contexto, possuímos projeções diferentes para cada tipo de consulta, como saldo da conta, movimentações do cliente e análise de fraude. Nenhuma delas precisa reconstruir o estado durante a consulta, pois são atualizadas por eventos sempre que ocorrem alterações no Ledger.
Snapshots
Se, por algum motivo, você realmente precisa reconstruir o estado durante a consulta, pode utilizar snapshots para armazenar estados intermediários periódicos do Ledger e acelerar essa reconstrução.
Imagine, por exemplo, um agregado com 100 mil eventos. Em vez de processar todos eles, você armazena:
Snapshot no evento 95.000:
saldo = 12.500
status = ACTIVENa próxima reconstrução, você lê o snapshot do evento 95.000 e processa somente os eventos 95.001 até 100.000.
Um snapshot pode conter dados como aggregate_id, state, created_at e version. Conceitualmente, a reconstrução ficaria assim:
const snapshot = await snapshotRepository.findLatest(accountId);
const state = snapshot
? deserialize(snapshot.state)
: Account.initialState();
const events = await ledger.findAfter(
accountId,
snapshot?.lastEventPosition ?? 0,
);
return events.reduce(applyEvent, state);Snapshots são particularmente úteis quando o agregado possui muitos eventos ou quando a lógica de reconstrução é complexa.
É importante ressaltar que não é necessário utilizar um intervalo fixo de eventos para gerar um snapshot. Você também pode usar um intervalo de tempo ou a versão do agregado como critério.