リアルタイム取引不正検出システムの設計

1. 概要

顧客がカードをスワイプするたびに、決済システムはこの質問に数十ミリ秒で答える必要があります:この取引は信頼できるか? 遅い回答は悪い決済体験をもたらします。 間違った回答はお金を失うか、正規の顧客を誤ってブロックします。

この記事では、リアルタイム不正検出システムの設計を説明します。 範囲には、要件、データモデル、API、および処理フローが含まれます。 この記事では、3つの困難な問題も処理します:ホットキー上の競合、再試行時の一貫性、および急激な負荷です。 読後、Spring Boot 3、Redis、PostgreSQL、Kafkaを使用した自分で実装できるアーキテクチャ骨組みが得られます。

2. 要件

2.1. 機能要件

  • 取引を受け取り、リスクスコアと理由を含むAPPROVE、REVIEW、またはDECLINEの決定を返します。
  • ルールを適用する:異常な金額、高いトランザクション頻度(velocity)、リスクの高い国。
  • 監査とその後のモデル学習に用いるすべての決定を記録します。
  • REVIEWの決定をケース管理部門に送ります。

2.2. スケールと制約

指標 値
平均負荷 3,000 TPS
ピーク負荷(Black Friday、11/11) 30,000 TPS
APIレイテンシ p99 < 50ms
決定の保存 ピーク時約2.6億レコード/日、2年間保持

3つの重要な制約があります:

  1. べき等性:決済ゲートウェイは同じ取引を再試行できます。再試行のたびに、古い決定を再度受け取る必要があります。
  2. イベント喪失なし:返された決定は記録して発行される必要があります。
  3. 説明可能:すべてのDECLINEには具体的な理由リストが必要です。

3. アーキテクチャと処理フロー

フローは2つの部分に分かれています。同期部分は決済パス上にあり、高速である必要があります。非同期部分は残りのすべての処理を行います。

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

具体的なステップは次のとおりです:

  1. APIはリクエストを受け取り、transactionIdの決定がすでに存在するかどうかを確認します。
  2. ルールを実行します。 ステータスが必要なルール(velocity)はRedisから読み取り、原子的なコマンドで書き込みます。
  3. スコアを合計し、閾値に従って決定を下します。
  4. 決定とoutboxレコードを同じ PostgreSQL トランザクション内に書き込みます。
  5. Relayはoutboxを読み取り、Kafkaに発行します。 遅い後続のconsumerでもAPIに影響を与えません。

4. データモデル

次のdependencyが必要です:

<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 スキーマ:

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、Kafkaキーとして使用
    payload     JSONB       NOT NULL,
    published   BOOLEAN     NOT NULL DEFAULT false
);
CREATE INDEX idx_outbox_unpublished ON fraud_outbox (id) WHERE NOT published;

テーブルは日付ごとにパーティション化されます。 これにより、古いデータの削除は単なるDROPパーティションで、数億行のDELETEではなくなります。

注意: パーティション化されたテーブルでは、主キーにパーティション列を含める必要があります。 実際にはPRIMARY KEY (transaction_id, created_at)を使用します。 transactionId単独で高速にべき等性チェックを行いたい場合は、TTL付きの小さいidempotency_keyテーブルを保持します。 ここでは記事を簡潔にするために省略しています。

ホットステータスはRedisに保存されます:

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

5. API

リクエスト、レスポンス、および共有データ型:

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

Controllerは単に転送するだけです:

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

サンプルのリクエストと結果:

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. ルールエンジン

各ルールは独立したbeanです。 新しいルールを追加しても古いコードは変わりません:

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

Serviceはスコアを合計し、決定を下して結果を保存します:

@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();               // 再試行 → 古い決定を返す
        }
        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はFraudRuleのすべてのbeanをList<FraudRule>に自動注入します。 スコアは100でキャップされます。 40と80の閾値は設定ファイルから取得すべきで、デプロイを再度実行する必要はありません。

7. 困難な点と解決方法

7.1. 競合:ホットキーのvelocity check

「1つのカードで60秒以内に5回以上のトランザクション」というルールは単純に見えます。 不正な行為者は並行してリクエストを大量送信します。 カウントを読み取ってから書き込むと、2つのリクエストが4を読み取って両方が承認されます。 これは古典的なrace conditionです。

解決策は、「古いものを削除、新しいものを追加、数える」をLua scriptに組み込むことです。 Redisがこのscriptを原子的に実行します:

@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、APIを停止させない
        }
    }
}

3つの注目すべき詳細があります:

  • MemberはtransactionId。 ゲートウェイが再試行するとき、ZADDは古いmemberのscoreをアップデートするだけで、カウントを増やしません。 ルール自体がべき等になります。
  • occurredAtを使用(サーバー時刻ではなく)。 結果は各podの時計ズレの影響を受けません。
  • Redis Clusterはキーで分散。 各カードは独自のキーなので負荷が自動的に分散します。 本当のホットキーは通常、大規模なmerchant向けのvelocity:merchant:*です。 この場合、キーを時間バケットで分割するか、ローカルメモリで数えてから定期的に同期します。

7.2. 一貫性:べき等性とoutbox

2つの一貫性エラーがよくあります:

  1. 同じtransactionIdの2つのリクエストが同時に到着します。 どちらも古い決定を見つけられません。
  2. DBへの書き込みは成功しますがKafka publishが失敗します(またはその逆)。

エラー(1)は一意制約とON CONFLICTで解決されます。 エラー(2)は同じトランザクション内のoutboxで解決されます:

@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();   // 別のリクエストが勝ちました
        }
        jdbc.update("INSERT INTO fraud_outbox (aggregate_key, payload) VALUES (?, ?::jsonb)",
                tx.cardId(), toJson(result));
        return result;
    }

    // find(...) と toJson(...) は簡潔にするため省略
}

inserted == 0は別の並行リクエストが先に書き込んだことを意味します。 その場合、そのリクエストの決定を返します。 これにより、2回の呼び出しは常に同じ回答を受け取ります。

Relayはoutboxをバッチで読み取り、キーをcardIdとして使ってKafkaにpublishします。 1つのカードのすべてのイベントは同じpartitionに入るため、順序が保持されます:

@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により、複数のインスタンスが並行してrelayを実行でき、互いに足を踏まないようにします。 このメカニズムはat-least-onceを保証します。 したがって、下流のconsumerもべき等である必要があります。たとえば、ケース調査を作成するときはtransactionIdをキーとして使用します。

7.3. 急激な負荷

ピーク負荷は平均の10倍です。 3つのテクニックが役立ちます:

厳密なタイムバジェット。 Redisとデータベースに短いタイムアウトを設定して、遅いコンポーネントが50msを超えないようにします:

spring.data.redis.timeout=15ms
spring.datasource.hikari.connection-timeout=20
spring.datasource.hikari.maximum-pool-size=30
spring.threads.virtual.enabled=true

Java 21のvirtual threadは、Tomcatのthread poolを枯渇させることなく、I/Oを待機している数千のリクエストを保持するのに役立ちます。

明確なdegradeポリシー。 Redisが失敗すると、VelocityRuleは20ポイントでVELOCITY_UNAVAILABLEを返します。 これは技術的ではなく、ビジネス上の選択です。 Fail-open(通す)は収入を保持します。 Fail-closed(ブロック)は安全を保持します。 このポリシーをあらかじめリスク管理チームと決め、degradeのたびにmetricを記録してください。

重い処理をホットパスから外す。 複雑なMLモデル、サードパーティサービスの呼び出し、日次集計の計算はすべてKafka consumerで実行されます。 Kafkaは負荷の急増を吸収します:consumerがラグ数分あってもpaymentに影響しません。 その結果はprofile:card:*に書き込まれ、後続のトランザクションで使用されます。

8. 主要動作のテスト

次のテストは決定ロジックとべき等性をチェックします。 テストは模擬ルールとin-memoryリポジトリを使用し、Redisや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);
    }
}

2番目のテストはとても重要です。 2回の呼び出しの間に外部ステータスが変わった場合でも、再試行はAPPROVEからDECLINEに変更してはいけません。 Lua scriptを使用する場合、Testcontainersを使用した統合テストも追加してください。 同じカードに対して20個のリクエストを並行送信し、6番目以降のリクエストのみVELOCITY_EXCEEDEDがタグ付けされることを確認します。

9. よくあるミスといつそれを避けるべきか

  • 決済パスで同期的にMLモデルを呼び出す、タイムアウトとfallbackなし。 1回のモデル遅延2秒で決済システム全体が遅くなります。
  • Velocityウィンドウでサーバー時刻を使用する。 各podの時計がずれるとカウントが間違います。
  • Kafkaを直接serviceで発行する(outboxの代わりに)。 決定は「幽霊」になります:ゲートウェイに返ってきたが誰もdownstreamで知りません。
  • Velocityを「読む、その後加える」方式でカウント(GETの後INCR)。 不正な行為者が並行でリクエストを送信するだけで越えます。
  • Redisを事実の根拠と見なす。 Redisはホット状態のみで失われる可能性があります。 事実の根拠はPostgreSQLとKafkaです。 そこからプロファイルを再構築できる必要があります。

注意: あなたの負荷が数十TPS程度で遅延コミットメントがない場合、このアーキテクチャは過剰です。 card_id, created_atインデックス付きでCOUNT(*)クエリを数回実行するPostgres読み書きサービスで十分です。 メトリクスが実際に必要であることを示した場合にのみ、Redis、outbox、Kafkaを追加してください。

10. まとめ

この記事では、ホットパスをコールドパスから分離するという原則に基づいてリアルタイム不正検出システムを設計しました。 同期APIは高速ルールのみを実行し、Lua scriptで原子的にRedisを読み書きし、決定とoutboxを1つのトランザクションに書き込みます。 より重い処理はすべてKafkaを通じて行われます。

3つの困難な点に対して3つの具体的な解決策があります。 競合は原子的な操作とカード別キーで処理されます。 一貫性はON CONFLICTとtransactional outboxに基づきます。 急激な負荷は厳密なタイムアウト、明確なdegradeポリシー、負荷を吸収するキューで制御されます。 このフレームワークから、新しいルールを追加したり、複雑なMLモデルをconsumerにプラグインしたり、その結果をRedisに書き込んで後続のトランザクションで使用したりできます。

system-designspring-bootrediskafkafraud-detection