Design de um sistema de detecção de fraude em transações em tempo real
1. Visão geral
Cada vez que um cliente passa o cartão, o sistema de pagamento tem dezenas de milissegundos para responder a pergunta: essa transação é confiável? Responder devagar piora a experiência de pagamento. Responder errado significa perder dinheiro ou bloquear um cliente legítimo por engano.
Este artigo cobre o design de um sistema de detecção de fraude em tempo real. O escopo inclui requisitos, modelo de dados, API e fluxo de processamento. O artigo também cobre três desafios: condições de corrida em chaves quentes, consistência com retry, e picos de carga. Ao final, você terá um framework de arquitetura usando Spring Boot 3, Redis, PostgreSQL e Kafka para implementar.
2. Requisitos
2.1. Requisitos funcionais
- Receber uma transação e retornar uma decisão
APPROVE,REVIEWouDECLINE, junto com pontuação de risco e motivos. - Aplicar regras: montante inusitado, frequência de transação alta (velocity), país de risco.
- Manter registro de todas as decisões para auditoria e treinamento de modelo futuro.
- Encaminhar decisões
REVIEWpara o departamento de investigação (gerenciamento de casos).
2.2. Escala e restrições
| Métrica | Valor |
|---|---|
| Carga média | 3.000 TPS |
| Carga de pico (Black Friday, 11/11) | 30.000 TPS |
| Latência da API | p99 < 50ms |
| Armazenamento de decisões | ~260 milhões de registros/dia no pico, mantidos por 2 anos |
Há três restrições importantes:
- Idempotente: o gateway de pagamento pode fazer retry da mesma transação. Cada retry deve receber exatamente a mesma decisão anterior.
- Sem perda de eventos: uma decisão retornada deve ser registrada e enviada.
- Explicável: todo DECLINE deve ter uma lista de motivos específicos.
3. Arquitetura e fluxo de processamento
O fluxo se divide em duas partes. A parte síncrona está no caminho do pagamento e precisa ser rápida. A parte assíncrona faz tudo o mais.
Payment Gateway
│ POST /api/v1/fraud/score
▼
┌──────────────────────┐ velocity, profile ┌─────────┐
│ API de Pontuação │ ────────────────────▶ │ Redis │
│ (stateless, N pods) │ └─────────┘
└──────────┬───────────┘
│ 1 transação: decisão + outbox
▼
┌────────────┐ relé de outbox ┌──────────────────────┐
│ PostgreSQL │ ───────────────▶ │ Kafka: fraud.decisions│
└────────────┘ └──────────┬───────────┘
▼
Gerenciamento de casos, análise, armazenamento de features de ML
Passos específicos:
- A API recebe o request e verifica se já há uma decisão para esse transactionId.
- Executa as regras. Regras que precisam de estado (velocity) leem e escrevem no Redis com um comando atômico.
- Soma os pontos e toma uma decisão baseada em limiares.
- Registra a decisão e o registro de outbox na mesma transação PostgreSQL.
- Um relay lê o outbox e publica para Kafka. Consumers posteriores processarem lentamente não afeta a API.
4. Modelo de dados
Você precisa das seguintes dependências:
<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>
Schema 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,
payload JSONB NOT NULL,
published BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX idx_outbox_unpublished ON fraud_outbox (id) WHERE NOT published;
A tabela é particionada por dia. Assim, deletar dados antigos requer apenas DROP de partição, não DELETE de centenas de milhões de linhas.
Nota: Com tabelas particionadas, a chave primária deve incluir a coluna de partição. Na prática você usaria
PRIMARY KEY (transaction_id, created_at). Para consultar idempotência rapidamente apenas portransaction_id, mantenha uma pequena tabelaidempotency_keycom TTL. Aqui o artigo foi simplificado para facilitar a leitura.
O estado quente reside em Redis:
velocity:card:{cardId} ZSET member=transactionId, score=epochMillis TTL 10 minutos
profile:card:{cardId} HASH avgAmount, homeCountry TTL 30 dias
5. API
Request, response e tipos de dados compartilhados:
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) {}
O controller apenas retransmite:
@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);
}
}
Um exemplo de chamada e 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 regras
Cada regra é um bean independente. Adicionar uma regra nova não afeta o código antigo:
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();
}
}
O service soma pontos, toma decisões e salva o 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(); // retry → retorna a decisão 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 injeta automaticamente todo bean FraudRule em List<FraudRule>. Os pontos são limitados a 100. Os limiares de 40 e 80 devem ser colocados em configuração para que o time de risco possa ajustá-los sem fazer deploy novamente.
7. Desafios e soluções
7.1. Condição de corrida: verificação de velocidade em chaves quentes
A regra “mais de 5 transações em 60 segundos em um cartão” parece simples. Mas fraudadores gostam de disparar requisições em paralelo. Se você ler o contador e depois escrever, duas requisições podem ler 4 e ambas serem aprovadas. Essa é uma race condition clássica.
A solução é agrupar “deletar antigos, adicionar novo, contar” em um script Lua. Redis executa este script de forma atômica:
@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); // degrade, não quebra a API
}
}
}
Há três detalhes dignos de nota:
- Member é transactionId. Quando o gateway faz retry, ZADD apenas atualiza a pontuação do member antigo, não aumenta a contagem. Assim a regra é naturalmente idempotente.
- Use occurredAt em vez de hora do servidor. O resultado não desvia quando os pods têm clocks dessincronizados.
- Redis Cluster distribui por key. Cada cartão é uma chave separada, então a carga se distribui naturalmente. Chaves verdadeiramente quentes geralmente são por merchant (
velocity:merchant:*) para um grande merchant. Para esse caso, divida a chave em buckets de tempo, ou conte aproximadamente na memória local e sincronize periodicamente.
7.2. Consistência: idempotência e outbox
Dois erros de consistência comuns:
- Duas requisições com o mesmo transactionId chegam ao mesmo tempo. Ambas não encontram decisão anterior.
- Escrever no DB com sucesso mas falhar ao publicar no Kafka, ou vice-versa.
O erro (1) é resolvido com constraint única e ON CONFLICT. O erro (2) é resolvido com outbox na mesma transação:
@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(); // outra requisição venceu
}
jdbc.update("INSERT INTO fraud_outbox (aggregate_key, payload) VALUES (?, ?::jsonb)",
tx.cardId(), toJson(result));
return result;
}
// find(...) e toJson(...) omitidos para brevidade
}
inserted == 0 significa que uma requisição paralela escreveu primeiro. Nesse caso você retorna a decisão daquela requisição. Assim duas chamadas sempre recebem a mesma resposta.
O relay lê o outbox em lotes e publica com key sendo cardId. Todos os eventos de um cartão vão para a mesma partição mantendo a ordem:
@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últiplas instâncias rodem relay em paralelo sem se pisarem. Esse mecanismo garante at-least-once. Por isso o consumer posterior também deve ser idempotente, por exemplo usando transactionId como chave ao criar um caso de investigação.
7.3. Picos de carga
A carga de pico é 10 vezes a média. Há três técnicas para lidar com isso:
Orçamento de tempo apertado. Defina timeouts curtos para Redis e DB para que um componente lento não puxe toda a API além 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
Virtual threads do Java 21 ajudam a manter milhares de requisições esperando I/O sem esgotar o thread pool do Tomcat.
Política de degradação clara. Quando Redis falha, VelocityRule retorna VELOCITY_UNAVAILABLE com 20 pontos. Essa é uma decisão de negócio, não técnica. Fail-open (aprovar) mantém a receita. Fail-closed (bloquear) mantém a segurança. Defina essa política com o time de risco antecipadamente e registre métricas para cada degradação.
Empurre o trabalho pesado para fora do caminho quente. Modelos ML complexos, chamadas a serviços terceirizados ou cálculo de features diários rodam nos consumers Kafka. Kafka absorve picos de carga: um consumer com lag de alguns minutos não afeta o pagamento. Os resultados são escritos de volta em profile:card:* para transações posteriores usarem.
8. Testes do comportamento principal
O teste abaixo verifica a lógica de decisão e idempotência. O teste usa regras fake e repositório em memória, sem precisar de Redis ou DB:
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);
}
}
O segundo teste é crucial. Se o estado externo mudar entre duas chamadas, um retry ainda não deve mudar APPROVE para DECLINE. Com Lua script, você deve escrever um teste de integração adicional com Testcontainers. Esse teste dispara 20 requisições em paralelo para o mesmo cartão e verifica que apenas as requisições da 6ª em diante recebem VELOCITY_EXCEEDED.
9. Erros comuns e quando não fazer assim
- Chamar modelo ML de forma síncrona no caminho de pagamento sem timeout e fallback. Uma vez que o modelo fica lento por 2 segundos, todo o sistema de pagamento fica lento.
- Use hora do servidor para janela de velocity. Pods com clocks dessincronizados por alguns segundos tornam a contagem errada.
- Publicar Kafka diretamente no service em vez de passar por outbox. Você terá decisões ‘fantasma’: retornadas para o gateway mas ninguém downstream sabe delas.
- Contar velocity de forma read-then-write (GET depois INCR). Fraudadores só precisam disparar requisições em paralelo para contornar.
- Tratar Redis como fonte de verdade. Redis apenas mantém estado quente e pode ser perdido. A fonte de verdade é PostgreSQL e Kafka. Você deve ser capaz de reconstruir perfis a partir deles.
Nota: Se sua carga é apenas poucas dezenas de TPS e não há SLA de latência, essa arquitetura é um exagero. Um service lendo/escrevendo PostgreSQL com algumas consultas
COUNT(*)indexadas porcard_id,created_até suficiente. Adicione Redis, outbox e Kafka apenas quando as métricas mostram que você realmente precisa.
10. Conclusão
Este artigo projetou um sistema de detecção de fraude em tempo real seguindo o princípio de separar o caminho quente do caminho frio. A API síncrona executa apenas regras rápidas, lê/escreve no Redis com scripts Lua atômicos e registra decisões junto com outbox em uma transação. Toda carga pesada é movida para Kafka.
Três desafios têm três soluções específicas. Condições de corrida são tratadas com operações atômicas e chaves por cartão. Consistência se apoia em ON CONFLICT junto com outbox transacional. Picos de carga são controlados com timeouts apertados, política de degradação clara e filas que absorvem carga. A partir deste framework, você pode adicionar novas regras, plugar modelos ML no lado consumer e trazer resultados de modelo de volta ao Redis para a API usar nas transações seguintes.