Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn đang xây hệ thống ví điện tử cho một startup fintech ở TP.HCM. Một buổi sáng, bộ phận chăm sóc khách hàng nhận được khiếu nại: một người dùng khẳng định số dư của họ là 500.000đ, nhưng hệ thống lại hiển thị 320.000đ. Bạn mở database ra và thấy đúng một dòng: balance = 320000. Chỉ vậy thôi. Không có gì cho biết vì sao con số này là 320.000, nó đã đi qua những giao dịch nào, ai đã chạm vào nó, và lúc nào. Bạn bị mù thông tin. Cách duy nhất để điều tra là lục lọi log ứng dụng — nếu log còn được giữ.
Đây chính là điểm yếu cốt lõi mà Event Sourcing ra đời để giải quyết. Trong cách lưu trữ truyền thống, chúng ta chỉ giữ trạng thái hiện tại (current state) và ghi đè nó mỗi khi có thay đổi. Lịch sử của việc "làm sao đến được đây" bị vứt đi. Event Sourcing lật ngược nguyên tắc này: thay vì lưu trạng thái, ta lưu toàn bộ chuỗi sự kiện đã tạo ra trạng thái đó, và trạng thái hiện tại chỉ là kết quả cộng dồn của các sự kiện.
Bài này không dạy bạn CQRS (bài 33 đã lo phần đó) hay cách chọn message broker (bài 30–32). Ở đây ta tập trung vào đúng một câu hỏi kiến trúc: Event Sourcing thực chất là gì, nó cho bạn những gì, phải trả giá gì, và trong hoàn cảnh nào một Principal/Staff Architect nên — hoặc tuyệt đối không nên — chọn nó. Đây là một trong những quyết định "một chiều" (khó đảo ngược nhất) trong kiến trúc, nên hiểu đúng bản chất là bắt buộc.
Khái niệm cốt lõi
State là kết quả, Event là nguồn sự thật
Ý tưởng trung tâm có thể tóm gọn trong một phương trình:
current_state = fold(events, initial_state)
Tức là, trạng thái hiện tại của một entity = tổng hợp (fold/reduce) tất cả các sự kiện đã xảy ra với nó, tính từ trạng thái khởi tạo. Ta không lưu state trực tiếp, mà lưu chuỗi event và replay (phát lại) chúng để tính ra state.
Lấy ví dụ một tài khoản ngân hàng. Cách truyền thống lưu:
Account { id: "acc-01", balance: 320000 }
Cách Event Sourcing lưu một chuỗi bất biến (immutable) các event:
AccountOpened { acc: "acc-01", at: 09:00, initial: 0 }
MoneyDeposited { acc: "acc-01", at: 09:15, amount: 500000 }
MoneyWithdrawn { acc: "acc-01", at: 10:30, amount: 200000 }
MoneyDeposited { acc: "acc-01", at: 11:00, amount: 100000 }
TransferFeeCharged { acc: "acc-01", at: 11:00, amount: 80000 }
Muốn biết số dư? Ta replay: 0 + 500000 - 200000 + 100000 - 80000 = 320000. Và bây giờ, khi khách hàng khiếu nại, bạn không mù nữa. Bạn có nguyên bản đồ đường đi của từng đồng: khi nào vào, khi nào ra, phí gì đã bị trừ. Đây là "audit log" nhưng không phải là thứ phụ thêm — nó chính là database của bạn.
Ba đặc tính bắt buộc của Event
Không phải cứ ghi log là Event Sourcing. Event trong Event Sourcing phải thỏa ba tính chất:
- Immutable (bất biến): Một khi event đã được ghi (append), nó không bao giờ được sửa hay xóa. Nếu sai, bạn ghi thêm một event bù trừ (compensating event), ví dụ
MoneyRefunded, chứ không xóa event cũ. - Diễn tả sự việc đã xảy ra ở thì quá khứ: Tên event luôn là
OrderPlaced,PaymentCaptured,ItemShipped— không phải mệnh lệnhPlaceOrder. Event là fact (sự thật đã rồi), khác với command (yêu cầu, có thể bị từ chối). - Append-only (chỉ thêm vào cuối): Event store về bản chất là một cuốn sổ cái (ledger) chỉ ghi thêm, giống sổ kế toán của một doanh nghiệp — bạn không tẩy xóa dòng cũ.
Snapshot — giải cứu hiệu năng
Nếu một entity có 2 triệu event thì mỗi lần đọc số dư phải replay 2 triệu event ư? Đó là lý do có snapshot: định kỳ (ví dụ mỗi 500 event) ta lưu lại một ảnh chụp trạng thái tại thời điểm đó. Khi cần tính state, ta lấy snapshot gần nhất rồi chỉ replay các event sau snapshot. Snapshot là tối ưu hóa, không phải nguồn sự thật — bạn luôn có thể vứt hết snapshot và replay lại từ đầu.
Ba năng lực "siêu năng lực" mà Event Sourcing mang lại
- Time travel: Bạn có thể tái dựng trạng thái hệ thống tại bất kỳ thời điểm nào trong quá khứ. "Số dư tài khoản này lúc 23:59 ngày 31/12 là bao nhiêu?" — chỉ cần replay đến đúng event đó.
- Audit hoàn hảo, miễn phí: Với ngành tài chính, bảo hiểm, y tế — nơi luật (như quy định của Ngân hàng Nhà nước) yêu cầu lưu vết mọi thay đổi — audit trail không còn là gánh nặng phải build thêm; nó là bản chất của hệ thống.
- Xây model mới từ dữ liệu cũ: Vì bạn giữ nguyên chuỗi event thô, sau này bạn có thể "phát minh ra câu hỏi mới" và trả lời nó bằng dữ liệu quá khứ. Đội phân tích muốn biết "bao nhiêu khách nạp tiền rồi rút ngay trong 5 phút"? Bạn replay event và dựng một projection mới — dù lúc thiết kế ban đầu chưa ai nghĩ tới câu hỏi này.
Tình huống thực tế
Ví dụ 1 — Ví điện tử ở Việt Nam: khi audit là bắt buộc pháp lý
Một ví điện tử (giả định tên "VíXanh") xử lý khoảng 3 triệu giao dịch/ngày. Ban đầu team lưu số dư theo kiểu truyền thống: mỗi transaction cập nhật UPDATE wallet SET balance = balance - ? WHERE id = ?. Mọi thứ chạy tốt cho tới khi có sự cố đối soát với ngân hàng đối tác: một số giao dịch bị double-charge do retry, và team không thể chứng minh được chuỗi nguyên nhân, vì database chỉ có con số cuối cùng.
Họ chuyển phần sổ cái ví (wallet ledger) sang Event Sourcing. Mỗi thao tác trở thành event: WalletCredited, WalletDebited, TransactionReversed, SettlementReconciled. Kết quả:
- Khi Ngân hàng Nhà nước hoặc kiểm toán yêu cầu giải trình một giao dịch từ 8 tháng trước, họ replay đúng chuỗi event và đưa ra bằng chứng không thể chối cãi.
- Vấn đề double-charge được phát hiện dễ dàng: hai event
WalletDebitedcùngidempotency_key— hệ thống từ chối event thứ hai. - Bài học: Với domain mà tính đúng đắn tài chính và audit là sống còn (core), Event Sourcing biến yêu cầu tuân thủ từ "chi phí phải gánh" thành "quà tặng miễn phí". Nhưng chú ý — họ chỉ áp dụng cho phần ledger, không phải toàn bộ hệ thống.
Ví dụ 2 — Sàn TMĐT Đông Nam Á: dùng đúng chỗ cho Order
Một sàn thương mại điện tử (giả định "ShopSEA", quy mô tương tự Tiki/Lazada) áp dụng Event Sourcing cho vòng đời đơn hàng. Một đơn hàng đi qua rất nhiều trạng thái phức tạp: OrderPlaced → PaymentAuthorized → Packed → HandedToCarrier → OutForDelivery → Delivered, hoặc rẽ nhánh PaymentFailed, CustomerCancelled, ReturnRequested, Refunded.
Vì sao Event Sourcing hợp ở đây? Vì câu hỏi "đơn hàng này tại sao lại ở trạng thái Refunded?" cực kỳ hay được hỏi bởi CSKH, kế toán, và cả team chống gian lận. Với chuỗi event, họ trả lời tức thì. Họ còn dùng time travel để dựng dashboard "tỷ lệ đơn bị hủy trong 10 phút đầu" mà không cần thay đổi schema — chỉ dựng thêm một projection từ event có sẵn.
Bài học: Event Sourcing tỏa sáng khi entity có vòng đời trạng thái phong phú và lịch sử chuyển trạng thái có giá trị nghiệp vụ. Ngược lại, họ không áp dụng nó cho bảng product_catalog (chỉ là dữ liệu tra cứu, ghi đè bình thường) — dùng nhầm chỗ chỉ tổ thêm phức tạp.
Ví dụ 3 — Startup dùng nhầm và trả giá
Một startup SaaS quản lý công việc (giả định "TaskFlow") nghe hội thảo xong quyết định "Event Sourcing hết cho toàn bộ hệ thống" ngay từ ngày đầu. Kết quả sau 6 tháng:
- Mỗi thay đổi tên một task cũng đẻ ra event, event store phình to nhanh mà 95% event chẳng ai truy vấn lịch sử.
- Đội mới vào không hiểu vì sao muốn xem danh sách task lại phải qua "projection" thay vì
SELECT * FROM tasks. Onboarding chậm hẳn. - Khi cần sửa một bug logic trong quá khứ, họ phát hiện ra chuyện khủng khiếp: các event cũ đã ghi bằng schema cũ, và họ chưa hề nghĩ tới versioning cho event. Việc replay bị vỡ.
billing. Bài học: Event Sourcing không phải kiến trúc mặc định. Áp dụng đại trà là một trong những sai lầm đắt nhất — bạn gánh toàn bộ chi phí phức tạp mà chỉ nhận lại lợi ích ở một góc nhỏ của hệ thống.Hướng dẫn từng bước
Nếu sau khi cân nhắc bạn quyết định dùng Event Sourcing cho một bounded context cụ thể, đây là các bước triển khai tư duy:
- Xác định aggregate và event. Chọn ranh giới nhất quán (consistency boundary), ví dụ
WallethoặcOrder. Liệt kê mọi sự việc nghiệp vụ có thể xảy ra và đặt tên chúng ở thì quá khứ:WalletCredited,OrderCancelled. Đây là bước khó nhất và quan trọng nhất — sai tên event là sai cả mô hình.
- Thiết kế event store. Đây là bảng append-only tối thiểu gồm:
aggregate_id,sequence_number(thứ tự trong aggregate),event_type,event_data(JSON),event_version,occurred_at,metadata(ai gây ra, correlation id). Bạn có thể dùng PostgreSQL thường (một bảngevents), hoặc công cụ chuyên dụng như EventStoreDB.
- Cài optimistic concurrency. Khi ghi event mới, kiểm tra
sequence_numbermong đợi. Nếu có người khác vừa ghi event vào cùng aggregate,sequencexung đột → từ chối và bắt client thử lại. Đây là cách bạn tránh ghi đè lẫn nhau mà không cần lock nặng.
- Viết hàm apply/fold. Một hàm thuần túy nhận
(state, event) → new_state. Replay = gọi hàm này lần lượt qua mọi event. Giữ nó thuần túy và không side-effect để việc replay luôn cho ra cùng kết quả.
- Thêm snapshot khi cần. Chỉ thêm khi đo đạc thấy replay chậm. Đừng tối ưu sớm. Lưu snapshot kèm
sequence_numberđể biết replay tiếp từ đâu.
- Xây projection để đọc. Vì replay để đọc từng lần thì tốn, bạn dựng các bảng đọc (read model) được cập nhật khi có event mới — ví dụ bảng
wallet_balance_view. (Cách tách read/write này liên quan chặt tới CQRS ở bài 33 — hai pattern thường đi cùng nhau nhưng độc lập về khái niệm.)
- Lập kế hoạch versioning ngay từ đầu. Ghi
event_versionvào mỗi event. Chuẩn bị "upcaster" — hàm chuyển event schema cũ sang mới lúc đọc. Bỏ qua bước này chính là cái bẫy đã hại TaskFlow ở trên.
Lỗi thường gặp & mẹo
- Nhầm Event Sourcing với "ghi thêm audit log". Audit log là thứ phụ, có thể sai lệch so với state thật. Trong Event Sourcing, event là nguồn sự thật duy nhất — state được suy ra từ nó, không tồn tại song song.
- Đưa side-effect vào event. Event chỉ mô tả đã xảy ra chuyện gì, tuyệt đối không được gọi API, gửi email, trừ tiền bên trong hàm apply. Nếu không, mỗi lần replay lịch sử bạn sẽ vô tình gửi lại 10.000 email cho khách. Đây là lỗi kinh điển và cực kỳ nguy hiểm.
- Quên versioning event. Nghiệp vụ sẽ thay đổi, event schema sẽ đổi. Không có chiến lược version, một ngày bạn không replay nổi lịch sử. Hãy chuẩn bị upcaster từ ngày đầu.
- Nhét dữ liệu tạm/không bất biến vào event. Đừng lưu
current_balancebên trong một eventMoneyDeposited— chỉ lưuamount. State là thứ được tính ra, không được đông cứng vào event.
- Áp dụng đại trà. Mẹo vàng: Event Sourcing là quyết định ở tầng bounded context, không phải tầng hệ thống. Hỏi từng context: "Lịch sử tại sao đến trạng thái này có giá trị nghiệp vụ không? Có yêu cầu audit không? Vòng đời trạng thái có phức tạp không?" Nếu cả ba đều "không" → dùng CRUD truyền thống.
- Quên chi phí GDPR / xóa dữ liệu. Event bất biất khiến việc "xóa dữ liệu cá nhân theo yêu cầu người dùng" trở nên khó. Mẹo phổ biến: crypto-shredding — mã hóa dữ liệu cá nhân trong event bằng một khóa riêng cho từng người dùng; muốn "xóa" thì hủy khóa, event vẫn còn nhưng nội dung thành rác không giải mã được.
Bài tập thực hành
- Mô hình hóa: Lấy một aggregate
HotelRoomBooking(đặt phòng khách sạn). Liệt kê tối thiểu 6 event thì-quá-khứ mô tả toàn bộ vòng đời, gồm cả nhánh hủy và đổi ngày. Viết ra hàm fold để tính trạng thái hiện tại từ chuỗi event đó.
- Replay bằng tay: Cho chuỗi event của một ví:
Opened(0),Credited(1.000.000),Debited(300.000),Reversed(300.000)(hoàn lại lần rút vừa rồi),Debited(200.000). Tính số dư cuối. Sau đó trả lời: số dư tại thời điểm ngay sau event thứ 3 là bao nhiêu? (Đây là time travel.)
- Ra quyết định kiến trúc: Cho 4 module — (a) danh mục sản phẩm, (b) sổ cái điểm thưởng loyalty, (c) hồ sơ cấu hình giao diện người dùng, (d) quy trình phê duyệt hợp đồng nhiều cấp. Với mỗi module, quyết định Event Sourcing hay CRUD, và viết 2 câu lý do dựa trên ba tiêu chí (giá trị lịch sử / yêu cầu audit / độ phức tạp vòng đời).
- Bẫy side-effect: Viết lại đoạn mô tả một hàm apply đang gọi
sendEmail()bên trong. Chỉ ra vì sao nó vỡ khi replay và đề xuất cách tách side-effect ra ngoài (gợi ý: dùng một tầng xử lý event riêng sau khi ghi).
Tóm tắt
Event Sourcing đảo ngược cách lưu trữ quen thuộc: thay vì lưu trạng thái hiện tại và ghi đè, ta lưu chuỗi bất biến các sự kiện đã xảy ra và suy ra trạng thái bằng cách replay (state = fold(events)). Event phải bất biến, mô tả sự việc ở thì quá khứ, và chỉ được append. Đổi lại, bạn nhận ba siêu năng lực: time travel về bất kỳ thời điểm nào, audit trail hoàn hảo miễn phí, và khả năng dựng model/câu hỏi mới từ dữ liệu quá khứ.
Nhưng đây là quyết định đắt và khó đảo ngược. Chi phí gồm: độ phức tạp nhận thức cho cả team, bài toán versioning event, quản lý snapshot, và rắc rối với việc xóa dữ liệu (GDPR). Vì vậy, quy tắc mấu chốt của một architect: Event Sourcing là lựa chọn ở tầng bounded context, không phải mặc định cho cả hệ thống. Hãy dùng nó đúng ở những nơi mà lịch sử "tại sao" có giá trị nghiệp vụ, audit là bắt buộc, và vòng đời trạng thái phức tạp — điển hình là sổ cái tài chính, vòng đời đơn hàng, quy trình phê duyệt. Với dữ liệu tra cứu đơn giản, CRUD truyền thống vẫn là câu trả lời đúng. Chọn đúng chỗ, bạn có một hệ thống minh bạch tuyệt đối; chọn nhầm chỗ, bạn gánh toàn bộ phức tạp mà chẳng thu về mấy lợi ích.