Projetando uma carteira digital: transferências seguras com dupla entrada
1. Visão geral
Transferir dinheiro entre duas carteiras parece simples: debita uma, credita outra. Quando há milhares de requisições simultâneas, retries de cliente, rede instável e requisitos de auditoria, atualizar a coluna balance vai rapidamente levar a inconsistências.
Este artigo passa por designing uma carteira digital baseada em razão de dupla entrada (double-entry ledger). O design inclui requisitos, modelo de dados, API, fluxo de processamento e depois os pontos difíceis: contenção de locks, consistência com outros sistemas e picos de tráfego. Depois de ler, você consegue construir um serviço de transferência usando Spring Boot 3 e PostgreSQL. Este serviço não perde dinheiro, não debita duas vezes e é auditável.
2. Requisitos do problema
2.1. Funcionalidade
- Cada usuário tem uma ou mais carteiras por tipo de moeda (VND).
- Depositar, sacar, transferir entre carteiras.
- Visualizar saldo e histórico de transações.
- Uma transação nunca é gravada duas vezes, mesmo que o cliente reenvie o pedido.
2.2. Escala e requisitos não-funcionais
- 10 milhões de carteiras, média de 500 transações/segundo. Pico de 5.000 transações/segundo durante flash sales ou dia de pagamento.
- Latência p99 abaixo de 300 ms para API de transferência.
- O saldo deve ser fortemente consistente. Ler um saldo stale para decidir se a transferência é permitida é inaceitável.
2.3. Restrições
- Nunca permitir saldo negativo, exceto para contas de sistema que estão autorizadas.
- Toda mudança de saldo deve ser rastreável para um lançamento. Sem edição, sem exclusão de lançamentos. Para corrigir um erro, grave uma entrada reversa.
- Armazene dinheiro como inteiros na menor unidade (centavos). Nunca use
double.
3. O que é razão de dupla entrada
Toda transação consiste de pelo menos dois lançamentos (entries). A soma dos lançamentos de uma transação sempre é zero. Transferir 100.000 VND de An para Bình cria dois lançamentos:
transaction T1
account An : -100000
account Binh : +100000
----------------------
sum : 0
Depositar segue a mesma regra. O dinheiro “vem de algum lugar” é uma conta do sistema, como BANK_CLEARING, e essa conta pode ficar negativa. Assim temos um invariante muito forte: a soma de todos os lançamentos no sistema sempre é zero. Se a soma diferir de zero, definitivamente há um bug.
4. Modelo de dados
CREATE TABLE account (
id BIGINT PRIMARY KEY,
owner_id BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
balance BIGINT NOT NULL DEFAULT 0,
allow_negative BOOLEAN NOT NULL DEFAULT FALSE,
CHECK (allow_negative OR balance >= 0)
);
CREATE TABLE journal_transaction (
id UUID PRIMARY KEY,
idempotency_key VARCHAR(64) NOT NULL UNIQUE,
type VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE ledger_entry (
id BIGSERIAL PRIMARY KEY,
transaction_id UUID NOT NULL REFERENCES journal_transaction(id),
account_id BIGINT NOT NULL REFERENCES account(id),
amount BIGINT NOT NULL,
balance_after BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_entry_account ON ledger_entry(account_id, id);
CREATE TABLE outbox_event (
id BIGSERIAL PRIMARY KEY,
topic VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE
);
As tabelas acima têm os seguintes papéis:
ledger_entryé a fonte da verdade. A tabela é apenas append-only.amountnegativo é débito, positivo é crédito.account.balanceé um cache da soma dos lançamentos. Mantemos essa coluna para verificar o saldo rapidamente sem fazerSUMde milhões de linhas.- A restrição
CHECKé a rede de segurança final. Se o código tiver um bug, o banco ainda rejeita saldo negativo. idempotency_key UNIQUEprevine duplicação de transações.outbox_eventé usado para enviar eventos para Kafka (seção 7.2).
5. API
POST /v1/transfers
Idempotency-Key: 6f1c2a9e-...-client-generated
Content-Type: application/json
{ "fromAccountId": 1001, "toAccountId": 2002, "amount": 100000 }
201 Created
{ "transactionId": "a3f0...", "status": "COMPLETED" }
O cliente gera seu próprio Idempotency-Key para cada intenção de transferência e reutiliza exatamente essa chave ao fazer retry. O servidor deve retornar o mesmo resultado para a mesma chave.
O controller é bem simples:
public record TransferRequest(@NotNull Long fromAccountId,
@NotNull Long toAccountId,
@Positive long amount) {}
public record TransferResult(UUID transactionId, String status) {}
@RestController
@RequestMapping("/v1/transfers")
class TransferController {
private final TransferService service;
TransferController(TransferService service) { this.service = service; }
@PostMapping
ResponseEntity<TransferResult> create(@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody TransferRequest req) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.transfer(key, req));
}
}
6. Fluxo de processamento de transferência
Usamos spring-boot-starter-jdbc para ver claramente cada instrução SQL:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
Todo o fluxo é executado dentro de uma única transação do banco de dados:
@Service
public class TransferService {
private final JdbcTemplate jdbc;
public TransferService(JdbcTemplate jdbc) { this.jdbc = jdbc; }
@Transactional
public TransferResult transfer(String key, TransferRequest req) {
if (req.fromAccountId().equals(req.toAccountId())) {
throw new IllegalArgumentException("Não pode transferir para si mesmo");
}
UUID txId = UUID.randomUUID();
int inserted = jdbc.update("""
INSERT INTO journal_transaction(id, idempotency_key, type)
VALUES (?, ?, 'TRANSFER') ON CONFLICT (idempotency_key) DO NOTHING
""", txId, key);
if (inserted == 0) { // (1)
UUID existing = jdbc.queryForObject(
"SELECT id FROM journal_transaction WHERE idempotency_key = ?", UUID.class, key);
return new TransferResult(existing, "COMPLETED");
}
long fromBalance = lockAndGet(req.fromAccountId(), req.toAccountId()); // (2)
if (fromBalance < req.amount()) {
throw new InsufficientFundsException(req.fromAccountId()); // (3)
}
post(txId, req.fromAccountId(), -req.amount()); // (4)
post(txId, req.toAccountId(), req.amount());
jdbc.update("INSERT INTO outbox_event(topic, payload) VALUES ('wallet.transfers', ?::jsonb)",
"""
{"transactionId":"%s","from":%d,"to":%d,"amount":%d}
""".formatted(txId, req.fromAccountId(), req.toAccountId(), req.amount()));
return new TransferResult(txId, "COMPLETED");
}
private long lockAndGet(long fromId, long toId) {
long fromBalance = 0;
for (long id : new long[]{Math.min(fromId, toId), Math.max(fromId, toId)}) {
long balance = jdbc.queryForObject(
"SELECT balance FROM account WHERE id = ? FOR UPDATE", Long.class, id);
if (id == fromId) fromBalance = balance;
}
return fromBalance;
}
private void post(UUID txId, long accountId, long amount) {
Long after = jdbc.queryForObject(
"UPDATE account SET balance = balance + ? WHERE id = ? RETURNING balance",
Long.class, amount, accountId);
jdbc.update("""
INSERT INTO ledger_entry(transaction_id, account_id, amount, balance_after)
VALUES (?, ?, ?, ?)""", txId, accountId, amount, after);
}
}
class InsufficientFundsException extends RuntimeException {
InsufficientFundsException(long accountId) { super("Saldo insuficiente: " + accountId); }
}
Os pontos-chave no código acima:
- (1) Idempotência.
ON CONFLICT DO NOTHINGretorna 0 linhas se a chave já existe. Suponha dois pedidos com a mesma chave em paralelo. PostgreSQL faz o segundo pedido esperar no índice único até o primeiro commit. Então o segundo pedido recebe 0 linhas e retorna a transação antiga. Sem segunda debitação. - (2) Locking em ordem de ID. Thread A transfere 1→2 e thread B transfere 2→1. Se cada thread bloquear a conta de origem primeiro, ambos vão segurar um lock e esperar pelo outro, causando deadlock. Bloqueando sempre o ID menor primeiro, todas as threads pedem locks na mesma ordem, sem ciclos de espera.
- (3) Verificar saldo após bloquear. Verificar antes de bloquear é o clássico erro check-then-act. Dois pedidos leem “saldo suficiente” e ambos debitam.
- (4) Dois lançamentos e outbox estão na mesma transação. Se qualquer passo falhar, tudo é feito rollback, incluindo a linha
journal_transaction. Então o cliente pode fazer retry quando receber “saldo insuficiente”.
Nota: Não use
@Transactionalem métodosprivateou chamadas do mesmo objeto (self-invocation). O proxy do Spring não envolverá a transação e todo o mecanismo de lock falha.
7. Os pontos difíceis
7.1. Contenção em contas quentes (hot accounts)
A carteira de um grande comerciante pode receber milhares de pagamentos por segundo. Toda transação fica na fila esperando FOR UPDATE em uma única linha, então o throughput é limitado a poucas centenas de transações/segundo.
A solução comum é dividir a conta quente em N sub-contas. Ao creditar, escolha aleatoriamente uma sub-conta. O saldo real do comerciante é a soma das sub-contas:
long creditTarget(long merchantAccountId, int shards) {
int shard = ThreadLocalRandom.current().nextInt(shards);
return jdbc.queryForObject(
"SELECT id FROM account WHERE parent_id = ? AND shard_no = ?",
Long.class, merchantAccountId, shard);
}
Com 16 shards, a contenção diminui cerca de 16 vezes. Isso funciona bem para créditos, já que adicionar dinheiro não requer verificação de saldo. Débitos, como saques de comerciante, acontecem muito menos frequentemente. Você pode consolidar dinheiro de volta para a conta principal com um job periódico, e esse job também é uma transação de dupla entrada.
Não faça sharding de carteiras pessoais comuns. Carteiras pessoais raramente sofrem contenção, e sharding torna a verificação de saldo muito mais complexa.
7.2. Consistência com outros sistemas: Transactional Outbox
Após uma transferência, serviços de notificação, fraude e pontos precisam saber. Se você chamar kafkaTemplate.send() dentro de transfer(), você pode sofrer um de dois problemas:
- Kafka recebe a mensagem mas DB faz rollback, então o usuário recebe notificação de uma transação que não existe.
- DB faz commit mas enviar para Kafka falha, então o evento é perdido.
Na seção 6, gravamos o evento em outbox_event na mesma transação. Um relay separado lerá e enviará essa tabela para Kafka:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
@Component
class OutboxRelay {
private final JdbcTemplate jdbc;
private final KafkaTemplate<String, String> kafka;
OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) {
this.jdbc = jdbc;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 200)
@Transactional
public void publish() {
var rows = jdbc.queryForList("""
SELECT id, topic, payload::text AS payload FROM outbox_event
WHERE published = FALSE ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED""");
for (var row : rows) {
kafka.send((String) row.get("topic"), (String) row.get("payload")).join();
jdbc.update("UPDATE outbox_event SET published = TRUE WHERE id = ?", row.get("id"));
}
}
}
Lembre-se de habilitar @EnableScheduling em uma classe de configuração para que @Scheduled funcione. SKIP LOCKED permite que múltiplas instâncias de relay rodem em paralelo sem pegar linhas duplicadas.
Esse mecanismo garante at-least-once. Se o relay morre após enviar mas antes de fazer commit, a mensagem será reenviada. Então o consumer deve deduplicar por transactionId.
7.3. Picos de tráfego
Quando o volume de requisições salta 10x, a primeira coisa que falha é o connection pool do banco, não CPU. Há três camadas de defesa:
- Rate limit por usuário no edge, usando Redis.
- Limite de connection pool (HikariCP) para não sobrecarregar o DB, com timeout curto para fail fast.
- Adie tarefas não-críticas como notificações e pontos para Kafka. Isso já está coberto pelo outbox.
Exemplo de rate limit de janela fixa de 10 requisições/segundo por usuário, usando spring-boot-starter-data-redis:
@Component
class TransferRateLimiter {
private final StringRedisTemplate redis;
TransferRateLimiter(StringRedisTemplate redis) { this.redis = redis; }
boolean allow(long userId) {
String key = "rl:transfer:%d:%d".formatted(userId, Instant.now().getEpochSecond());
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) redis.expire(key, Duration.ofSeconds(2));
return count != null && count <= 10;
}
}
spring.datasource.hikari.maximum-pool-size=40
spring.datasource.hikari.connection-timeout=500
spring.jpa.open-in-view=false
Quando allow() retorna false, retorne 429 Too Many Requests. Quando não conseguir uma conexão em 500 ms, retorne 503. O cliente tem Idempotency-Key então retry é seguro.
Nota: Não use Redis para armazenar saldos ou “debitar primeiro e sincronizar com o DB depois”. Redis é rápido mas não oferece transações ACID junto com o ledger. Um failover perderia dinheiro.
8. Testando comportamento concorrente
O teste mais importante é disparar muitas requisições em paralelo contra a mesma carteira. Carteira A tem 50.000 VND e recebe 100 comandos de transferência de 1.000 VND. O resultado correto é exatamente 50 comandos bem-sucedidos, saldo chega a zero e soma dos lançamentos é zero. O teste abaixo requer um PostgreSQL real, por exemplo via Testcontainers, e dados inicializados em @Sql.
@SpringBootTest
@Sql(statements = {
"TRUNCATE ledger_entry, journal_transaction, outbox_event, account",
"INSERT INTO account(id, owner_id, currency, balance) VALUES (1, 1, 'VND', 50000), (2, 2, 'VND', 0)"
})
class TransferServiceConcurrencyTest {
@Autowired TransferService service;
@Autowired JdbcTemplate jdbc;
@Test
void neverOverdraftsUnderConcurrency() throws Exception {
AtomicInteger success = new AtomicInteger();
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
String key = "k-" + i;
pool.submit(() -> {
try {
service.transfer(key, new TransferRequest(1L, 2L, 1_000));
success.incrementAndGet();
} catch (InsufficientFundsException ignored) { }
});
}
}
assertThat(success.get()).isEqualTo(50);
assertThat(jdbc.queryForObject("SELECT balance FROM account WHERE id = 1", Long.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COALESCE(SUM(amount),0) FROM ledger_entry", Long.class)).isZero();
}
@Test
void sameIdempotencyKeyReturnsSameTransaction() {
var req = new TransferRequest(1L, 2L, 1_000);
var first = service.transfer("dup", req);
var second = service.transfer("dup", req);
assertThat(second.transactionId()).isEqualTo(first.transactionId());
assertThat(jdbc.queryForObject("SELECT balance FROM account WHERE id = 1", Long.class))
.isEqualTo(49_000L);
}
}
Se remover FOR UPDATE em lockAndGet, o primeiro teste falhará aleatoriamente. Às vezes success fica maior que 50, às vezes a restrição CHECK lança erro. Sem ON CONFLICT, o segundo teste debitaria 2.000 VND.
Além dos testes, execute um job de reconciliação todas as noites. Esse job verifica duas coisas: soma dos lançamentos é zero e account.balance iguala a soma de ledger_entry de cada conta:
SELECT a.id, a.balance, COALESCE(SUM(e.amount), 0) AS ledger_sum
FROM account a LEFT JOIN ledger_entry e ON e.account_id = a.id
GROUP BY a.id, a.balance
HAVING a.balance <> COALESCE(SUM(e.amount), 0);
Consulta retornando 0 linhas significa que os livros batem.
9. Erros comuns e quando não usar isso
- Usar
double/floatpara dinheiro.0.1 + 0.2não dá0.3. Uselongna menor unidade, ouBigDecimalse precisar de taxas. - Atualizar
balancediretamente sem lançamentos. Isso funciona rápido no começo, mas quando o cliente reclama você não consegue explicar “para onde foi o dinheiro”. - Editar ou deletar lançamentos errados. Grave um lançamento reverso como uma nova transação.
- Locks fora de ordem levam a deadlock sob carga. PostgreSQL quebra o deadlock matando uma transação, e o usuário vê erros aleatórios.
- Chamar APIs externas enquanto segura locks, como gateway de pagamento bancário. Locks são segurados por segundos e throughput colapsa. Separe fluxos de banco em state machines (
PENDING→COMPLETED/FAILED) com contas de hold. - Não use esse design para dados não-monetários, como contadores de views. Lá, pequenas inconsistências são aceitáveis e razão de dupla entrada é apenas overhead.
10. Conclusão
O cerne de uma carteira digital segura não está em tecnologia nova mas em alguns princípios rigorosos:
- Razão de dupla entrada append-only, com soma dos lançamentos sempre zero.
- Idempotency key para todo comando de escrita.
- Locks pessimistas em ordem fixa e verificação de saldo após bloquear.
- Restrição
CHECKcomo rede de segurança final.
Em cima disso, tratamos contas quentes com sub-contas, sincronizamos com outros sistemas com Transactional Outbox e Kafka, e lidamos com picos com rate limit Redis e connection pool limitado.
Conforme você escala, pode separar fluxos de banco em state machines com contas de hold. Você também pode particionar ledger_entry por tempo quando a tabela crescer para bilhões de linhas. Não importa como você escala, mantenha testes concorrentes e jobs de reconciliação rodando.