Event Sourcing
Xem thửSố dư hiện tại là một con số, hay là lịch sử của mọi giao dịch?
Ví của khách trên gdev-shop có số dư 250.000 đồng. Cách lưu truyền thống: một cột balance trong bảng wallets, mỗi giao dịch cập nhật giá trị đó. Khách khiếu nại: "Tôi nhớ có 400.000, sao giờ chỉ còn 250.000?" Bạn nhìn vào bảng và thấy duy nhất con số 250.000. Lịch sử đã mất: bạn không biết tiền đã đi đâu, khi nào, vì lý do gì. Phải mò từ log, nếu còn.
Có một cách lưu khác: không lưu số dư, chỉ lưu từng việc đã xảy ra. Tính số dư bằng cách cộng dồn các sự việc đó. Đó là Event Sourcing.
Event Sourcing là gì?
Event Sourcing là mẫu thiết kế trong đó trạng thái của hệ thống không được lưu trực tiếp, mà mọi thay đổi được lưu dưới dạng chuỗi sự kiện bất biến, chỉ ghi thêm (append-only). Trạng thái hiện tại được tính ra bằng cách phát lại các sự kiện từ đầu.
So sánh với cách truyền thống:
| Lưu trạng thái (CRUD) | Event Sourcing | |
|---|---|---|
| Lưu gì | Giá trị hiện tại (balance = 250000) | Chuỗi sự kiện (Deposited 500000, Spent 250000) |
| Cập nhật | Ghi đè giá trị cũ | Thêm một sự kiện mới, không sửa cái cũ |
| Lịch sử | Mất, trừ khi tự lưu thêm | Có sẵn và là nguồn dữ liệu gốc |
| Trạng thái hiện tại | Đọc trực tiếp | Tính bằng cách cộng dồn (fold) |
Ví dụ ví tiền
Chuỗi sự kiện của ví khách An, lưu theo thứ tự:
| Thứ tự | Sự kiện | Số tiền |
|---|---|---|
| 1 | WalletOpened | |
| 2 | Deposited | +500.000 |
| 3 | PaymentMade (đơn 1001) | −150.000 |
| 4 | Deposited | +100.000 |
| 5 | PaymentMade (đơn 1002) | −200.000 |
Số dư hiện tại: 500.000 trừ 150.000 cộng 100.000 trừ 200.000 bằng 250.000. Câu hỏi "tiền đã đi đâu" có câu trả lời ngay: hai đơn hàng cụ thể.
Một cách tính trạng thái bằng Java (minh họa ý tưởng, không phải thư viện cụ thể):
public long balanceOf(List<WalletEvent> events) {
long balance = 0;
for (WalletEvent event : events) {
if (event instanceof Deposited deposited) {
balance += deposited.amount();
} else if (event instanceof PaymentMade payment) {
balance -= payment.amount();
}
}
return balance;
}
Hàm duyệt sự kiện theo thứ tự và áp dụng từng sự kiện vào trạng thái. Đây là phép fold (gấp lại): từ danh sách sự kiện ra một trạng thái duy nhất.
Luồng hoạt động
Đang tải Animation…
Cách đọc hình
- Một command (yêu cầu thực hiện việc gì đó, ví dụ thanh toán 150.000) đến hệ thống.
- Hệ thống kiểm tra command dựa trên trạng thái hiện tại (tính từ các sự kiện): số dư có đủ không?
- Nếu hợp lệ, hệ thống ghi thêm một sự kiện
PaymentMade. Nếu không, trả lỗi và không ghi gì. Sự kiện là việc đã xảy ra, nên chỉ ghi khi đã chấp nhận. - Sự kiện được thêm vào event store, nơi chỉ ghi thêm, không sửa, không xóa.
- Từ event store có thể dựng ra nhiều projection (hình chiếu): số dư hiện tại cho màn hình ví, và các truy vấn lịch sử và kiểm toán, nơi có thể phát lại bất kỳ thời điểm nào.
Ý nghĩa quan trọng: sự kiện là nguồn sự thật; các bảng số dư chỉ là bản dựng có thể làm lại bất cứ lúc nào từ sự kiện. Sửa sai bằng một sự kiện bù chứ không sửa sự kiện cũ.
Đang tải Animation…
Đang tải Animation…
Snapshot: tránh phát lại cả triệu sự kiện
Một ví dùng 5 năm có thể có hàng chục nghìn sự kiện. Phát lại từ đầu mỗi lần đọc sẽ chậm. Giải pháp: snapshot (ảnh chụp trạng thái). Định kỳ (ví dụ cứ 1.000 sự kiện), lưu lại trạng thái tại điểm đó. Khi cần dựng trạng thái, bắt đầu từ snapshot gần nhất rồi chỉ phát lại các sự kiện sau nó. Snapshot chỉ là tối ưu hiệu năng, không phải nguồn dữ liệu gốc: nếu mất, dựng lại được từ sự kiện.
Lợi ích
- Lịch sử đầy đủ và kiểm toán: biết chính xác chuyện gì đã xảy ra, theo thứ tự, tại sao.
- Truy vấn theo thời gian: "số dư của ví lúc 15 giờ hôm qua là bao nhiêu?" bằng cách chỉ phát lại đến thời điểm đó.
- Sửa lỗi nhờ phát lại: phát hiện bug trong cách tính, sửa code rồi dựng lại projection từ sự kiện đã lưu.
- Nhiều góc nhìn dữ liệu: cùng một chuỗi sự kiện dựng nhiều projection khác nhau cho nhiều nhu cầu đọc.
- Hợp tự nhiên với EDA: các sự kiện đã lưu có thể được phát ra cho các service khác.
Cái giá và những điều dễ sai
- Độ phức tạp tư duy: phải nghĩ theo sự kiện và projection thay vì bảng dữ liệu quen thuộc.
- Tiến hóa cấu trúc sự kiện: sự kiện bất biến và lưu mãi, nên cấu trúc cũ phải đọc được suốt đời. Đổi cấu trúc cần chiến lược nâng cấp (upcasting: chuyển sự kiện cũ sang dạng mới khi đọc).
- Truy vấn khó: không thể hỏi trực tiếp "những ví nào có số dư trên 1 triệu" từ event store. Cần projection phục vụ truy vấn đó.
- Nhất quán sau một khoảng thời gian: projection cập nhật sau khi ghi sự kiện, nên người đọc có thể thấy dữ liệu cũ trong chốc lát.
- Xóa dữ liệu cá nhân: luật bảo vệ dữ liệu có thể yêu cầu xóa thông tin cá nhân, trong khi sự kiện bất biến. Cần thiết kế riêng (ví dụ mã hóa dữ liệu cá nhân bằng khóa riêng rồi hủy khóa).
- Phát lại cần idempotent: xử lý cùng sự kiện hai lần không được gây hậu quả ngoài ý muốn.
Khi nào dùng, khi nào chưa cần?
Hợp: miền cần kiểm toán chặt và lịch sử có giá trị (tài chính, kho, vận chuyển); logic phức tạp theo trạng thái thay đổi theo thời gian; cần nhiều góc nhìn dữ liệu.
Chưa cần: ứng dụng CRUD thông thường (danh sách sản phẩm, hồ sơ người dùng) mà lịch sử không quan trọng; nhóm chưa quen mô hình; không có nhu cầu truy vấn theo thời gian hay kiểm toán.
Một điểm thường được kết hợp: CQRS (Command Query Responsibility Segregation), tách mô hình ghi (command, sự kiện) khỏi mô hình đọc (projection). Event Sourcing và CQRS thường đi cùng nhau nhưng là hai mẫu độc lập. Ở mức Good to Know.
Những hiểu lầm phổ biến
- "Event Sourcing là EDA." EDA là cách các thành phần giao tiếp bằng sự kiện. Event Sourcing là cách lưu trữ trạng thái dưới dạng sự kiện. Chúng liên quan nhưng độc lập: có thể dùng EDA mà vẫn lưu CRUD, và có thể dùng Event Sourcing mà không phát sự kiện ra ngoài.
- "Event Sourcing luôn tốt hơn CRUD." Nó mạnh ở kiểm toán và lịch sử, nhưng thêm nhiều phức tạp. Với CRUD thông thường là quá mức.
- "Có thể sửa hoặc xóa sự kiện sai." Sự kiện bất biến. Sửa sai bằng cách thêm sự kiện bù (ví dụ
PaymentRefunded), như sổ kế toán không tẩy xóa. - "Snapshot là nguồn dữ liệu." Chỉ là bản tối ưu, nguồn gốc vẫn là sự kiện.
Must Know, Good to Know, Advanced
- Must Know: lưu sự kiện thay vì trạng thái; trạng thái tính bằng fold; append-only và bất biến; sửa sai bằng sự kiện bù; projection; snapshot là tối ưu.
- Good to Know: truy vấn theo thời gian; tiến hóa cấu trúc sự kiện; xóa dữ liệu cá nhân; kết hợp CQRS.
- Advanced: upcasting, event store chuyên dụng, kiểm soát đồng thời bằng phiên bản. Chỉ cần biết tên và mục đích.
Tự kiểm tra
- Với chuỗi sự kiện ví ở trên, thêm một sự kiện
PaymentRefunded +150.000cho đơn 1001. Số dư mới là bao nhiêu và vì sao không sửa sự kiện cũ? - Event Sourcing khác EDA ở điểm nào?
- Vì sao cần snapshot, và nếu mất snapshot thì hệ thống có mất dữ liệu không?
Đáp án gợi ý
- Trước sự kiện hoàn tiền, số dư là 250.000 (theo chuỗi 500.000 trừ 150.000 cộng 100.000 trừ 200.000). Thêm
PaymentRefunded +150.000cho đơn 1001 thì số dư mới là 400.000. Không sửa sự kiện cũ vì sự kiện là sự thật đã xảy ra, bất biến: sửa sẽ làm mất lịch sử (khách đã thanh toán, sau đó được hoàn). Sửa sai bằng cách thêm sự kiện bù, như sổ kế toán. - EDA là cách các thành phần giao tiếp bằng sự kiện (sự kiện là thông báo giữa các bên, có thể bị bỏ). Event Sourcing là cách lưu trữ trạng thái của một thực thể bằng chuỗi sự kiện là nguồn sự thật. Có thể dùng một mà không cần cái kia, dù thường đi cùng nhau.
- Snapshot giúp không phải phát lại từ sự kiện đầu tiên mỗi lần tính trạng thái khi chuỗi rất dài. Mất snapshot thì không mất dữ liệu, vì nguồn sự thật là chuỗi sự kiện: chỉ cần phát lại để dựng lại snapshot (chậm hơn một lần).
Nếu chỉ nhớ 3 điều
- Event Sourcing lưu chuỗi sự kiện bất biến, chỉ ghi thêm làm nguồn sự thật; trạng thái hiện tại được tính bằng cách phát lại.
- Sửa sai bằng sự kiện bù, không sửa sự kiện cũ; snapshot chỉ là tối ưu, projection phục vụ truy vấn.
- Mạnh về kiểm toán và lịch sử, nhưng phức tạp ở tiến hóa cấu trúc, truy vấn và nhất quán; không cần cho CRUD thông thường.