Skip to content

Event-Driven Architecture - Từ Bài Toán Ngân Hàng Đến Hệ Thống Chống Chịu Lỗi

By Nhân Nguyễn on Apr 20, 2026

Event-Driven Architecture - Từ Bài Toán Ngân Hàng Đến Hệ Thống Chống Chịu Lỗi

Chào bạn, trong bài viết này chúng ta sẽ cùng mổ xẻ Event‑Driven Architecture – một chủ đề không mới nhưng luôn nóng trong các hệ thống tài chính, thương mại điện tử hay bất kỳ nơi nào yêu cầu audit và khả năng mở rộng. Tôi sẽ không nói lý thuyết suông. Thay vào đó, chúng ta bắt đầu từ một cơn ác mộng có thật ở ngân hàng X, rồi lần theo các giải pháp kỹ thuật mà bạn có thể áp dụng ngay.


[1] Ác Mộng Tuân Thủ Tại Ngân Hàng X

Ngân hàng X nhận yêu cầu từ cơ quan quản lý: “Trong vòng 48 giờ kể từ bất kỳ thao tác nào trên tài khoản, phải cung cấp chính xác trạng thái của tài khoản đó tại bất kỳ thời điểm nào để phục vụ kiểm toán.”

Hiện tại, hệ thống của họ chỉ lưu trạng thái hiện tại của tài khoản:

SELECT * FROM accounts WHERE account_id = 123;
-- Trả về: balance = 1,000,000, last_updated = 2026-03-29

Khi kiểm toán viên hỏi:

  • “Số dư tài khoản 123 vào lúc 14:32:15 ngày 15/03/2026 là bao nhiêu?"
  • "Tài khoản này đã vượt hạn mức thấu chi bao nhiêu lần trong tháng 1?”

Hệ thống cũ không thể trả lời. Đội ngũ kỹ thuật đã cố gắng dùng một bảng account_changes để ghi log mọi thay đổi. Mỗi khi cần biết trạng thái quá khứ, họ replay lại các thay đổi từ đầu đến thời điểm cần truy vấn. Vấn đề? 5 triệu giao dịch mỗi ngày × 30 ngày = 150 triệu dòng. Câu query “state at 2026-03-15 14:32:15” quét qua 50 triệu dòng và mất 30 giây. Đây là một nỗi đau thực sự.

Vậy làm sao để trả lời những câu hỏi trên trong dưới 100ms? Đó chính là lúc Event Sourcing bước vào cuộc chơi.


[2] Mô Hình Tư Duy: Sự Kiện, Mệnh Lệnh Và Truy Vấn

Trước khi đi sâu vào code, chúng ta cần thống nhất ba khái niệm dễ gây nhầm lẫn nhất trong kiến trúc này: Command, Event, và Query.

Command (Mệnh lệnh)

Là một ý định (Intent). Nó là yêu cầu hệ thống làm điều gì đó, nhưng chưa chắc đã xảy ra.

// Chưa xảy ra, có thể thất bại vì số dư không đủ
{
  "type": "TransferMoneyCommand",
  "fromAccountId": 123,
  "toAccountId": 456,
  "amount": 100
}

Event (Sự kiện)

Là một sự thật đã xảy ra (Fact). Nó là bất biến (immutable) và được gắn mốc thời gian chính xác.

// Đã xảy ra, không thể thay đổi
{
  "type": "MoneyTransferredEvent",
  "fromAccountId": 123,
  "toAccountId": 456,
  "amount": 100,
  "timestamp": "2026-03-29T14:32:15Z",
  "transferId": "uuid-xyz"
}

Query (Truy vấn)

Là yêu cầu lấy dữ liệu mà không làm thay đổi trạng thái hệ thống.

Tại sao phải tách biệt? Bởi vì Command có thể không hợp lệ, nhưng một khi Event đã được tạo ra, nó là chân lý. Bạn có thể kiểm toán Event, tìm kiếm Event, nhưng bạn không thể sửa Event.

Event Sourcing: Lưu Trữ Chuỗi Sự Kiện Thay Vì Trạng Thái

Cách truyền thống (CRUD):

-- Bạn chỉ biết hiện tại, quá khứ đã mất
accounts: (account_id=123, balance=850, updated_at=2026-03-29)

Cách Event Sourcing:

-- Lưu mọi thứ đã xảy ra
account_events:
event_id=1 | AccountCreated     | amount=0    | 2026-01-01 10:00
event_id=2 | MoneyDeposited     | amount=1000 | 2026-01-01 11:00
event_id=3 | FeeCharged         | amount=-50  | 2026-01-02 09:00
event_id=4 | MoneyTransferred   | amount=-100 | 2026-03-25 14:32:15
event_id=5 | InterestCredited   | amount=10   | 2026-03-29 23:59:59

Để biết số dư lúc 14:32:00 ngày 25/03, bạn chỉ cần cộng dồn các event có timestamp nhỏ hơn thời điểm đó (1 + 2 + 3 = 950). Để biết số dư hiện tại, bạn cộng tất cả. Đây chính là khả năng temporal query – truy vấn xuyên thời gian.

Giải Quyết Vấn Đề Hiệu Năng Với Snapshot

Nếu một tài khoản có 5 triệu event trong 10 năm, việc replay từ đầu là bất khả thi. Snapshot Pattern là lời giải. Định kỳ (ví dụ mỗi tháng), hệ thống sẽ lưu lại một “bức ảnh” trạng thái của tài khoản tại một event sequence nhất định.

[Snapshot tháng 3] -> Balance: 500,000 (đã replay đến event 1000)
+ [Event 1001, 1002, 1003] (các event mới)
= Balance hiện tại (chỉ cần replay vài event gần đây)

[3] Triển Khai Thực Tế Trong Production

Dưới đây là cách bạn có thể “đóng gói” Event Sourcing trong một ứng dụng Java thực thụ, với đầy đủ các yếu tố: Event Store, Idempotency, và Projection.

3.1. Domain Event Base Class

Mọi event trong hệ thống đều kế thừa từ một base class chứa metadata (ID, timestamp, version).

public abstract class DomainEvent {
    private final String eventId;
    private final String aggregateId; // ID của đối tượng chịu tác động (tài khoản)
    private final Instant occurredAt;
    private final int eventVersion;   // Cực kỳ quan trọng cho việc migrate schema sau này

    protected DomainEvent(String aggregateId, int eventVersion) {
        this.eventId = UUID.randomUUID().toString();
        this.aggregateId = aggregateId;
        this.occurredAt = Instant.now();
        this.eventVersion = eventVersion;
    }
    // getters...
}

// Một event cụ thể
public class MoneyTransferredEvent extends DomainEvent {
    private static final int VERSION = 1;
    private final String fromAccountId;
    private final String toAccountId;
    private final Money amount;
    // constructor, getters...
}

3.2. Event Store: Trái Tim Của Hệ Thống

Event Store có trách nhiệm duy nhất: lưu trữ và truy xuất chuỗi sự kiện. Chúng ta triển khai nó bằng JPA nhưng logic domain hoàn toàn độc lập với database.

public interface EventStore {
    void appendEvents(String aggregateId, List<DomainEvent> events);
    List<DomainEvent> getEventsForAggregate(String aggregateId);
    EventStreamWithSnapshot getEventStream(String aggregateId); // Optimized với snapshot
}

@Repository
public class JpaEventStore implements EventStore {
    // ... implementation chi tiết như trong file outline
    // Quan trọng: Khi lưu event, luôn sắp xếp theo sequence (ORDER BY sequence ASC)
    // để đảm bảo thứ tự replay chính xác.
}

3.3. Idempotent Consumer: Kẻ Thù Của Duplicate Message

Trong thế giới message broker (Kafka, RabbitMQ), “at-least-once delivery” là mặc định. Điều gì xảy ra nếu event MoneyTransferred được gửi hai lần? Tài khoản bị trừ tiền hai lần.

Giải pháp: Idempotent Consumer.

@Service
public class IdempotentEventHandler {
    private final Jedis redis;

    @Transactional
    public void handleMoneyTransferred(MoneyTransferredEvent event) {
        String dedupKey = "processed_event:" + event.getEventId();
        
        // Nếu đã xử lý rồi thì bỏ qua
        if (redis.exists(dedupKey)) {
            return;
        }

        // Xử lý nghiệp vụ (cập nhật số dư)
        account.debit(event.getAmount());
        
        // Đánh dấu đã xử lý (set expire 24h để tránh tràn Redis)
        redis.setex(dedupKey, 86400, "done");
    }
}

3.4. Projection: Xây Dựng Read Model Tối Ưu

Việc query trực tiếp vào Event Store để lấy danh sách giao dịch là chậm. Thay vào đó, ta lắng nghe các event và cập nhật một bảng Read Model được thiết kế riêng cho việc tìm kiếm.

@Component
public class TransactionProjection {
    @EventListener
    public void on(MoneyTransferredEvent event) {
        TransactionReadModel view = new TransactionReadModel();
        view.setId(event.getEventId());
        view.setAmount(event.getAmount());
        // ... copy data
        readRepository.save(view); // Lưu vào bảng transaction_read_model
    }
}

Lúc này, câu query lịch sử giao dịch chỉ là một câu SELECT đơn giản trên một bảng đã được index, cho kết quả trong vài ms.


[4] Những Cạm Bẫy Chết Người (Anti-Patterns)

Sau đây là 5 sai lầm tôi đã chứng kiến ở nhiều dự án Event Sourcing thất bại.

1. Nhầm Lẫn Event và Command

  • Sai: MakePaymentEvent (Mang ý nghĩa “hãy thực hiện thanh toán”).
  • Hậu quả: Listener xử lý như thể payment đã thành công, nhưng thực tế validation thất bại ở service khác. Hệ thống rơi vào trạng thái không nhất quán.
  • Đúng: PaymentRequestedCommand (Intent) -> Xử lý -> PaymentCompletedEvent (Fact).

2. Không Có Chiến Lược Schema Evolution

  • Vấn đề: Event v1 có field amount, v2 thêm fee. Khi replay event cũ, code mới bị NullPointerException.
  • Giải pháp: Luôn lưu eventVersion. Khi deserialize, nếu version = 1 thì tự động map fee = 0 trước khi đưa vào handler.

3. Payload Quá Lớn

  • Sai: Gửi toàn bộ object Account nặng 10KB trong mỗi event AccountUpdated.
  • Hậu quả: Tốn băng thông Kafka, tốn ổ cứng lưu trữ Event Store.
  • Đúng: Event chỉ nên chứa delta (phần thay đổi). Ví dụ: MoneyWithdrawn(amount=100) thay vì AccountStateChanged(fullAccountJson).

4. Quên Mất Idempotency

  • Triệu chứng: Khách hàng phản ánh “Tôi chuyển 1 triệu nhưng bị trừ 2 triệu”.
  • Nguyên nhân: Kafka retry do mạng chập chờn, consumer không check trùng lặp.
  • Phòng tránh: Bắt buộc dùng Deduplication Key (Event ID) ở mọi Consumer xử lý giao dịch tài chính.

5. Không Test Replay Trên Production

  • Thực tế đau thương: Một ngày đẹp trời cần khôi phục dữ liệu từ backup event log. Bạn nhấn nút “Replay”. Hệ thống crash vì event 2 năm trước có field null mà code mới không handle.
  • Yêu cầu: Phải có Replay Test định kỳ, chạy trên dữ liệu thật (đã anonymized) để đảm bảo tính tương thích ngược.

[5] Góc Phỏng Vấn: Bạn Sẽ Trả Lời Thế Nào?

Để kết thúc bài viết, tôi muốn tặng bạn bộ khung trả lời 3 tầng cho các câu hỏi về Event Sourcing khi đi phỏng vấn Senior/Architect.

Tầng 1: Bề Nổi (Surface Level)

Hỏi: “Event Sourcing là gì? Tại sao không dùng CRUD?” Đáp: “Event Sourcing lưu chuỗi sự kiện bất biến thay vì trạng thái cuối cùng. Nó cho phép audit toàn diện, truy vấn trạng thái tại bất kỳ thời điểm nào trong quá khứ (temporal query), và dễ dàng debug bằng cách replay. Đánh đổi là độ phức tạp hệ thống và dung lượng lưu trữ tăng.”

Tầng 2: Chuyên Sâu (Deep Dive)

Hỏi: “Thiết kế hệ thống Event Store cho 50 triệu event/năm, làm sao query số dư quá khứ trong <100ms?” Đáp: “Tôi sẽ không query trực tiếp Event Store. Tôi dùng Snapshot Pattern để lưu trạng thái định kỳ (hàng tháng). Khi cần query balance at T, tôi lấy snapshot gần nhất trước T và chỉ replay các event từ snapshot đó đến T. Song song đó, tôi xây dựng Read Model Projection để phục vụ các truy vấn thường xuyên.”

Tầng 3: Kiến Trúc (Architecture)

Hỏi: “Xử lý giao dịch chuyển tiền liên ngân hàng với 2 service tách biệt database. Choreography hay Orchestration?” Đáp: “Với nghiệp vụ tài chính yêu cầu tính nhất quán cao và khả năng hoàn tiền (compensation) rõ ràng, tôi chọn Orchestration (SAGA). Tuy có bottleneck ở Coordinator nhưng flow minh bạch, dễ giám sát và xử lý lỗi. Choreography phù hợp hơn cho các flow không đồng bộ, ít ràng buộc như gửi email thông báo sau khi chuyển tiền thành công.”


Kết Luận

Event-Driven Architecture nói chung và Event Sourcing nói riêng không phải là “viên đạn bạc”. Nó đánh đổi sự đơn giản của CRUD để lấy khả năng truy vết và độ tin cậy tuyệt đối của dữ liệu lịch sử.

Nếu hệ thống của bạn có Audit Trail là tính năng sống còn (Banking, Healthcare, Logistics), đây là kiến trúc bạn nên đầu tư. Nếu bạn chỉ làm một trang Blog cá nhân, hãy tránh xa nó để giữ cho giấc ngủ ngon.

Hy vọng qua bài viết này, bạn đã có cái nhìn sâu sắc và thực tế hơn về Event Sourcing, từ lý thuyết đến những dòng code “chiến đấu” được trong Production.

Hãy kết nối

Nếu bạn quan tâm tới việc hợp tác, có câu hỏi về bài viết, hay chỉ đơn giản muốn chuyện trò về backend — cứ ping mình nhé.