Diseño de un sistema de detección de fraude de transacciones en tiempo real
1. Descripción general
Cada vez que un cliente desliza su tarjeta, el sistema de pagos tiene varios milisegundos para responder a la pregunta: ¿es confiable esta transacción? Responder lentamente arruina la experiencia de pago. Responder incorrectamente te cuesta dinero o bloquea a un cliente legítimo.
Este artículo explora el diseño de un sistema de detección de fraude en tiempo real. El alcance incluye requisitos, modelo de datos, API y flujo de procesamiento. El artículo también aborda tres desafíos clave: contención en claves calientes, consistencia en reintentos y picos de carga. Al terminar, tendrás un marco de arquitectura usando Spring Boot 3, Redis, PostgreSQL y Kafka que puedes implementar por tu cuenta.
2. Requisitos
2.1. Requisitos funcionales
- Recibir una transacción y devolver una decisión
APPROVE,REVIEWoDECLINE, junto con una puntuación de riesgo y razones. - Aplicar reglas: cantidad inusual, alta frecuencia de transacciones (velocity), país de riesgo.
- Guardar todas las decisiones para auditoría y entrenamiento de modelo posterior.
- Enviar decisiones
REVIEWal departamento de investigación (gestión de casos).
2.2. Escala y restricciones
| Métrica | Valor |
|---|---|
| Carga promedio | 3.000 TPS |
| Carga pico (Black Friday, 11/11) | 30.000 TPS |
| Latencia de API | p99 < 50ms |
| Almacenamiento de decisiones | ~260 millones de registros/día en pico, retener 2 años |
Hay tres restricciones clave:
- Idempotente: la puerta de pago puede reintentar la misma transacción. Cada reintento debe devolver la misma decisión anterior.
- Sin pérdida de eventos: una decisión devuelta debe guardarse y publicarse.
- Explicable: todo
DECLINEdebe tener una lista de razones específicas.
3. Arquitectura y flujo de procesamiento
El flujo se divide en dos partes. La parte síncrona está en la ruta de pago y debe ser rápida. La parte asíncrona maneja todo lo demás.
Puerta de Pago
│ POST /api/v1/fraud/score
▼
┌──────────────────────┐ velocity, profile ┌─────────┐
│ Fraud Scoring API │ ────────────────────▶ │ Redis │
│ (stateless, N pod) │ └─────────┘
└──────────┬───────────┘
│ 1 transaction: decision + outbox
▼
┌────────────┐ outbox relay ┌──────────────────────┐
│ PostgreSQL │ ───────────────▶ │ Kafka: fraud.decisions│
└────────────┘ └──────────┬───────────┘
▼
Case management, analytics, ML feature store
Pasos específicos:
- La API recibe la solicitud y verifica si ya hay una decisión para ese
transactionId. - Ejecutar reglas. Las reglas que necesitan estado (velocity) leen y escriben en Redis con un comando atómico.
- Sumar puntos y tomar una decisión según los umbrales.
- Guardar la decisión y el registro outbox en la misma transacción de PostgreSQL.
- Un relé lee el outbox y lo publica en Kafka. El procesamiento lento del consumidor posterior no afecta la API.
4. Modelo de datos
Necesitas las siguientes dependencias:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
Esquema de PostgreSQL:
CREATE TABLE fraud_decision (
transaction_id VARCHAR(64) PRIMARY KEY,
card_id VARCHAR(64) NOT NULL,
amount NUMERIC(18,2) NOT NULL,
decision VARCHAR(10) NOT NULL,
score INT NOT NULL,
reasons TEXT[] NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);
CREATE TABLE fraud_outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_key VARCHAR(64) NOT NULL, -- card_id, usado como clave de Kafka
payload JSONB NOT NULL,
published BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX idx_outbox_unpublished ON fraud_outbox (id) WHERE NOT published;
La tabla se particiona por día. Así, eliminar datos antiguos solo requiere hacer un DROP en la partición, no DELETE en cientos de millones de filas.
Nota: Con tablas particionadas, la clave primaria debe contener la columna de partición. En la práctica usarías
PRIMARY KEY (transaction_id, created_at). Para buscar idempotencia rápidamente solo portransaction_id, mantén una tabla pequeña adicionalidempotency_keycon TTL. Aquí simplificamos para mayor claridad.
El estado activo reside en Redis:
velocity:card:{cardId} ZSET member=transactionId, score=epochMillis TTL 10 min
profile:card:{cardId} HASH avgAmount, homeCountry TTL 30 días
5. API
Solicitud, respuesta y tipos de datos compartidos:
public record TransactionRequest(
String transactionId, String cardId, String merchantId,
BigDecimal amount, String currency, String country, Instant occurredAt) {}
public enum Decision { APPROVE, REVIEW, DECLINE }
public record RuleHit(String code, int score) {
public static RuleHit none() { return new RuleHit(null, 0); }
public boolean matched() { return code != null; }
}
public record FraudResult(String transactionId, Decision decision, int score, List<String> reasons) {}
El controlador solo actúa como intermediario:
@RestController
@RequestMapping("/api/v1/fraud")
public class FraudController {
private final FraudScoringService scoringService;
public FraudController(FraudScoringService scoringService) {
this.scoringService = scoringService;
}
@PostMapping("/score")
public FraudResult score(@RequestBody TransactionRequest request) {
return scoringService.score(request);
}
}
Una llamada de ejemplo y su resultado:
curl -X POST localhost:8080/api/v1/fraud/score -H 'Content-Type: application/json' -d '{
"transactionId":"tx-1001","cardId":"card-42","merchantId":"m-7",
"amount":15000000,"currency":"VND","country":"NG","occurredAt":"2026-10-12T08:00:00Z"}'
{"transactionId":"tx-1001","decision":"REVIEW","score":55,"reasons":["HIGH_RISK_COUNTRY","AMOUNT_SPIKE"]}
6. Motor de reglas
Cada regla es un bean independiente. Agregar una regla nueva no toca el código antiguo:
public interface FraudRule {
RuleHit evaluate(TransactionRequest tx);
}
@Component
public class HighRiskCountryRule implements FraudRule {
private static final Set<String> RISKY = Set.of("NG", "KP", "IR");
@Override
public RuleHit evaluate(TransactionRequest tx) {
return RISKY.contains(tx.country()) ? new RuleHit("HIGH_RISK_COUNTRY", 30) : RuleHit.none();
}
}
El servicio suma puntos, toma una decisión y guarda el resultado:
@Service
public class FraudScoringService {
private final List<FraudRule> rules;
private final DecisionRepository repository;
public FraudScoringService(List<FraudRule> rules, DecisionRepository repository) {
this.rules = rules;
this.repository = repository;
}
public FraudResult score(TransactionRequest tx) {
Optional<FraudResult> existing = repository.find(tx.transactionId());
if (existing.isPresent()) {
return existing.get(); // reintento → devuelve la decisión anterior
}
List<RuleHit> hits = rules.stream().map(r -> r.evaluate(tx)).filter(RuleHit::matched).toList();
int score = Math.min(100, hits.stream().mapToInt(RuleHit::score).sum());
Decision decision = score >= 80 ? Decision.DECLINE : score >= 40 ? Decision.REVIEW : Decision.APPROVE;
FraudResult result = new FraudResult(tx.transactionId(), decision, score,
hits.stream().map(RuleHit::code).toList());
return repository.saveOrGetExisting(tx, result);
}
}
Spring inyecta automáticamente todos los beans FraudRule en List<FraudRule>. La puntuación se limita a 100. Los umbrales 40 y 80 deberían ser configurables para que el equipo de riesgos pueda ajustarlos sin necesidad de redeploy.
7. Desafíos clave y soluciones
7.1. Contención: verificación de velocidad en claves calientes
La regla “más de 5 transacciones en 60 segundos en una tarjeta” parece simple. Pero los estafadores suelen disparar solicitudes en paralelo. Si lees el conteo y luego escribes, dos solicitudes pueden leer ambas 4 y pasar ambas. Esa es una race condition clásica.
La solución es agrupar “eliminar antiguo, agregar nuevo, contar” en un script Lua. Redis ejecuta este script atómicamente:
@Component
public class VelocityRule implements FraudRule {
private static final RedisScript<Long> SCRIPT = RedisScript.of("""
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, tonumber(ARGV[1]) - tonumber(ARGV[2]))
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[3])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return redis.call('ZCARD', KEYS[1])
""", Long.class);
private final StringRedisTemplate redis;
public VelocityRule(StringRedisTemplate redis) { this.redis = redis; }
@Override
public RuleHit evaluate(TransactionRequest tx) {
try {
Long count = redis.execute(SCRIPT, List.of("velocity:card:" + tx.cardId()),
String.valueOf(tx.occurredAt().toEpochMilli()), "60000", tx.transactionId());
return count != null && count > 5 ? new RuleHit("VELOCITY_EXCEEDED", 50) : RuleHit.none();
} catch (DataAccessException e) {
return new RuleHit("VELOCITY_UNAVAILABLE", 20); // degradar, no derribes la API
}
}
}
Hay tres detalles notables:
- El miembro es
transactionId. Cuando la puerta reintentar,ZADDsolo actualiza la puntuación del miembro anterior, sin aumentar el conteo. Por lo tanto, la regla es automáticamente idempotente. - Usa
occurredAten lugar de la hora del servidor. El resultado no se desvía cuando los pods tienen relojes desincronizados. - Redis Cluster distribuye por clave. Cada tarjeta es una clave separada, por lo que la carga se distribuye naturalmente. Las claves realmente calientes suelen ser por comerciante (
velocity:merchant:*) para un comerciante grande. Para esto, subdivide la clave por bucket temporal o cuenta aproximadamente en memoria local y sincroniza periódicamente.
7.2. Consistencia: idempotencia y outbox
Dos errores de consistencia comunes:
- Dos solicitudes con el mismo
transactionIdllegan simultáneamente. Ambas no encuentran una decisión anterior. - Escribir en BD se completa pero publicar en Kafka falla, o viceversa.
El error (1) se resuelve con restricción única y ON CONFLICT. El error (2) se resuelve con outbox en la misma transacción:
@Repository
public class JdbcDecisionRepository implements DecisionRepository {
private final JdbcTemplate jdbc;
private final ObjectMapper mapper;
public JdbcDecisionRepository(JdbcTemplate jdbc, ObjectMapper mapper) {
this.jdbc = jdbc;
this.mapper = mapper;
}
@Override
@Transactional
public FraudResult saveOrGetExisting(TransactionRequest tx, FraudResult result) {
int inserted = jdbc.update("""
INSERT INTO fraud_decision (transaction_id, card_id, amount, decision, score, reasons)
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (transaction_id) DO NOTHING
""", tx.transactionId(), tx.cardId(), tx.amount(), result.decision().name(),
result.score(), result.reasons().toArray(String[]::new));
if (inserted == 0) {
return find(tx.transactionId()).orElseThrow(); // otra solicitud ganó
}
jdbc.update("INSERT INTO fraud_outbox (aggregate_key, payload) VALUES (?, ?::jsonb)",
tx.cardId(), toJson(result));
return result;
}
// find(...) y toJson(...) omitidos por brevedad
}
inserted == 0 significa que otra solicitud concurrente escribió primero. En ese caso, devuelves la decisión de esa otra solicitud. Así, ambas llamadas siempre reciben la misma respuesta.
El relé lee el outbox en lotes y publica con clave cardId. Todos los eventos de una tarjeta van a la misma partición, preservando el orden:
@Scheduled(fixedDelay = 200)
@Transactional
public void relay() {
List<Map<String, Object>> rows = jdbc.queryForList("""
SELECT id, aggregate_key, payload::text AS payload FROM fraud_outbox
WHERE NOT published ORDER BY id LIMIT 500 FOR UPDATE SKIP LOCKED""");
for (Map<String, Object> row : rows) {
kafka.send("fraud.decisions", (String) row.get("aggregate_key"), (String) row.get("payload")).join();
jdbc.update("UPDATE fraud_outbox SET published = true WHERE id = ?", row.get("id"));
}
}
FOR UPDATE SKIP LOCKED permite que múltiples instancias ejecuten el relé en paralelo sin pisarse. Este mecanismo garantiza at-least-once. Por lo tanto, el consumidor posterior también debe ser idempotente, por ejemplo usando transactionId como clave al crear un caso de investigación.
7.3. Picos de carga
La carga pico es 10 veces el promedio. Hay tres técnicas para superarla:
Presupuesto de tiempo estricto. Establece timeouts cortos para Redis y BD para que un componente lento no arrastre toda la API más allá de 50ms:
spring.data.redis.timeout=15ms
spring.datasource.hikari.connection-timeout=20
spring.datasource.hikari.maximum-pool-size=30
spring.threads.virtual.enabled=true
Los virtual threads de Java 21 ayudan a mantener miles de solicitudes esperando I/O sin agotar el thread pool de Tomcat.
Política de degradación explícita. Cuando Redis falla, VelocityRule devuelve VELOCITY_UNAVAILABLE con 20 puntos. Es una decisión comercial, no técnica. Fail-open (permitir) preserva ingresos. Fail-closed (bloquear) preserva seguridad. Define esta política con el equipo de riesgos de antemano y registra métricas para cada degradación.
Mueve el trabajo pesado fuera de la ruta caliente. El modelo ML complejo, las llamadas a servicios de terceros o el cálculo de características diarias se ejecutan en el consumidor de Kafka. Kafka absorbe los picos: aunque el consumidor se retrase unos minutos, no afecta los pagos. Los resultados se escriben nuevamente en profile:card:* para que las transacciones posteriores los usen.
8. Prueba del comportamiento principal
La siguiente prueba verifica la lógica de decisión e idempotencia. Usa reglas simuladas y un repositorio en memoria, sin necesidad de Redis ni BD:
class FraudScoringServiceTest {
static class InMemoryRepo implements DecisionRepository {
final Map<String, FraudResult> store = new ConcurrentHashMap<>();
public Optional<FraudResult> find(String id) { return Optional.ofNullable(store.get(id)); }
public FraudResult saveOrGetExisting(TransactionRequest tx, FraudResult r) {
FraudResult prev = store.putIfAbsent(tx.transactionId(), r);
return prev != null ? prev : r;
}
}
private TransactionRequest tx(String id) {
return new TransactionRequest(id, "card-42", "m-7", new BigDecimal("15000000"),
"VND", "NG", Instant.parse("2026-10-12T08:00:00Z"));
}
@Test
void declinesWhenScoreReachesThreshold() {
List<FraudRule> rules = List.of(t -> new RuleHit("HIGH_RISK_COUNTRY", 30),
t -> new RuleHit("VELOCITY_EXCEEDED", 50));
var service = new FraudScoringService(rules, new InMemoryRepo());
FraudResult result = service.score(tx("tx-1"));
assertThat(result.decision()).isEqualTo(Decision.DECLINE);
assertThat(result.score()).isEqualTo(80);
assertThat(result.reasons()).containsExactly("HIGH_RISK_COUNTRY", "VELOCITY_EXCEEDED");
}
@Test
void retryReturnsSameDecisionEvenIfRulesChange() {
AtomicInteger calls = new AtomicInteger();
FraudRule flaky = t -> calls.incrementAndGet() == 1 ? RuleHit.none() : new RuleHit("X", 90);
var service = new FraudScoringService(List.of(flaky), new InMemoryRepo());
FraudResult first = service.score(tx("tx-2"));
FraudResult retry = service.score(tx("tx-2"));
assertThat(first.decision()).isEqualTo(Decision.APPROVE);
assertThat(retry).isEqualTo(first);
}
}
La segunda prueba es crítica. Incluso si el estado externo cambia entre dos llamadas, un reintento nunca debe cambiar APPROVE a DECLINE. Con scripts Lua, debes escribir una prueba de integración adicional con Testcontainers. Esa prueba dispara 20 solicitudes en paralelo para la misma tarjeta y verifica que solo las solicitudes a partir de la 6ª tengan VELOCITY_EXCEEDED.
9. Errores comunes y cuándo no hacer esto
- Llamar al modelo ML de forma síncrona en la ruta de pago sin timeout ni fallback. Si el modelo se ralentiza 2 segundos, todo el sistema de pagos se ralentiza.
- Usar la hora del servidor para la ventana de velocidad. Si los pods tienen relojes desincronizados unos pocos segundos, el conteo es incorrecto.
- Publicar en Kafka directamente en el servicio en lugar de a través del outbox. Terminarás con decisiones “fantasma”: devueltas a la puerta pero que nadie downstream conoce.
- Contar velocidad con patrón lectura-luego-escritura (
GETluegoINCR). Los defraudadores solo necesitan disparar en paralelo para eludir esto. - Considerar Redis como la fuente de verdad. Redis solo mantiene estado activo y puede perderse. La fuente de verdad es PostgreSQL y Kafka. Debes poder reconstruir los perfiles desde ahí.
Nota: Si tu carga es solo algunos TPS y no tienes compromisos de latencia, esta arquitectura es excesiva. Un servicio que lee y escribe en PostgreSQL con algunos
COUNT(*)indexados porcard_id, created_ates suficiente. Agrega Redis, outbox y Kafka solo cuando los números demuestren que realmente los necesitas.
10. Conclusión
Este artículo ha diseñado un sistema de detección de fraude en tiempo real siguiendo el principio de separar la ruta caliente de la fría. La API síncrona solo ejecuta reglas rápidas, lee y escribe en Redis con scripts Lua atómicos y guarda la decisión junto al outbox en una transacción. Todo trabajo más pesado se envía a través de Kafka.
Los tres desafíos tienen tres soluciones específicas. La contención se maneja con operaciones atómicas y claves por tarjeta. La consistencia se basa en ON CONFLICT junto con outbox transaccional. Los picos de carga se controlan con timeouts estrictos, políticas de degradación explícitas y colas que absorben la carga. Desde este marco, puedes agregar reglas nuevas, conectar modelos ML en el lado del consumidor y luego escribir puntuaciones del modelo de vuelta a Redis para que la API las use en transacciones posteriores.