Blog

Saldo calculado ou saldo materializado: escolha com consequência.

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.

← Voltar pro blog