Diseño de cartera digital: transferencias seguras con libro de contabilidad de partida doble
1. Resumen
Transferir dinero entre dos carteras suena sencillo: restar de aquí, sumar allá. Cuando hay miles de solicitudes simultáneas, reintentos del cliente, red inestable y auditoría requerida, el método de “actualizar la columna balance” pronto resultará en saldos incorrectos.
Este artículo recorre el diseño de una cartera digital basada en un libro de contabilidad de partida doble (double-entry ledger). El diseño incluye requisitos, modelo de datos, API, flujo de procesamiento, y luego los puntos difíciles: contención de bloqueos, consistencia con otros sistemas y picos de carga. Al terminar, podrás construir un servicio de transferencias con Spring Boot 3 y PostgreSQL. Este servicio no pierde dinero, no efectúa deducciones dobles y es auditable.
2. Requisitos del problema
2.1. Funcionalidades
- Cada usuario tiene una o más carteras por tipo de moneda (VND).
- Depósito, retiro, transferencia entre dos carteras.
- Ver saldo e historial de transacciones.
- Una transacción nunca se registra dos veces, incluso si el cliente reenvía la solicitud.
2.2. Escala y características no funcionales
- 10 millones de carteras, promedio 500 transacciones/segundo. Pico de 5.000 transacciones/segundo durante flash sales o días de pago.
- Latencia p99 menor a 300 ms para la API de transferencias.
- El saldo debe ser consistente fuertemente. Leer un saldo antiguo para decidir si se permite la transferencia no es aceptable.
2.3. Restricciones
- Nunca permitir un saldo negativo, excepto para cuentas del sistema que estén autorizadas.
- Todo cambio de saldo debe rastrearse hasta una entrada. Sin ediciones, sin eliminaciones. Para corregir errores, registra una entrada inversa.
- El dinero se almacena como entero en la unidad más pequeña (đồng). No uses
double.
3. Qué es el libro de contabilidad de partida doble
Cada transacción consta de al menos dos asientos (entries). La suma de los asientos de una transacción siempre es cero. Transferir 100.000đ de An a Bình genera dos asientos:
transaction T1
account An : -100000
account Binh : +100000
----------------------
sum : 0
Los depósitos también siguen esta regla. El dinero “viene de algún lado” y es una cuenta del sistema, por ejemplo BANK_CLEARING, y esta cuenta puede estar en negativo. Gracias a esto tenemos un invariante (invariant) muy fuerte: la suma de todos los asientos en el sistema siempre es cero. Si la suma es diferente de 0, definitivamente hay un bug.
4. Modelo de datos
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
);
Las tablas anteriores tienen los siguientes roles:
ledger_entryes la fuente de verdad. La tabla solo acepta agregaciones (append-only).amountnegativo es débito, positivo es crédito.account.balancees un caché de la suma de los asientos. Mantenemos esta columna para verificar el saldo rápidamente sin necesidad de hacerSUMde millones de filas.- La restricción
CHECKes la última red de seguridad. Si el código tiene un bug, la base de datos rechazará un saldo negativo. idempotency_key UNIQUEpreviene el registro duplicado de transacciones.outbox_eventsirve para enviar eventos a Kafka (sección 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" }
El cliente genera su propio Idempotency-Key para cada intención de transferencia y reutiliza exactamente esa clave en reintentos. El servidor debe devolver el mismo resultado para la misma clave.
El controlador es muy delgado:
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. Flujo de procesamiento de transferencias
Usamos spring-boot-starter-jdbc para ver cada sentencia SQL claramente:
<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>
El flujo completo se ejecuta en una transacción de base de datos:
@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("No se puede transferir a sí mismo");
}
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); }
}
Los puntos clave en el código:
- (1) Idempotencia.
ON CONFLICT DO NOTHINGdevuelve 0 filas si la clave ya existe. Supongamos que dos solicitudes con la misma clave se ejecutan en paralelo. PostgreSQL hace que la segunda solicitud espere en el índice único hasta que la primera haga commit. Después, la segunda solicitud recibe 0 filas y devuelve la transacción anterior. No hay una segunda deducción. - (2) Bloqueo en orden de id. El flujo A transfiere 1→2 y el flujo B transfiere 2→1. Si cada flujo bloquea su cuenta fuente primero, ambos flujos mantendrán un bloqueo y esperarán el otro, es decir, deadlock. Cuando siempre bloqueamos ids menores primero, todos los flujos solicitan bloqueos en el mismo orden, sin ciclos de espera.
- (3) Verificar saldo después de haber adquirido el bloqueo. Verificar antes de bloquear es un error clásico de check-then-act. Dos solicitudes leen “saldo suficiente” y ambas restan.
- (4) Dos asientos y outbox están en la misma transacción. Si cualquier paso falla, todo se revierte, incluida la fila
journal_transaction. Por lo tanto, el cliente puede reintentar cuando se le informa que no hay suficiente dinero.
Nota: No uses
@Transactionalen métodosprivateni los llames desde la misma clase (self-invocation). El proxy de Spring no envolverá la transacción y el mecanismo de bloqueo completo se perderá.
7. Puntos difíciles
7.1. Contención en cuentas activas (hot account)
La cartera de un comerciante grande puede recibir miles de pagos por segundo. Cada transacción se pone en cola esperando FOR UPDATE en una fila única, por lo que el throughput se limita a cientos de transacciones/segundo.
La solución común es dividir la cuenta activa en N subcuentas. Al registrar un crédito, elige una subaccount al azar. El saldo real del comerciante es la suma de las subcuentas:
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);
}
Con 16 shards, la contención se reduce aproximadamente 16 veces. Este enfoque funciona bien para la dirección de crédito, porque agregar dinero no requiere verificar el saldo. La dirección de débito, como cuando un comerciante retira dinero, ocurre mucho menos frecuentemente. Puedes consolidar el dinero en la cuenta principal mediante un trabajo periódico, y este trabajo también es una transacción de partida doble.
No debes aplicar sharding a carteras personales normales. Las carteras personales rara vez tienen contención, y el sharding hace que la verificación del saldo sea mucho más compleja.
7.2. Consistencia con otros sistemas: Transactional Outbox
Después de transferir dinero, el servicio de notificación, fraude y puntos debe estar informado. Si llamas kafkaTemplate.send() directamente dentro de transfer(), puedes encontrarte con uno de estos dos errores:
- Kafka recibe el mensaje pero la BD hace rollback, causando que el usuario reciba una notificación para una transacción que no existe.
- La BD hace commit pero el envío a Kafka falla, causando que el evento se pierda.
En la sección 6, ya registramos el evento en outbox_event dentro de la misma transacción. Un relay independiente leerá y enviará esta tabla a 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"));
}
}
}
Recuerda habilitar @EnableScheduling en una clase de configuración para que @Scheduled funcione. SKIP LOCKED permite que múltiples instancias de relay se ejecuten en paralelo sin tomar filas duplicadas.
Este mecanismo garantiza at-least-once (al menos una vez). Si relay muere después de enviar pero antes de hacer commit, el mensaje será reenviado. Por lo tanto, el consumidor debe deduplicar según transactionId.
7.3. Picos de carga
Cuando el volumen de solicitudes aumenta 10 veces, lo primero que falla es el pool de conexiones de la base de datos, no la CPU. Hay tres capas de defensa:
- Rate limit por usuario en el perímetro, usando Redis.
- Limitar el pool de conexiones (HikariCP) para que la BD no se sobrecargue, con un timeout corto para fallar rápido.
- Aplazar tareas no críticas como notificaciones y puntos a Kafka. Esto ya está cubierto por el outbox.
Ejemplo de rate limit de ventana fija de 10 solicitudes/segundo por usuario, 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
Cuando allow() devuelve false, devuelve 429 Too Many Requests. Cuando no se puede obtener una conexión en 500 ms, devuelve 503. El cliente tiene Idempotency-Key, por lo que reintentar es seguro.
Nota: No uses Redis para almacenar saldos ni para “restar dinero primero y sincronizar con la BD después”. Redis es muy rápido pero no te proporciona transacciones ACID junto con el ledger. Perder un failover significa perder dinero.
8. Pruebas de comportamiento concurrente
La prueba más importante es disparar muchas solicitudes en paralelo a la misma cartera. La cartera A tiene 50.000đ y recibe 100 órdenes de transferencia de 1.000đ cada una. El resultado correcto es que exactamente 50 órdenes tengan éxito, el saldo sea 0 y la suma de los asientos sea 0. La siguiente prueba requiere un PostgreSQL real, por ejemplo a través de Testcontainers, y datos de inicialización en @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);
}
}
Si quitas FOR UPDATE en lockAndGet, la primera prueba fallará aleatoriamente. A veces success será mayor que 50, a veces la restricción CHECK lanzará un error. Si no hay ON CONFLICT, la segunda prueba deducirá 2.000đ.
Además de las pruebas, ejecuta un trabajo de reconciliación cada noche. Este trabajo verifica dos cosas: que la suma de asientos sea 0 y que account.balance sea igual a la suma de ledger_entry para cada cuenta:
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);
La consulta devolviendo 0 filas significa que los libros concuerdan.
9. Errores comunes y cuándo no usar este diseño
- Usar
double/floatpara dinero. El cálculo0.1 + 0.2no da0.3. Usalongen la unidad más pequeña, oBigDecimalsi necesitas tipos de cambio. - Actualizar directamente
balancesin registrar asientos. Este enfoque es rápido inicialmente, pero cuando un cliente se queja, no puedes responder “¿a dónde fue el dinero?”. - Editar o eliminar asientos incorrectos. Registra un asiento inverso (reversal) como una nueva transacción.
- Bloqueos que no siguen un orden conducen a deadlock bajo carga. PostgreSQL resolverá el deadlock cancelando una transacción, y el usuario verá un error aleatorio.
- Llamar a API externas mientras se mantienen bloqueos, por ejemplo llamar a una puerta de pago bancaria. Los bloqueos se mantienen durante segundos y el throughput se desmorona. Separa los flujos relacionados con bancos en una máquina de estado (
PENDING→COMPLETED/FAILED) con una cuenta de retención (hold). - No uses este diseño para datos que no sean dinero o activos, por ejemplo contadores de visualizaciones. Allí, las pequeñas discrepancias son aceptables y el libro de contabilidad de partida doble es solo un costo adicional.
10. Conclusión
El núcleo de una cartera digital segura no está en tecnología nueva sino en unos pocos principios rigurosos:
- Libro de contabilidad de partida doble append-only, con la suma de asientos siempre igual a 0.
- Clave de idempotencia para todos los comandos de escritura.
- Bloqueos pesimistas en orden fijo, y verificación de saldo después de adquirir los bloqueos.
- Restricción
CHECKcomo red de seguridad final.
Sobre eso, manejamos cuentas activas con subcuentas, sincronizamos con otros sistemas mediante Transactional Outbox y Kafka, y resistimos picos de carga con rate limit de Redis junto con un pool de conexiones limitado.
Al escalar más, puedes separar los flujos relacionados con bancos en una máquina de estado con una cuenta de retención. También puedes particionar ledger_entry por tiempo cuando la tabla alcance mil millones de filas. No importa cómo escales, mantén las pruebas concurrentes y el trabajo de reconciliación en ejecución constante.