Conception d'un système d'évaluation des fraudes en temps réel

1. Vue d’ensemble

À chaque fois qu’un client passe sa carte, le système de paiement dispose de quelques dizaines de millisecondes pour répondre à la question : cette transaction est-elle fiable ? Une réponse lente offre une mauvaise expérience de paiement. Une réponse incorrecte te coûte de l’argent ou refuse un client légitime.

Cet article présente la conception d’un système de détection de fraude en temps réel. Le périmètre couvre les exigences, le modèle de données, l’API et le flux de traitement. L’article traite également de trois points critiques : les conditions de concurrence sur les clés chaudes, la cohérence lors des retentatives, et les pics de charge. À la fin, tu auras une architecture complète utilisant Spring Boot 3, Redis, PostgreSQL et Kafka pour une implémentation propre.

2. Exigences

2.1. Exigences fonctionnelles

  • Recevoir une transaction et retourner une décision APPROVE, REVIEW ou DECLINE, accompagnée d’un score de risque et des raisons.
  • Appliquer des règles : montant anormal, fréquence de transaction élevée (vélocité), pays à risque.
  • Enregistrer chaque décision pour l’audit et l’entraînement ultérieur des modèles.
  • Envoyer les décisions REVIEW à l’équipe d’investigation (case management).

2.2. Échelle et contraintes

Métrique Valeur
Débit moyen 3 000 TPS
Débit pic (Black Friday, 11/11) 30 000 TPS
Latence API p99 < 50ms
Stockage des décisions ~260 millions d’enregistrements/jour au pic, conservés 2 ans

Trois contraintes critiques :

  1. Idempotence : la passerelle de paiement peut retenter une même transaction. Chaque retentative doit recevoir la même décision.
  2. Aucune perte d’événement : une décision renvoyée doit être enregistrée et propagée.
  3. Traçabilité : chaque DECLINE doit avoir une liste de raisons précises.

3. Architecture et flux de traitement

Le flux se divise en deux parties. La partie synchrone se trouve sur le chemin critique du paiement et doit être rapide. La partie asynchrone effectue tout le reste.

Payment Gateway
      │ 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

Les étapes spécifiques :

  1. L’API reçoit la requête et vérifie s’il existe déjà une décision pour transactionId.
  2. Exécute les règles. Les règles nécessitant un état (vélocité) lisent et écrivent dans Redis de manière atomique.
  3. Accumule les scores et prend une décision selon les seuils.
  4. Enregistre la décision et l’entrée d’outbox dans une seule transaction PostgreSQL.
  5. Un relay lit l’outbox puis publie sur Kafka. Un consommateur en aval, même s’il est lent, n’affecte pas l’API.

4. Modèle de données

Tu auras besoin des dépendances suivantes :

<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>

Schéma 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, utilisé comme clé Kafka
    payload     JSONB       NOT NULL,
    published   BOOLEAN     NOT NULL DEFAULT false
);
CREATE INDEX idx_outbox_unpublished ON fraud_outbox (id) WHERE NOT published;

La table est partitionnée par jour. Ainsi, supprimer les anciennes données ne requiert qu’un DROP de partition, pas un DELETE sur des centaines de millions de lignes.

Note : Avec une table partitionnée, la clé primaire doit contenir la colonne de partition. En pratique, tu utiliseras PRIMARY KEY (transaction_id, created_at). Pour interroger rapidement l’idempotence par transaction_id seul, maintiens une petite table idempotency_key avec TTL. Pour simplifier la lisibilité ici, l’article raccourcit les détails.

L’état chaud réside dans Redis :

velocity:card:{cardId}     ZSET  member=transactionId, score=epochMillis   TTL 10 minutes
profile:card:{cardId}      HASH  avgAmount, homeCountry                    TTL 30 jours

5. API

Requête, réponse et types de données courants :

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) {}

Le contrôleur se contente de relayer :

@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);
    }
}

Un appel exemple et son résultat :

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. Moteur de règles

Chaque règle est un bean indépendant. Ajouter une nouvelle règle ne touche pas l’ancien code :

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();
    }
}

Le service accumule les scores, prend une décision et enregistre le résultat :

@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 → retourner la décision précédente
        }
        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 injecte automatiquement chaque bean FraudRule dans List<FraudRule>. Le score est limité à 100. Les seuils 40 et 80 devraient être extraits en configuration pour que l’équipe risque puisse les ajuster sans redéployer.

7. Points critiques et solutions

7.1. Concurrence : contrôle de vélocité sur clés chaudes

La règle « plus de 5 transactions en 60 secondes sur une même carte » semble simple. Or, les fraudeurs aiment bombarder avec des requêtes parallèles. Si tu lis le compteur puis l’écris, deux requêtes simultanées lisent toutes deux 4 et sont toutes deux approuvées. C’est une race condition classique.

La solution est d’empiler « supprimer les anciens, ajouter nouveau, compter » dans un script Lua. Redis exécute ce script de manière atomique :

@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, ne pas casser l'API
        }
    }
}

Trois détails valent la peine :

  • Le membre est transactionId. Quand la passerelle retente, ZADD met à jour seulement le score du membre existant, n’augmente pas le compteur. La règle est donc naturellement idempotente.
  • Utiliser occurredAt au lieu de l’heure serveur. Le résultat ne décale pas si les pods ont des horloges désynchronisées.
  • Redis Cluster distribue par clé. Chaque carte est une clé distincte donc la charge se répartit naturellement. Les vraies clés chaudes sont souvent par merchant (velocity:merchant:*) avec un grand commerce. Pour ce type, subdivise la clé par bucket temporel ou compte approximativement en mémoire locale avec synchronisation régulière.

7.2. Cohérence : idempotence et outbox

Deux erreurs de cohérence courent :

  1. Deux requêtes avec le même transactionId arrivent simultanément. Aucune ne trouve la décision précédente.
  2. L’écriture BD réussit mais la publication Kafka échoue, ou vice versa.

L’erreur (1) est résolue par une contrainte unique et ON CONFLICT. L’erreur (2) est résolue en plaçant l’outbox dans la même transaction :

@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();   // une autre requête a gagné
        }
        jdbc.update("INSERT INTO fraud_outbox (aggregate_key, payload) VALUES (?, ?::jsonb)",
                tx.cardId(), toJson(result));
        return result;
    }

    // find(...) et toJson(...) omis pour la brièveté
}

inserted == 0 signifie qu’une requête parallèle a écrit en premier. Tu retournes alors sa décision. De cette façon, deux appels reçoivent toujours la même réponse.

Le relay lit l’outbox par lot et publie avec pour clé cardId. Tous les événements d’une carte vont dans la même partition donc conservent l’ordre :

@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 permet à plusieurs instances de relay de tourner en parallèle sans se marcher dessus. Ce mécanisme assure at-least-once. Par conséquent, le consommateur en aval doit aussi être idempotent, par exemple en utilisant transactionId comme clé lors de la création d’une enquête.

7.3. Pics de charge

La charge pic est 10 fois la moyenne. Trois techniques t’aident à passer ces pics :

Budget de temps serré. Définis des timeouts courts pour Redis et BD afin qu’un composant lent ne tire pas l’API au-delà 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

Les virtual thread de Java 21 aident à maintenir des milliers de requêtes attendant l’I/O sans épuiser le thread pool de Tomcat.

Politique de dégradation explicite. Quand Redis échoue, VelocityRule retourne VELOCITY_UNAVAILABLE avec 20 points. C’est un choix métier, pas technique. Fail-open (laisser passer) préserve le revenu. Fail-closed (rejeter) préserve la sécurité. Arrête cette politique avec ton équipe risque d’avance et enregistre une métrique à chaque dégradation.

Repousser le travail lourd hors du chemin critique. Un modèle ML complexe, appels à des services tiers ou calcul de features par jour tournent sur un consommateur Kafka. Kafka absorbe les pics : un consommateur en retard de quelques minutes n’affecte pas le paiement. Les résultats sont ensuite réécrits dans profile:card:* pour que les futures transactions les utilisent.

8. Test du comportement principal

Ce test vérifie la logique de décision et l’idempotence. Il utilise des règles simulées et un repository en mémoire, sans 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);
    }
}

Le second test est très important. Si l’état externe change entre les deux appels, une retentative ne doit pas basculer APPROVE en DECLINE. Avec un script Lua, tu devrais ajouter un test d’intégration avec Testcontainers. Ce test tire 20 requêtes parallèles pour la même carte et vérifie que seules les requêtes 6 et suivantes reçoivent VELOCITY_EXCEEDED.

9. Pièges courants et quand ne pas faire ainsi

  • Appeler un modèle ML de façon synchrone sur le chemin critique sans timeout ni fallback. Un modèle lent de 2 secondes rend le paiement entier lent.
  • Utiliser l’heure serveur pour la fenêtre de vélocité. Des pods avec une dérive d’horloge de quelques secondes faussent le compteur.
  • Publier Kafka directement dans le service au lieu de passer par l’outbox. Tu auras des transactions « fantômes » : renvoyées à la passerelle mais inconnues en aval.
  • Compter la vélocité à la manière read-then-write (GET puis INCR). Un fraudeur n’a qu’à bombarder de requêtes parallèles pour franchir.
  • Traiter Redis comme la source de vérité. Redis ne contient que l’état chaud et peut être perdu. La source de vérité est PostgreSQL et Kafka. Tu dois pouvoir reconstruire le profil à partir de là.

Note : Si ton débit est seulement quelques dizaines de TPS et tu n’as pas d’engagement sur la latence, cette architecture est excessive. Un service lisant-écrivant PostgreSQL avec quelques requêtes COUNT(*) indexées sur card_id, created_at suffit. N’ajoute Redis, l’outbox et Kafka que si les métriques montrent que tu en as vraiment besoin.

10. Conclusion

Cet article a conçu un système de détection de fraude en temps réel en séparant le chemin critique du chemin asynchrone. L’API synchrone exécute seulement les règles rapides, lit-écrit Redis via un script Lua atomique et enregistre la décision avec l’outbox dans une seule transaction. Tout travail plus lourd est repoussé sur Kafka.

Les trois points critiques ont chacun une solution concrète. La concurrence est gérée par des opérations atomiques et des clés par carte. La cohérence repose sur ON CONFLICT avec un outbox transactionnel. Les pics sont maîtrisés par des timeouts serrés, une politique de dégradation explicite et une file absorbant la charge. À partir de ce cadre, tu peux ajouter de nouvelles règles, brancher un modèle ML côté consommateur, puis réécrire ses scores dans Redis pour que l’API les utilise aux transactions suivantes.

system-designspring-bootrediskafkafraud-detection