Event sourcing — lưu chuỗi sự kiện thay vì lưu trạng thái
Thay vì lưu trạng thái rồi ghi đè, event sourcing lưu chuỗi sự kiện bất biến và dựng lại trạng thái bằng cách phát lại. Nối tư duy input bất biến ở Module 1.
TL;DR: Event sourcing không lưu trạng thái hiện tại rồi ghi đè — thay vào đó, nó lưu chuỗi sự kiện bất biến (append-only) theo đúng thứ tự xảy ra. Muốn biết số dư tài khoản lúc này? Phát lại (replay) toàn bộ sự kiện từ đầu và fold chúng thành trạng thái. Mô hình này giữ toàn bộ lịch sử, cho phép xây nhiều view khác nhau từ cùng một event log. Đánh đổi: phải quản lý event schema versioning khi đổi cấu trúc (dùng upcaster), tránh side-effect khi replay, và cần snapshot khi log quá dài — replay 1 triệu event mất hàng phút.
Một hệ thống ngân hàng lưu số dư theo kiểu CRUD thông thường: khi bạn gửi tiền, nó chạy UPDATE account SET balance = 800000. Đơn giản và đọc nhanh — nhưng "số dư này đến từ đâu?" là câu hỏi không còn trả lời được, audit trail bị chôn vùi, và một bug trong công thức tính phí thì lịch sử đã mất, không thể tính lại.
Event sourcing chọn hướng ngược: không lưu kết quả, mà lưu những gì đã xảy ra. Thay vì ghi đè balance, nó append sự kiện DepositMade vào một log bất biến. State hiện tại là thứ dẫn xuất — tính ra được bằng cách phát lại (replay) toàn bộ chuỗi đó. Bài này giải thích cơ chế replay, vì sao cần snapshot, và khi nào event sourcing đáng đánh đổi hơn CRUD.
1. Analogy — sổ cái kế toán chỉ ghi thêm
Kế toán không xoá con số cũ rồi ghi số mới lên chỗ đó — họ ghi thêm một bút toán (journal entry) vào sổ cái. Muốn biết số dư tài khoản hôm nay? Cộng dồn toàn bộ bút toán từ đầu sổ. Phát hiện bút toán tháng trước sai? Không xoá — ghi một bút toán đảo (reversing entry) để bù. Sổ cái không bao giờ mất dữ liệu; mọi diễn biến đều truy ngược được.
| Sổ cái kế toán | Event sourcing |
|---|---|
| Bút toán (journal entry) | Event |
| Sổ cái chỉ ghi thêm, không xoá | Event store append-only |
| Số dư = tổng tất cả bút toán | State = fold toàn bộ event |
| Bút toán đảo khi phát hiện sai | Compensating event |
| Lịch sử kiểm toán đầy đủ | Event log không mất gì |
Event store là sổ cái không bao giờ dùng tẩy — chỉ có bút viết thêm. State hiện tại là tổng kết trang cuối; lật ngược sổ là xem lại toàn bộ hành trình.
2. Vì sao lưu sự kiện thay vì lưu trạng thái?
Trong CRUD thông thường, mỗi thay đổi ghi đè trực tiếp vào bản ghi: UPDATE account SET balance = 800000. Thao tác này nhanh, nhưng thông tin về sự thay đổi bị mất ngay: không còn biết 800000 là kết quả của giao dịch nào, vào lúc nào, do ai khởi xướng.
Event sourcing đảo ngược thứ tự ưu tiên: thay vì lưu state, lưu sự kiện dẫn đến state đó. Mỗi hành động sinh ra một event bất biến được ghi vào event store — một log chỉ-ghi-thêm (append-only). Khi cần state hiện tại, hệ thống phát lại (replay) toàn bộ event từ đầu.
Bốn khái niệm cốt lõi cần nắm:
- Event: một sự việc đã xảy ra, bất biến, đặt tên dạng quá khứ (
DepositMade,OrderPlaced,UserRegistered). Không phải lệnh ("deposit money") mà là kết quả đã hoàn thành. - Event store: nơi lưu event, append-only. Mỗi event mang
sequenceNumber(thứ tự),aggregateId,timestamp,eventType, và payload. - Aggregate: đơn vị nghiệp vụ có ranh giới rõ (vd: một tài khoản ngân hàng, một đơn hàng). Toàn bộ event của một aggregate được đọc và phát lại cùng nhau.
- Projection: một "view" dẫn xuất từ event log — ví dụ số dư hiện tại, danh sách giao dịch 30 ngày, hay trạng thái của đơn hàng. Một aggregate có thể sinh ra nhiều projection.
Bài M1 đặt nền tảng: batch job chỉ đọc nguồn bất biến, ghi kết quả ra nơi khác — nhờ vậy chạy lại bao nhiêu lần cũng cho đúng kết quả. Event sourcing áp chính kỷ luật đó lên state của nghiệp vụ: event log là nguồn bất biến, state hiện tại chỉ là derived data dựng lại từ replay. Mất state? Replay lại từ event log là xong.
3. Dựng lại trạng thái bằng REPLAY
Phát lại (replay) event là fold tuần tự: bắt đầu từ state ban đầu (thường rỗng), lần lượt áp từng event, mỗi event biến đổi state theo cách của nó.
function loadAggregate(aggregateId):
events <- eventStore.loadAll(aggregateId) -- load ordered by sequence
state <- initialState() -- empty / zero state
for each event in events:
state <- applyEvent(state, event) -- fold: each event mutates state
return state
-- Example: bank account A-001
-- Event log:
-- [seq=1] AccountOpened { initial_balance: 0 }
-- [seq=2] DepositMade { amount: 1_000_000 }
-- [seq=3] WithdrawalMade { amount: 200_000 }
-- [seq=4] DepositMade { amount: 500_000 }
--
-- Replay (fold):
-- state0 = { balance: 0 } (initial)
-- state1 = applyAccountOpened(state0) -> { balance: 0 }
-- state2 = applyDepositMade(state1) -> { balance: 1_000_000 }
-- state3 = applyWithdrawal(state2) -> { balance: 800_000 }
-- state4 = applyDepositMade(state3) -> { balance: 1_300_000 }
-- Current state: { balance: 1_300_000 }

Điều kiện bắt buộc: applyEvent phải là pure function — không gọi DB, không gửi email, không đọc đồng hồ hệ thống. Chỉ nhận (state, event) và trả ra state mới. Đây là điều kiện để replay an toàn (xem Pitfall 2 về side-effect).
4. Vì sao SNAPSHOT lại cần thiết?
Hãy nhẩm tính: một tài khoản ngân hàng với trung bình 3 giao dịch mỗi ngày tích luỹ sau 17 năm đạt khoảng 18.615 event. Ở mức đó, replay có thể còn chấp nhận được với một số trường hợp — nhưng tài khoản giao dịch tần suất cao (trading, sổ cái) dễ đạt từ 10.000 đến hơn 100.000 event. Bottleneck của replay là I/O đọc event từ store (thường vài chục nghìn event/giây), không phải phép fold trong bộ nhớ: replay 10.000 event thường dưới một giây, còn 1 triệu event có thể mất hàng chục giây tới vài phút tuỳ tốc độ đọc store — không chấp nhận được cho request đồng bộ.
Snapshot giải quyết điều này: ở mốc định kỳ, lưu một bản chụp (checkpoint) của state vào thời điểm đó cùng với sequenceNumber tương ứng. Lần sau load aggregate, chỉ cần load snapshot gần nhất rồi phát lại các event sau snapshot đó thay vì toàn bộ từ đầu.
function loadAggregateWithSnapshot(aggregateId):
snap <- snapshotStore.loadLatest(aggregateId) -- null if none exists
fromSeq <- (snap != null) ? snap.sequenceNumber : 0
events <- eventStore.loadFrom(aggregateId, fromSeq + 1)
state <- (snap != null) ? snap.state : initialState()
for each event in events:
state <- applyEvent(state, event)
return state

Ngưỡng thực tế (chọn một, tuỳ tốc độ event store của bạn): snapshot theo chính sách cố định — ví dụ mỗi ~1.000 event — hoặc theo rule-of-thumb khi thời gian replay một aggregate vượt ~100ms. Aggregate ngắn (dưới ~1.000 event) thường không cần snapshot. Snapshot không thay thế event store — event cũ vẫn giữ nguyên, snapshot chỉ là shortcut bỏ qua replay phần đầu.
5. Event sourcing và CRUD — khi nào dùng cái gì?
| Tiêu chí | CRUD (ghi đè state) | Event sourcing (append events) |
|---|---|---|
| Đọc state hiện tại | Nhanh — 1 query | Cần replay (cần snapshot nếu log dài) |
| Audit trail | Không có sẵn (phải thêm log riêng) | Có sẵn — toàn bộ lịch sử trong event log |
| Rollback về thời điểm T | Bất khả nếu không backup | Replay đến seq=T |
| Debug sự kiện quá khứ | Khó — state đã bị ghi đè | Dễ — replay lại đúng chuỗi event đó |
| Tích hợp nhiều service | Cần trigger hoặc polling | Event log là nguồn publish tự nhiên |
| Độ phức tạp | Thấp — CRUD đơn giản | Cao hơn (schema versioning, snapshot, upcaster) |
| Khi nên dùng | Hầu hết CRUD thông thường | Audit bắt buộc, tài chính, lịch sử quan trọng |
Martin Fowler (2005) nhấn mạnh: event sourcing thêm đáng kể complexity — chỉ xứng đáng khi bạn thật sự cần toàn bộ lịch sử hoặc nhiều cách nhìn vào cùng một dữ liệu. Hệ thống CRUD đơn giản với yêu cầu audit tối thiểu không cần event sourcing; thêm vào chỉ làm mọi thứ phức tạp hơn mà không mang lợi tương xứng.
6. Pitfall
❌ Pitfall 1 — Sửa event cũ khi đổi schema
Sau 6 tháng bạn nhận ra payload của OrderPlaced thiếu field currency. Cám dỗ: mở event store và thêm currency: "VND" vào các event cũ.
✅ Event đã ghi là bất biến — không bao giờ sửa. Thay vào đó, dùng event versioning và upcaster: định nghĩa OrderPlacedV2 với field currency, ghi event mới theo schema V2. Khi đọc event V1 cũ từ store, upcaster tự động nâng cấp nó lên V2 trước khi đưa vào applyEvent.
function upcaster(rawEvent):
if rawEvent.type = "OrderPlaced" and rawEvent.version = 1:
return OrderPlacedV2 {
...rawEvent.payload,
currency: "VND" -- default for old events
}
return rawEvent -- V2+ already good
❌ Pitfall 2 — Side-effect trong applyEvent
Bạn muốn debug trạng thái đơn hàng bằng cách replay event — nhưng bên trong applyOrderPlaced() có lời gọi emailService.sendConfirmation(). Kết quả: khách hàng nhận hàng chục email xác nhận đơn hàng đã đặt từ tháng trước.
✅ applyEvent phải là pure function hoàn toàn. Side-effect (gửi email, trừ tiền, gọi API ngoài) chỉ xảy ra một lần duy nhất tại thời điểm event được tạo lần đầu — không được chạy lại trong replay. Tách rõ "xử lý command" (side-effect OK) khỏi "fold event" (pure).
❌ Pitfall 3 — GDPR và quyền xoá dữ liệu
Event log là append-only — khi người dùng yêu cầu xoá dữ liệu cá nhân (GDPR right-to-erasure), không thể xoá event cũ chứa thông tin của họ mà không phá vỡ tính toàn vẹn của log.
✅ Ba workaround phổ biến: (1) Crypto-shredding — mã hoá PII trong event bằng khoá riêng của từng user; xoá khoá là dữ liệu trở thành ciphertext vô nghĩa. (2) Reference-based — event chỉ chứa userId tham chiếu đến bảng riêng; xoá bảng đó là PII không còn resolve được. (3) Event tombstone — append sự kiện UserDataErased và lọc PII ở tầng projection; event gốc còn đó nhưng không bao giờ được đọc ra nữa.
7. 📚 Deep Dive
Nguồn authority:
- Event Sourcing — Martin Fowler (2005) — định nghĩa kinh điển: "capture all changes to application state as a sequence of events"; thảo luận thách thức external side-effect khi replay. Điểm mốc lịch sử của pattern.
- What do you mean by "Event-Driven"? — Fowler (2017) — phân biệt rõ 4 pattern hay bị nhầm: Event Notification (chỉ thông báo, payload tối thiểu), Event-Carried State Transfer (mang đủ state trong event), Event Sourcing (log bất biến làm nguồn sự thật), và CQRS (tách read/write model). Dùng event để giao tiếp giữa service không đồng nghĩa đang làm Event Sourcing.
- Pattern: Event sourcing — Chris Richardson (microservices.io) — lý do event sourcing giải quyết atomic update + publish event mà không cần distributed transaction (2PC): ghi event vào store là nguồn sự thật, publish ra broker là derived.
Ghi chú: DDIA (Kleppmann) Ch11–12 xây nền tảng — "event log bất biến như database inside out"; event sourcing là một biểu hiện cụ thể của triết lý lưu log thay vì lưu state. Đọc Ch11 trước nếu chưa quen với log-based systems.
8. Liên hệ các bài khác
- Bài 01 — CDC: CDC cũng đọc một log bất biến (WAL/binlog của DB) và phát ra event stream. Điểm khác: CDC là quan sát từ ngoài DB (observer pattern), còn event sourcing là thiết kế trong nghiệp vụ — event được tạo chủ động như phần cốt lõi của domain model.
- Bài 03 — CQRS: Event sourcing và CQRS hay đi cùng nhau nhưng độc lập. CQRS tách read/write model; event sourcing cung cấp event log để xây nhiều read model (projection). Bài 03 giải thích khi nào kết hợp và khi nào không cần.
- Module 1, Bài 01 — Tư duy batch: Nguồn gốc tư duy input bất biến → output phái sinh. Event sourcing áp nguyên tắc đó lên state: event log là nguồn bất biến, state là derived data dựng lại bằng replay.
- Module 2, Bài 02 — Log-based broker: Kafka cũng là append-only log — event sourcing có thể dùng Kafka làm event store và tận dụng retention, offset, replay mà bài đó đã giải thích.
9. Tóm tắt
- Event sourcing lưu chuỗi sự kiện bất biến — state là derived data, tính lại bằng replay (pure fold). Bốn khái niệm: event / event store / aggregate / projection.
- Snapshot: checkpoint tại seq=N; ngưỡng ~1.000 event hoặc replay vượt 100ms. 3 giao dịch/ngày × 17 năm = 18.615 event; trading/ledger dễ đạt 100.000+.
- Pitfall 1: đổi schema event cũ tại chỗ → replay vỡ; dùng upcaster nâng v1→v2 khi đọc. Pitfall 2: side-effect trong
applyEvent→ email trùng khi replay — fold phải pure. - Pitfall 3 (GDPR): log bất biến vs quyền xoá → crypto-shredding / reference-based / event tombstone.
- Không phải mặc định đúng: chỉ xứng đáng khi audit trail bắt buộc, cần nhiều projection, hoặc tích hợp multi-service qua event log.
10. Tự kiểm tra
- Q1Vì sao event sourcing lưu chuỗi sự kiện thay vì lưu trạng thái hiện tại, và điều đó mang lại những khả năng gì mà CRUD không có?
- Q2Tài khoản ngân hàng tích luỹ 18.615 event sau 17 năm. Tại sao replay toàn bộ mỗi khi có request là không chấp nhận được, và snapshot giải quyết bằng cách nào?
- Q3Bạn phát hiện schema của event 'OrderPlaced' đã ghi 6 tháng nay thiếu field 'currency'. Vì sao không được sửa event cũ, và cách đúng là gì?
- Q4Vì sao 'applyEvent' phải là pure function, và điều gì xảy ra nếu bên trong nó có lời gọi gửi email xác nhận đơn hàng?
- Q5Aggregate P001 đã tích luỹ 60.000 event; mỗi request GET tồn kho phải replay từ đầu mất khoảng 2 giây. Snapshot giải quyết vấn đề này thế nào — và nếu snapshot gần nhất ở seq=59.000 thì load state cần đọc và fold bao nhiêu event?
- Q6GDPR yêu cầu xoá toàn bộ dữ liệu của một người dùng theo yêu cầu của họ. Event sourcing gặp thách thức gì, và nêu một workaround cụ thể kèm cơ chế hoạt động?
Bài tiếp theo: CQRS — tách read model khỏi write model
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên