Entwurf eines Echtzeit-Betrugserkennung-Systems

1. Übersicht

Jedes Mal, wenn ein Kunde seine Karte durchzieht, hat das Zahlungssystem nur ein paar Dutzend Millisekunden Zeit, um eine Frage zu beantworten: Ist diese Transaktion vertrauenswürdig? Zu lange Antwortzeiten führen zu schlechter Zahlungserfahrung. Eine falsche Antwort kostet dir Geld oder blockiert einen echten Kunden.

Dieser Artikel behandelt den Entwurf eines Echtzeit-Betrugserkennung-Systems. Der Umfang umfasst Anforderungen, Datenmodell, API und Verarbeitungsfluss. Der Artikel behandelt auch drei schwierige Punkte: Konkurrenz auf „heißen” Keys, Konsistenz bei Retries und Lastspitzen. Nach dem Lesen hast du ein Architektur-Framework mit Spring Boot 3, Redis, PostgreSQL und Kafka, das du selbst implementieren kannst.

2. Anforderungen

2.1. Funktionale Anforderungen

  • Eine Transaktion entgegennehmen und eine Entscheidung APPROVE, REVIEW oder DECLINE mit Risikopunkten und Begründung zurückgeben.
  • Verschiedene Regeln anwenden: ungewöhnliche Beträge, hohe Transaktionshäufigkeit (Velocity), Länder mit hohem Risiko.
  • Alle Entscheidungen speichern für Audit-Zwecke und zum Training zukünftiger Modelle.
  • Entscheidungen vom Typ REVIEW an die Untersuchungsabteilung (Case Management) weiterleiten.

2.2. Umfang und Constraints

Metrik Wert
Durchschnittslast 3.000 TPS
Spitzenlast (Black Friday, 11/11) 30.000 TPS
API-Latenz p99 < 50ms
Speicherung von Entscheidungen ~260 Millionen Datensätze/Tag bei Spitzenlast, 2 Jahre aufbewahren

Es gibt drei wichtige Constraints:

  1. Idempotent: Das Zahlungs-Gateway kann dieselbe Transaktion erneut versuchen. Jeder Retry muss die gleiche alte Entscheidung erhalten.
  2. Keine verlorenen Ereignisse: Eine Entscheidung, die zurückgegeben wurde, muss gespeichert und weitergeleitet werden.
  3. Erklärbar: Jedes DECLINE muss eine Liste mit spezifischen Gründen haben.

3. Architektur und Verarbeitungsfluss

Der Fluss ist in zwei Teile aufgeteilt. Der synchrone Teil liegt auf dem Zahlungspfad und muss schnell sein. Der asynchrone Teil macht alles andere.

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

Die spezifischen Schritte:

  1. API empfängt die Anfrage, prüft ob bereits eine Entscheidung für transactionId existiert.
  2. Führt die Regeln aus. Regeln, die Status benötigen (Velocity), lesen und schreiben Redis mit einem atomaren Befehl.
  3. Punkte addieren und Entscheidung basierend auf Schwellwert treffen.
  4. Entscheidung und Outbox-Datensatz in der gleichen PostgreSQL-Transaktion speichern.
  5. Ein Relay liest das Outbox und veröffentlicht zu Kafka. Consumer im Hintergrund beeinträchtigen die API auch bei Verzögerungen nicht.

4. Datenmodell

Du brauchst die folgenden Dependencies:

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

PostgreSQL-Schema:

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, used as Kafka key
    payload     JSONB       NOT NULL,
    published   BOOLEAN     NOT NULL DEFAULT false
);
CREATE INDEX idx_outbox_unpublished ON fraud_outbox (id) WHERE NOT published;

Die Tabelle wird nach Datum partitioniert. Dadurch kann das Löschen alter Daten einfach durch DROP der Partition erfolgen, nicht durch DELETE von Hunderten Millionen Zeilen.

Hinweis: Bei partitionierten Tabellen muss der Primärschlüssel die Partitionierungsspalte enthalten. In der Praxis würdest du PRIMARY KEY (transaction_id, created_at) verwenden. Um schnell nur nach transaction_id nachzuschlagen, behalte eine kleine idempotency_key-Tabelle mit TTL. Hier vereinfacht der Artikel die Lesbarkeit.

Der „heiße” Zustand befindet sich in Redis:

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

5. API

Request, Response und gemeinsame Datentypen:

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

Der Controller fungiert nur als Vermittler:

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

Ein Beispielaufruf und Ergebnis:

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. Rule Engine

Jede Regel ist ein unabhängiges Bean. Neue Regeln hinzufügen berührt keinen alten 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();
    }
}

Der Service addiert Punkte, trifft Entscheidungen und speichert das Ergebnis:

@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 → alte Entscheidung zurückgeben
        }
        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 injiziert automatisch alle FraudRule-Beans in List<FraudRule>. Der Punktewert wird auf 100 begrenzt. Die Schwellwerte 40 und 80 sollten aus Konfiguration kommen, damit das Risk-Team sie anpassen kann, ohne neu zu deployen.

7. Schwierige Punkte und Lösungen

7.1. Konkurrenz: Velocity Check auf heißen Keys

Die Regel „mehr als 5 Transaktionen in 60 Sekunden auf einer Karte” klingt einfach. Betrüger schicken aber gerne viele Requests parallel. Wenn du die Anzahl liest und dann schreibst, können zwei Requests beide 4 lesen und beide genehmigt werden. Das ist eine klassische Race Condition.

Die Lösung besteht darin, „alte entfernen, neue hinzufügen, zählen” in ein Lua-Skript zu packen. Redis führt dieses Skript atomar aus:

@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);  // graceful degrade, API nicht zusammenbrechen lassen
        }
    }
}

Es gibt drei beachtenswerte Details:

  • Member ist transactionId. Wenn das Gateway erneut versucht, aktualisiert ZADD nur den Score des alten Members, erhöht nicht die Anzahl. Die Regel ist daher selbst idempotent.
  • Nutze occurredAt statt Serverzeit. Das Ergebnis verschiebt sich nicht, wenn Pods Uhrabweichungen haben.
  • Redis Cluster verteilt nach Key. Jede Karte ist ein separater Key, also verteilt sich die Last gleichmäßig. Wirklich „heiße” Keys sind oft nach Merchant (velocity:merchant:*), wenn ein Merchant groß ist. Mit solchen Fällen, teile den Key in zeitliche Buckets auf, oder zähle approximativ im lokalen Speicher und synchronisiere regelmäßig.

7.2. Konsistenz: Idempotency und Outbox

Zwei häufige Konsistenzfehler:

  1. Zwei Requests mit gleicher transactionId kommen gleichzeitig an. Beide finden keine alte Entscheidung.
  2. DB-Speicherung erfolgreich aber Kafka-Veröffentlichung fehlgeschlagen, oder umgekehrt.

Fehler (1) wird durch unique Constraint und ON CONFLICT gelöst. Fehler (2) wird durch Outbox in der gleichen Transaktion gelöst:

@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();   // anderer Request hat gewonnen
        }
        jdbc.update("INSERT INTO fraud_outbox (aggregate_key, payload) VALUES (?, ?::jsonb)",
                tx.cardId(), toJson(result));
        return result;
    }

    // find(...) und toJson(...) weggelassen zur Kürze
}

inserted == 0 bedeutet, dass ein paralleler Request schon vorher geschrieben hat. Dann gibst du die Entscheidung dieses anderen Requests zurück. Dadurch erhalten zwei Aufrufe immer die gleiche Antwort.

Das Relay liest das Outbox in Batches und veröffentlicht mit Key als cardId. Alle Ereignisse einer Karte gehen in die gleiche Partition und bleiben in der richtigen Reihenfolge:

@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 ermöglicht mehreren Instances paralleles Relay, ohne sich gegenseitig zu blockieren. Dieser Mechanismus garantiert at-least-once. Daher muss der Consumer dahinter auch idempotent sein, z.B. mit transactionId als Schlüssel beim Erstellen eines Untersuchungsfalls.

7.3. Lastspitzen

Die Spitzenlast ist 10 mal so hoch wie der Durchschnitt. Es gibt drei Techniken, um diese Phase zu bewältigen:

Strikte Zeitbudgets. Setze kurze Timeouts für Redis und DB, damit eine langsame Komponente nicht die ganze API über 50ms treibt:

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 von Java 21 helfen, Tausende von Requests zu behalten, die auf I/O warten, ohne den Tomcat-Thread-Pool zu erschöpfen.

Klare Degradations-Richtlinie. Wenn Redis ausfällt, gibt VelocityRule VELOCITY_UNAVAILABLE mit 20 Punkten zurück. Das ist eine geschäftliche Wahl, nicht technisch. Fail-open (durchlassen) erhält Einnahmen. Fail-closed (blockieren) erhält Sicherheit. Lege diese Richtlinie mit dem Risk-Team vorher fest und protokolliere Metriken für jeden Degradation-Fall.

Schwere Aufgaben aus dem heißen Pfad schieben. Komplexe ML-Modelle, Aufrufe von Drittdiensten oder tägliche Feature-Berechnung laufen auf Kafka-Consumern. Kafka absorbiert Lastspitzen: Consumer-Lag von ein paar Minuten schadet nicht der Zahlung. Ergebnisse von dort werden in profile:card:* zurückgeschrieben, damit nachfolgende Transaktionen sie nutzen.

8. Testen der Hauptlogik

Der folgende Test überprüft Entscheidungslogik und Idempotency. Der Test nutzt Mock-Regeln und In-Memory-Repository, braucht nicht Redis oder 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);
    }
}

Der zweite Test ist sehr wichtig. Selbst wenn sich der externe Zustand zwischen den zwei Aufrufen ändert, darf ein Retry nicht APPROVE zu DECLINE ändern. Mit Lua-Skript solltest du ein Integration-Test mit Testcontainers schreiben. Der Test sendet 20 parallele Requests für die gleiche Karte und überprüft, dass nur die Requests ab der 6. VELOCITY_EXCEEDED erhalten.

9. Häufige Fehler und wann man diese nicht machen sollte

  • Komplexes ML-Modell synchron auf dem Zahlungspfad aufrufen ohne Timeout und Fallback. Ein 2-Sekunden-Delay im Modell verzögert das ganze Zahlungssystem.
  • Serverzeit für Velocity-Window verwenden. Uhrabweichungen von einigen Sekunden zwischen Pods führen zu falschen Zählungen.
  • Kafka direkt im Service veröffentlichen statt über Outbox. Du wirst „Geist”-Entscheidungen haben: zurückgegeben zum Gateway, aber kein Downstream weiß davon.
  • Velocity durch Lesen dann Schreiben zählen (GET dann INCR). Ein Betrüger sendet einfach parallel und umgeht es.
  • Redis als Quelle der Wahrheit behandeln. Redis hält nur den „heißen” Zustand und kann verloren gehen. Die Quelle der Wahrheit ist PostgreSQL und Kafka. Du musst Profile davon neu aufbauen können.

Hinweis: Wenn deine Last nur ein paar Dutzend TPS ist und du keine Latenz-Garantie hast, ist diese Architektur Overkill. Ein Service, der PostgreSQL liest und schreibt mit ein paar indizierten COUNT(*) Abfragen nach card_id, created_at reicht aus. Füge Redis, Outbox und Kafka nur hinzu, wenn Metriken zeigen, dass du sie wirklich brauchst.

10. Fazit

Der Artikel hat ein Echtzeit-Betrugserkennung-System entworfen, das die heißen und kalten Pfade nach einem Prinzip trennt. Die synchrone API führt nur schnelle Regeln aus, liest und schreibt Redis mit atomarem Lua-Skript und speichert Entscheidungen zusammen mit Outbox in einer Transaktion. Schwere Aufgaben werden durch Kafka verteilt.

Drei schwierige Punkte haben drei konkrete Lösungen. Konkurrenz wird durch atomare Operationen und Key-Partitionierung nach Karte gelöst. Konsistenz basiert auf ON CONFLICT mit transaktionalem Outbox. Lastspitzen werden durch strikte Timeouts, klare Degradations-Richtlinien und Last-absorbierende Warteschlangen kontrolliert. Von diesem Framework kannst du neue Regeln hinzufügen, ML-Modelle auf dem Consumer-Seite einstecken, dann Modell-Punkte zurück zu Redis schreiben, damit die API sie für nachfolgende Transaktionen nutzt.

system-designspring-bootrediskafkafraud-detection