Thiết kế hệ thống phát hiện gian lận giao dịch theo thời gian thực

1. Tổng quan

Mỗi lần khách hàng quẹt thẻ, hệ thống thanh toán có vài chục mili-giây để trả lời câu hỏi: giao dịch này có đáng tin không? Trả lời chậm thì trải nghiệm thanh toán tệ. Trả lời sai thì bạn mất tiền, hoặc chặn nhầm khách thật.

Bài này đi qua thiết kế một hệ thống phát hiện gian lận theo thời gian thực. Phạm vi gồm yêu cầu, mô hình dữ liệu, API và luồng xử lý. Bài cũng xử lý ba điểm khó: tranh chấp trên key nóng, nhất quán khi retry, và tải đột biến. Đọc xong, bạn có một khung kiến trúc dùng Spring Boot 3, Redis, PostgreSQL và Kafka để tự triển khai.

2. Yêu cầu

2.1. Yêu cầu chức năng

  • Nhận một giao dịch và trả về quyết định APPROVE, REVIEW hoặc DECLINE, kèm điểm rủi ro và lý do.
  • Áp dụng các rule: số tiền bất thường, tần suất giao dịch cao (velocity), quốc gia rủi ro.
  • Lưu lại mọi quyết định để phục vụ audit và huấn luyện model sau này.
  • Đẩy quyết định REVIEW sang bộ phận điều tra (case management).

2.2. Quy mô và ràng buộc

Chỉ số Giá trị
Tải trung bình 3.000 TPS
Tải đỉnh (Black Friday, 11/11) 30.000 TPS
Độ trễ API p99 < 50ms
Lưu trữ quyết định ~260 triệu bản ghi/ngày ở đỉnh, giữ 2 năm

Có ba ràng buộc quan trọng:

  1. Idempotent: cổng thanh toán có thể retry cùng một giao dịch. Mỗi lần retry phải nhận lại đúng quyết định cũ.
  2. Không mất sự kiện: quyết định đã trả về thì phải được ghi và phát đi.
  3. Giải thích được: mọi DECLINE phải có danh sách lý do cụ thể.

3. Kiến trúc và luồng xử lý

Luồng chia làm hai phần. Phần đồng bộ nằm trên đường thanh toán và phải nhanh. Phần bất đồng bộ làm mọi việc còn lại.

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

Các bước cụ thể:

  1. API nhận request, kiểm tra đã có quyết định cho transactionId chưa.
  2. Chạy các rule. Rule cần trạng thái (velocity) đọc và ghi Redis bằng một lệnh atomic.
  3. Cộng điểm và ra quyết định theo ngưỡng.
  4. Ghi quyết định và bản ghi outbox trong cùng một transaction PostgreSQL.
  5. Một relay đọc outbox rồi publish sang Kafka. Consumer phía sau xử lý chậm cũng không ảnh hưởng API.

4. Mô hình dữ liệu

Bạn cần các dependency sau:

<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,   -- card_id, dùng làm Kafka key
    payload     JSONB       NOT NULL,
    published   BOOLEAN     NOT NULL DEFAULT false
);
CREATE INDEX idx_outbox_unpublished ON fraud_outbox (id) WHERE NOT published;

Bảng được partition theo ngày. Nhờ vậy, xoá dữ liệu cũ chỉ cần DROP partition, không phải DELETE hàng trăm triệu dòng.

Lưu ý: Với bảng partition, primary key phải chứa cột partition. Thực tế bạn sẽ dùng PRIMARY KEY (transaction_id, created_at). Muốn tra idempotency nhanh theo riêng transaction_id, hãy giữ thêm một bảng nhỏ idempotency_key có TTL. Ở đây bài đơn giản hoá cho dễ đọc.

Trạng thái nóng nằm trong Redis:

velocity:card:{cardId}     ZSET  member=transactionId, score=epochMillis   TTL 10 phút
profile:card:{cardId}      HASH  avgAmount, homeCountry                     TTL 30 ngày

5. API

Request, response và các kiểu dữ liệu dùng chung:

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 chỉ làm nhiệm vụ chuyển tiếp:

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

Một lời gọi mẫu và kết quả:

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

Mỗi rule là một bean độc lập. Thêm rule mới không đụng vào code cũ:

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 cộng điểm, ra quyết định và lưu kết quả:

@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 → trả lại quyết định cũ
        }
        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 tự inject mọi bean FraudRule vào List<FraudRule>. Điểm bị chặn ở 100. Ngưỡng 40 và 80 nên đưa ra cấu hình để đội rủi ro tự chỉnh, không cần deploy lại.

7. Các điểm khó và cách giải

7.1. Tranh chấp: velocity check trên key nóng

Rule “quá 5 giao dịch trong 60 giây trên một thẻ” nghe đơn giản. Kẻ gian lại hay bắn hàng loạt request song song. Nếu bạn đọc số đếm rồi mới ghi, hai request cùng đọc ra 4 và cùng được duyệt. Đó là race condition kinh điển.

Cách giải là gộp “xoá cũ, thêm mới, đếm” vào một Lua script. Redis chạy script này atomic:

@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, không làm sập API
        }
    }
}

Có ba chi tiết đáng chú ý:

  • Member là transactionId. Khi gateway retry, ZADD chỉ cập nhật score của member cũ, không tăng số đếm. Rule vì thế tự idempotent.
  • Dùng occurredAt thay vì giờ máy chủ. Kết quả không lệch khi các pod lệch đồng hồ.
  • Redis Cluster phân tán theo key. Mỗi thẻ là một key riêng nên tải tự chia đều. Key nóng thật sự thường là theo merchant (velocity:merchant:*) với một merchant lớn. Với loại này, hãy chia nhỏ key theo bucket thời gian, hoặc đếm xấp xỉ trong bộ nhớ local rồi đồng bộ định kỳ.

7.2. Nhất quán: idempotency và outbox

Hai lỗi nhất quán thường gặp:

  1. Hai request trùng transactionId đến cùng lúc. Cả hai đều không tìm thấy quyết định cũ.
  2. Ghi DB thành công nhưng publish Kafka thất bại, hoặc ngược lại.

Lỗi (1) được giải bằng ràng buộc unique và ON CONFLICT. Lỗi (2) được giải bằng outbox trong cùng 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();   // request khác đã thắng
        }
        jdbc.update("INSERT INTO fraud_outbox (aggregate_key, payload) VALUES (?, ?::jsonb)",
                tx.cardId(), toJson(result));
        return result;
    }

    // find(...) và toJson(...) lược bỏ cho gọn
}

inserted == 0 nghĩa là một request song song đã ghi trước. Lúc đó bạn trả về quyết định của request kia. Nhờ vậy, hai lần gọi luôn nhận cùng một câu trả lời.

Relay đọc outbox theo lô và publish với key là cardId. Mọi sự kiện của một thẻ vào cùng partition nên giữ đúng thứ tự:

@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 cho phép nhiều instance chạy relay song song mà không giẫm chân nhau. Cơ chế này đảm bảo at-least-once. Vì vậy consumer phía sau cũng phải idempotent, ví dụ dùng transactionId làm khoá khi tạo case điều tra.

7.3. Tải đột biến

Tải đỉnh gấp 10 lần trung bình. Có ba kỹ thuật giúp bạn qua đợt này:

Ngân sách thời gian chặt. Đặt timeout ngắn cho Redis và DB để một thành phần chậm không kéo cả API vượt 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 thread của Java 21 giúp giữ hàng nghìn request đang chờ I/O mà không cạn thread pool của Tomcat.

Chính sách degrade rõ ràng. Khi Redis lỗi, VelocityRule trả về VELOCITY_UNAVAILABLE với 20 điểm. Đó là một lựa chọn kinh doanh, không phải kỹ thuật. Fail-open (cho qua) giữ doanh thu. Fail-closed (chặn) giữ an toàn. Hãy chốt chính sách này với đội rủi ro từ trước và ghi metric cho từng lần degrade.

Đẩy việc nặng ra ngoài đường nóng. Model ML phức tạp, gọi dịch vụ bên thứ ba hay tính feature theo ngày đều chạy trên consumer Kafka. Kafka hấp thụ đợt tăng tải: consumer có tụt lag vài phút cũng không ảnh hưởng thanh toán. Kết quả từ đó được ghi ngược vào profile:card:* để các giao dịch sau dùng.

8. Kiểm thử hành vi chính

Test sau kiểm tra logic ra quyết định và idempotency. Test dùng rule giả và repository trong bộ nhớ, không cần Redis hay 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);
    }
}

Test thứ hai rất quan trọng. Trạng thái bên ngoài có thay đổi giữa hai lần gọi thì một lần retry cũng không được đổi APPROVE thành DECLINE. Với Lua script, bạn nên viết thêm integration test bằng Testcontainers. Test đó bắn 20 request song song cho cùng một thẻ và kiểm tra rằng chỉ các request từ thứ 6 trở đi bị gắn VELOCITY_EXCEEDED.

9. Lỗi hay gặp và khi nào không nên làm như vậy

  • Gọi model ML đồng bộ trên đường thanh toán mà không có timeout và fallback. Một lần model chậm 2 giây là cả hệ thống thanh toán chậm theo.
  • Dùng giờ máy chủ cho velocity window. Pod lệch đồng hồ vài giây là số đếm sai.
  • Publish Kafka trực tiếp trong service thay vì qua outbox. Bạn sẽ có quyết định “ma”: đã trả về cho gateway nhưng không ai downstream biết.
  • Đếm velocity theo kiểu đọc rồi ghi (GET rồi INCR). Kẻ gian chỉ cần bắn song song là vượt qua.
  • Coi Redis là nguồn sự thật. Redis chỉ giữ trạng thái nóng và có thể mất. Nguồn sự thật là PostgreSQL và Kafka. Bạn phải dựng lại được profile từ đó.

Lưu ý: Nếu tải của bạn chỉ vài chục TPS và không có cam kết độ trễ, kiến trúc này là quá tay. Một service đọc ghi PostgreSQL kèm vài truy vấn COUNT(*) có index theo card_id, created_at là đủ. Hãy chỉ thêm Redis, outbox và Kafka khi số liệu cho thấy bạn thực sự cần.

10. Kết luận

Bài đã thiết kế một hệ thống phát hiện gian lận theo thời gian thực theo nguyên tắc tách đường nóng khỏi đường lạnh. API đồng bộ chỉ chạy các rule nhanh, đọc ghi Redis bằng Lua script atomic và ghi quyết định cùng outbox trong một transaction. Mọi việc nặng hơn được đẩy qua Kafka.

Ba điểm khó có ba lời giải cụ thể. Tranh chấp được xử lý bằng thao tác atomic và key theo thẻ. Nhất quán dựa vào ON CONFLICT cùng transactional outbox. Tải đột biến được kiểm soát bằng timeout chặt, chính sách degrade rõ ràng và hàng đợi hấp thụ tải. Từ khung này, bạn có thể thêm rule mới, cắm model ML ở phía consumer, rồi đưa điểm model ngược về Redis để API dùng cho các giao dịch tiếp theo.

system-designspring-bootrediskafkafraud-detection