A conta da plataforma tem uma exigência que o contador não tem: débitos, como repasses e estornos, só podem acontecer se houver saldo. Fatiar o saldo obrigaria cada débito a travar todas as fatias. A alternativa que resolve a escrita e ainda entrega histórico para auditoria é parar de atualizar o saldo a cada venda. Cada crédito vira um lançamento inserido, que nunca disputa linha com outro, e um processo consolidador aplica os lançamentos pendentes no saldo em lotes.
-- Saldo consolidado: uma linha por conta, tocada so pelo consolidador e por debitos.
CREATE TABLE conta_saldo (
conta_id bigint PRIMARY KEY,
saldo_centavos bigint NOT NULL DEFAULT 0,
consolidado_em timestamptz NOT NULL DEFAULT now()
);
-- Historico permanente e append-only: auditoria e conciliacao leem daqui.
CREATE TABLE lancamento (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
conta_id bigint NOT NULL,
valor_centavos bigint NOT NULL,
referencia text NOT NULL,
criado_em timestamptz NOT NULL DEFAULT now(),
UNIQUE (conta_id, referencia)
);
-- O que ainda nao entrou no saldo consolidado. Cada linha vive poucos segundos.
CREATE TABLE lancamento_pendente (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
lancamento_id bigint NOT NULL,
conta_id bigint NOT NULL,
valor_centavos bigint NOT NULL
);
CREATE INDEX lancamento_pendente_conta ON lancamento_pendente (conta_id, id);
-- Tabela pequena com muita insercao e exclusao: vacuum por volume absoluto.
ALTER TABLE lancamento_pendente SET (
autovacuum_vacuum_scale_factor = 0,
autovacuum_vacuum_threshold = 5000
);
-- Saldo atual = consolidado + pendentes, lidos no mesmo snapshot.
SELECT s.saldo_centavos
+ coalesce((SELECT sum(p.valor_centavos)
FROM lancamento_pendente p
WHERE p.conta_id = s.conta_id), 0) AS saldo_centavos
FROM conta_saldo s
WHERE s.conta_id = $1;
-- Consolidacao em lotes: apaga pendentes e soma no saldo na mesma transacao.
WITH lote AS (
DELETE FROM lancamento_pendente
WHERE id IN (
SELECT id
FROM lancamento_pendente
WHERE conta_id = $1
ORDER BY id
LIMIT 5000
FOR UPDATE SKIP LOCKED
)
RETURNING valor_centavos
), soma AS (
SELECT count(*) AS linhas, coalesce(sum(valor_centavos), 0) AS total
FROM lote
)
UPDATE conta_saldo s
SET saldo_centavos = s.saldo_centavos + soma.total,
consolidado_em = now()
FROM soma
WHERE s.conta_id = $1
AND soma.linhas > 0
RETURNING soma.linhas;
Dois detalhes sustentam a correção. Primeiro, o saldo atual é o consolidado mais a soma dos pendentes, lidos em um único comando: os dois valores vêm do mesmo snapshot, e como o consolidador apaga pendentes e soma no saldo na mesma transação, a leitura nunca conta um lançamento duas vezes nem deixa de contá-lo. Segundo, os pendentes ficam em uma tabela separada e são apagados ao consolidar. A versão que guarda só o último id consolidado parece mais simples e perde dinheiro: ids de identidade são distribuídos na inserção, não no commit, então um lançamento com id menor pode ficar visível depois que o consolidador já passou por ele.
const LOTE = 5000;
const LANCAR_SQL = [
'WITH novo AS (',
' INSERT INTO lancamento (conta_id, valor_centavos, referencia)',
' VALUES ($1, $2, $3)',
' ON CONFLICT (conta_id, referencia) DO NOTHING',
' RETURNING id, conta_id, valor_centavos',
')',
'INSERT INTO lancamento_pendente (lancamento_id, conta_id, valor_centavos)',
'SELECT id, conta_id, valor_centavos FROM novo',
].join('\n');
const SALDO_SQL = [
'SELECT s.saldo_centavos + coalesce((',
' SELECT sum(p.valor_centavos) FROM lancamento_pendente p',
' WHERE p.conta_id = s.conta_id), 0) AS saldo_centavos',
' FROM conta_saldo s WHERE s.conta_id = $1',
].join('\n');
const CONSOLIDAR_SQL = [
'WITH lote AS (',
' DELETE FROM lancamento_pendente WHERE id IN (',
' SELECT id FROM lancamento_pendente WHERE conta_id = $1',
' ORDER BY id LIMIT ' + LOTE + ' FOR UPDATE SKIP LOCKED)',
' RETURNING valor_centavos',
'), soma AS (',
' SELECT count(*) AS linhas, coalesce(sum(valor_centavos), 0) AS total FROM lote',
')',
'UPDATE conta_saldo s SET saldo_centavos = s.saldo_centavos + soma.total,',
' consolidado_em = now()',
' FROM soma WHERE s.conta_id = $1 AND soma.linhas > 0',
'RETURNING soma.linhas',
].join('\n');
export class SaldoInsuficiente extends Error {}
// Credito: so insere. Roda dentro da transacao do pedido e nao disputa nenhuma linha.
// A referencia (ex.: "pedido:8812:comissao") torna a repeticao inofensiva.
export async function creditar(client, contaId, valorCentavos, referencia) {
await client.query(LANCAR_SQL, [contaId, valorCentavos, referencia]);
}
// Debito: raro (repasse, estorno, saque). Trava so a linha consolidada da conta,
// e o saldo e lido em um comando seguinte, com snapshot novo, depois do lock.
export async function debitar(pool, contaId, valorCentavos, referencia) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('SELECT 1 FROM conta_saldo WHERE conta_id = $1 FOR UPDATE', [contaId]);
const { rows } = await client.query(SALDO_SQL, [contaId]);
if (Number(rows[0].saldo_centavos) < valorCentavos) {
throw new SaldoInsuficiente('saldo insuficiente na conta ' + contaId);
}
await client.query(LANCAR_SQL, [contaId, -valorCentavos, referencia]);
await client.query('COMMIT');
} catch (erro) {
await client.query('ROLLBACK').catch(() => {});
throw erro;
} finally {
client.release();
}
}
// Aplica os pendentes em lotes; cada lote e uma transacao curta e independente.
export async function consolidar(pool, contaId) {
let aplicadas = 0;
for (;;) {
const { rows } = await pool.query(CONSOLIDAR_SQL, [contaId]);
const linhas = rows.length ? Number(rows[0].linhas) : 0;
aplicadas += linhas;
if (linhas < LOTE) return aplicadas;
}
}
export function iniciarConsolidador(pool, contaId, intervaloMs = 500) {
let parado = false;
const ciclo = async () => {
if (parado) return;
try {
await consolidar(pool, contaId);
} catch (erro) {
console.error('consolidacao da conta ' + contaId + ' falhou', erro);
}
setTimeout(ciclo, intervaloMs);
};
ciclo();
return () => {
parado = true;
};
}
O débito é o único caminho que ainda trava uma linha, e é raro: algumas dezenas por hora contra 1.600 créditos por segundo. Ele trava a linha consolidada, lê o saldo em um comando seguinte, que no READ COMMITTED enxerga tudo o que foi confirmado até ali, e só então insere o lançamento negativo. Dois débitos simultâneos ficam em fila entre si, que é o comportamento desejado. Créditos continuam entrando sem esperar, e como só aumentam o saldo, não invalidam a verificação de quem está debitando.
Checkout (1.600 por segundo) Repasse / estorno (poucos por hora)
| |
v v
INSERT pedidos, itens, pagamentos SELECT ... FROM conta_saldo FOR UPDATE
| |
v v
INSERT lancamento + lancamento_pendente saldo = consolidado + soma dos pendentes
(sem disputa: cada pedido cria a sua linha) |
| suficiente? -- não --> recusa
v | sim
upsert em metrica_fatia (1 de 16 fatias) v
| INSERT lançamento negativo
v |
COMMIT COMMIT
Consolidador, a cada 500 ms:
DELETE até 5.000 pendentes + UPDATE conta_saldo, na mesma transação