Você tem uma tabela de lançamentos. O usuário quer ver o saldo. Duas opções:
Somar os lançamentos toda vez que alguém perguntar.
Manter uma coluna com o saldo e atualizar a cada lançamento.
A segunda parece obviamente melhor. É a que eu evito.
Por que a coluna de saldo dá errado
O problema não é performance. É que ela cria uma segunda fonte de verdade.
A partir do momento em que existe account.balance e existe sum(ledger_entry.amount), existe a possibilidade de os dois discordarem. E eles vão discordar, porque basta um caminho de código gravar lançamento sem atualizar a coluna, ou uma transação falhar entre as duas escritas.
Quando divergirem, você vai precisar decidir qual está certo. E aí vem a parte ruim: se você já mostrou o saldo errado pro cliente, se já autorizou uma transferência com base nele, a correção não é técnica. É operacional, com gente envolvida.
O saldo derivado da soma não tem esse problema. Ele não pode estar errado em relação aos lançamentos, porque ele é os lançamentos.
Somar é mais rápido do que parece
A intuição de que somar é caro vem de imaginar uma varredura completa. Não é o que acontece.
create index on ledger_entry (account_id, id) include (amount_minor);
select coalesce(sum(amount_minor), 0)
from ledger_entry
where account_id = $1;
Com esse índice, o Postgres faz um index only scan na faixa daquela conta. Numa conta com dez mil lançamentos, isso é sub-milissegundo. Com cem mil, poucos milissegundos.
A conta que importa: quantos lançamentos uma conta tem, não quantos a tabela tem. Uma tabela com quinhentos milhões de linhas onde nenhuma conta passa de cinquenta mil lançamentos soma rápido.
Comece assim. Meça. Complique só quando o número justificar.
Quando o volume justifica
Existe o caso onde não dá: conta com milhões de lançamentos, ou consulta de saldo em toda requisição de um sistema de alto tráfego.
A saída é snapshot, que é diferente de coluna de saldo em um detalhe que muda tudo:
create table account_balance_snapshot (
account_id uuid not null,
up_to_entry_id bigint not null,
balance_minor bigint not null,
created_at timestamptz not null default now(),
primary key (account_id, up_to_entry_id)
);
O saldo corrente é o último snapshot mais o que veio depois:
with snap as (
select balance_minor, up_to_entry_id
from account_balance_snapshot
where account_id = $1
order by up_to_entry_id desc
limit 1
)
select coalesce(snap.balance_minor, 0) + coalesce(sum(e.amount_minor), 0)
from snap
full outer join ledger_entry e
on e.account_id = $1 and e.id > snap.up_to_entry_id
group by snap.balance_minor;
A diferença conceitual: o snapshot é reconstruível a partir dos lançamentos, a qualquer momento, sem perda. A coluna de saldo não é, porque ela foi escrita por um caminho que talvez não exista mais.
Se o snapshot divergir da soma completa, o snapshot está errado e você regenera. Essa hierarquia precisa estar clara na cabeça de todo mundo, senão alguém vai “corrigir” os lançamentos pra bater com o snapshot, que é o pior desfecho possível.
A frequência do snapshot
A regra prática: um snapshot a cada N lançamentos por conta, com N entre mil e dez mil dependendo do custo aceitável de leitura.
Gerar por tempo (diário, mensal) é pior, porque conta movimentada e conta parada recebem o mesmo tratamento. Uma acumula lançamentos demais entre snapshots, a outra gera snapshot repetido sem necessidade.
E gere de forma assíncrona, num job. Snapshot dentro da transação de escrita reintroduz a contenção que você estava evitando.
O caso do saldo insuficiente
Aqui a discussão muda, porque não é sobre leitura: é sobre decisão.
Duas transferências simultâneas de R$ 80 numa conta com R$ 100. As duas somam, as duas veem 100, as duas aprovam.
Somar mais rápido não resolve. Resolve serializar a decisão, e existem três formas.
Lock na linha da conta com SELECT ... FOR UPDATE antes de calcular. Simples e correto. Serializa as operações daquela conta, o que é aceitável na maioria dos produtos porque conta individual raramente tem concorrência alta.
Constraint de saldo não negativo, o que exige saldo materializado e traz de volta o problema anterior.
Aceitar negativo e reconciliar, que é o que cartão de crédito faz porque a autorização acontece offline. Nesse modelo o limite é política de risco, não garantia técnica.
O erro é não escolher. Sistema sem decisão explícita aqui descobre a resposta em produção, com uma conta negativa e uma reunião.
A regra que eu sigo
Lançamento é verdade. Saldo é opinião derivada.
Toda vez que essa hierarquia inverte, o sistema fica um pouco mais difícil de auditar e um pouco mais fácil de corromper em silêncio. E a diferença entre um ledger em que se confia e um em que não se confia é justamente essa.
Escrevi sobre a mecânica completa em como funciona um core ledger por dentro.