
Race conditions en sistemas de pagos: diagnóstico y patrones para entornos críticos
Cómo diagnosticar y resolver race conditions en sistemas de pagos críticos. Patrones probados de idempotencia, compare-and-swap y sagas para prevenir dobles cobros y garantizar consistencia bajo alta concurrencia.
Race conditions en sistemas de pagos: diagnóstico y patrones para entornos críticos
El síntoma que suele confundirse con un bug puntual
Lunes por la mañana. El equipo de soporte de una FinTech recibe la primera alerta: tres transacciones del viernes generaron dobles cobros. Los registros muestran el sistema marcó las operaciones como "completadas", los importes son correctos, pero el dinero salió dos veces de la cuenta del cliente. Una race condition clásica en sistemas de pagos bajo alta concurrencia.
Tras revisar los logs, el código parece impecable y las pruebas unitarias pasan sin errores. En los entornos de staging, bajo condiciones controladas, el fallo es imposible de reproducir. Generalmente, estos tickets terminan cerrándose con un diagnóstico impreciso: "Condición de carrera intermitente bajo carga alta — monitorizar".
Sin embargo, en IberianHub hemos comprobado que este patrón no es un error de implementación aislado; es el síntoma visible de un modelo de concurrencia que ha dejado de escalar.
Tres indicadores de un problema de concurrencia estructural
No todas las race conditions son iguales. Algunas son bugs puntuales: una sección crítica sin proteger, un índice único que falta. Esas se corrigen. Las que señalan un problema de modelo tienen tres firmas reconocibles.
Solo aparecen bajo carga real. Si el fallo es imposible de reproducir en staging con datos de prueba pero aparece de forma consistente en producción durante picos de tráfico, el problema no es el código en sí. Es la interacción entre múltiples instancias del código bajo carga concurrente real. En staging, las pruebas de carga suelen ejecutarse con datos limpios y volúmenes que no reproducen las condiciones de contención real. El sistema que falla a 500.000 transacciones/día pasa todos los tests a 5.000.
Las inconsistencias son matemáticamente improbables. Balances negativos en cuentas con saldo positivo. Transacciones en estado "pending" y "completed" simultáneamente. Importes que no coinciden con ninguna combinación posible de operaciones válidas. Estos estados son señales de que dos procesos tomaron una decisión basada en el mismo estado leído de la base de datos, pero la actualizaron de forma no coordinada. El sistema tiene una ventana de inconsistencia que en condiciones normales dura microsegundos. Bajo carga alta, esa ventana se amplía.
La frecuencia escala no linealmente con el volumen. Si duplicas el volumen de transacciones y el número de incidentes se cuadruplica o decuplica, el modelo de concurrencia está exhibiendo comportamiento superlineal. Esto es característico del locking optimista cuando la tasa de conflictos supera el umbral para el que fue diseñado. Con locking optimista, cada conflicto genera un reintento. Si la tasa de conflictos sube del 0,1% al 2%, el número de reintentos no sube 20x: sube más, porque los reintentos compiten con las operaciones nuevas y generan más conflictos en un ciclo que puede llevar a contención severa.
El modelo de concurrencia que genera estos síntomas
La mayoría de los sistemas de pagos modernos construidos sobre frameworks ORM estándar usan locking optimista por defecto. Es una decisión razonable para volúmenes bajos: menos contención, mejor throughput en el caso sin conflictos, más simple de implementar. El problema aparece cuando el modelo de dominio tiene invariantes que el ORM no puede proteger de forma atómica.
El patrón clásico es el check-then-act no atómico:
1. Leer saldo actual: 1.000€
2. Verificar que saldo >= importe (500€): OK
3. Calcular nuevo saldo: 500€
4. Escribir nuevo saldo: 500€
Si dos procesos ejecutan este flujo en paralelo sobre la misma cuenta, ambos leen 1.000€ en el paso 1, ambos verifican que 1.000€ >= 500€, y ambos escriben 500€. El resultado final es 500€ cuando debería ser 0€ o un error de fondos insuficientes.
Con locking optimista, si el ORM detecta el conflicto mediante un campo version, uno de los dos procesos fallará en el commit. Pero el comportamiento depende del nivel de aislamiento de la transacción, de cuándo exactamente se hace la lectura del campo version, y de si el código de la aplicación reintenta correctamente.
Tres fuentes de fallo frecuentes en sistemas reales:
Transacciones largas que incluyen llamadas externas. Si la transacción de base de datos queda abierta durante una llamada a una API externa (el proveedor de pagos, un sistema antifraude), el tiempo de exposición al conflicto se multiplica por el tiempo de respuesta de esa API. A p95, esa API tarda 200ms. En un pico de tráfico, puede tardar 2 segundos. El conflicto que era improbable al 0,1% pasa a ser frecuente.
El ORM que oculta la semántica real. Los frameworks ORM de alto nivel pueden ocultar cuándo realmente se hace la lectura del campo version. Si se hace en el momento del load() inicial pero el objeto queda en caché durante operaciones de validación de negocio largas, la versión que el ORM compara al hacer el save() puede estar desactualizada desde hace varios cientos de milisegundos.
El reintento que no es idempotente. Cuando el locking optimista detecta un conflicto y el sistema reintenta, si la operación de negocio no es idempotente, el reintento puede procesar la transacción por segunda vez en lugar de simplemente aplicar la operación fallida sobre el estado actualizado. Este es el origen más frecuente de los dobles cobros.
Tres patrones para resolver el modelo sin romper producción
Estos tres patrones son complementarios. En un sistema de pagos real, probablemente necesites los tres.
Idempotency keys: hacer que reintentar sea seguro
La idempotency key es un identificador único asignado por el cliente para cada operación. El servidor garantiza que si recibe la misma key dos veces, solo procesa la operación una vez y devuelve el mismo resultado.
Esto desacopla el problema del doble cobro del modelo de concurrencia subyacente. Incluso si el sistema reintenta internamente, la base de datos tiene una constraint de unicidad sobre la key que previene el procesamiento duplicado.
Implementación mínima viable:
ALTER TABLE payment_operations
ADD COLUMN idempotency_key VARCHAR(128) UNIQUE NOT NULL;
@Transactional
public PaymentResult processPayment(String idempotencyKey, BigDecimal amount, Long accountId) {
try {
// Buscar operación existente
PaymentOperation existing = entityManager
.createQuery(
"SELECT p FROM PaymentOperation p WHERE p.idempotencyKey = :key",
PaymentOperation.class
)
.setParameter("key", idempotencyKey)
.getResultStream()
.findFirst()
.orElse(null);
if (existing != null) {
return existing.getResult(); // idempotente: devolver resultado previo
}
// Ejecutar el pago
PaymentResult result = executePayment(amount, accountId);
// Persistir la operación
PaymentOperation operation = new PaymentOperation();
operation.setIdempotencyKey(idempotencyKey);
operation.setResult(result);
entityManager.persist(operation);
return result;
} catch (DataIntegrityViolationException | ConstraintViolationException e) {
// Race condition en la propia inserción: releer el resultado
return entityManager
.createQuery(
"SELECT p FROM PaymentOperation p WHERE p.idempotencyKey = :key",
PaymentOperation.class
)
.setParameter("key", idempotencyKey)
.getSingleResult()
.getResult();
}
}
El punto crítico: el UniqueViolation del INSERT también es una race condition. Hay que manejarla explícitamente, no asumir que nunca ocurre.
Saga Pattern con compensación: gestionar transacciones distribuidas en sistemas de pagos
En sistemas de pagos con múltiples servicios (core bancario, servicio de notificaciones, sistema antifraude, proveedor externo), no existe transacción distribuida sin coste de coordinación prohibitivo en producción real.
El Saga pattern divide la operación en pasos locales, cada uno con su transacción de base de datos individual, y define una acción de compensación para cada paso que pueda revertirse en caso de fallo posterior.
Lo que la mayoría de equipos implementan mal es la compensación: diseñan el happy path correctamente pero las acciones de compensación son incompletas o tienen sus propias race conditions. La regla práctica es que cada acción de compensación debe ser idempotente (reintentable) y debe funcionar incluso si el paso que compensa se ejecutó solo parcialmente.
Compare-and-Swap a nivel de dominio
El CAS de base de datos es más explícito y controlable que el locking optimista del ORM:
UPDATE accounts
SET balance = :new_balance, version = version + 1
WHERE id = :account_id
AND balance = :expected_balance
AND version = :expected_version;
Si el UPDATE afecta 0 filas, otro proceso modificó el estado: hay que reintentar con el estado actualizado. La diferencia con el locking optimista estándar es que el CAS está explícitamente en la semántica de negocio: "actualiza el saldo SI el saldo actual es el que leí". Esto permite implementar reintentos correctos. El reintento re-lee el estado actual, re-evalúa la operación de negocio, y solo procede si la operación sigue siendo válida con el nuevo estado. Si el saldo ha cambiado y la operación ya no es válida (fondos insuficientes), se devuelve error en lugar de reintentar ciegamente.
La migración en caliente: lo que la mayoría hace mal
Añadir idempotency keys a un sistema de pagos en producción con millones de transacciones históricas tiene dos problemas prácticos que aparecen de forma consistente.
El ALTER TABLE que bloquea la tabla. No se puede añadir una columna NOT NULL sin valor por defecto en una tabla con millones de filas sin bloquear la tabla durante la migración. La solución estándar es el backfill en tres fases: primero añadir la columna como NULL sin bloqueo, luego hacer el backfill en background con valores generados para registros históricos, finalmente añadir la constraint NOT NULL + UNIQUE cuando el backfill está completo.
El código nuevo que convive con el código antiguo durante el despliegue. Durante el rollout, parte de las instancias generan idempotency keys y parte no. Las filas creadas por el código antiguo no tienen key y no pueden backfillearse con un valor semánticamente correcto. La solución es gestionar esto en la lógica de lectura: si la operación no tiene idempotency key (código legacy), no se aplica la lógica de deduplicación y se procesa normalmente. Solo las operaciones con key activan el path idempotente. Esto permite el rollout incremental sin forzar un corte limpio.
## Conclusión Las race conditions recurrentes en sistemas financieros no son eventos fortuitos; son indicadores de un contrato de idempotencia inexistente o de una gestión de estado débil.
El primer paso para la solución es la instrumentación. Antes de cambiar una sola línea de arquitectura, es vital medir la tasa de conflictos en la base de datos y los tiempos de transacción.
¿Tu sistema de pagos experimenta race conditions o inconsistencias bajo carga? En IberianHub auditamos arquitecturas críticas y aplicamos patrones de concurrencia probados para prevenir dobles cobros y garantizar consistencia transaccional. Contáctanos para una evaluación técnica de tu plataforma.