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 }
flowchart LR ES["Event Log<br/>(append-only)"] -->|"load in order"| FOLD["fold each event<br/>into state"] FOLD --> STATE["Aggregate State"]
Đ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
flowchart LR SNAP["Snapshot<br/>(state at seq=1000)"] -->|"load as start state"| FOLD["fold only<br/>events after snapshot"] ES["Event Store"] -->|"events seq=1001..N"| FOLD FOLD --> STATE["Current 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 (append-only event store), không lưu state hiện tại. State là derived data, tính lại được bằng replay (fold từng event lên state ban đầu).
- Bốn khái niệm: event (sự việc đã xảy ra, tên quá khứ), event store (log append-only), aggregate (đơn vị nghiệp vụ có ranh giới), projection (view dẫn xuất từ log).
- Replay = pure fold:
applyEvent(state, event)phải là pure function — không side-effect. Side-effect chỉ xảy ra một lần khi event được tạo lần đầu, không bao giờ trong vòng lặp fold. - Snapshot: checkpoint state tại seq=N, load snapshot + events sau đó. Ngưỡng: mỗi ~1.000 event hoặc khi replay vượt 100ms. Một tài khoản ngân hàng 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 event v1 lên v2 khi đọc — không sửa event đã ghi.
- Pitfall 2: side-effect trong
applyEvent→ khách nhận email trùng khi replay. Tách hoàn toàn fold (pure) khỏi command handler (side-effect OK). - Pitfall 3: GDPR xoá dữ liệu với log bất biến → crypto-shredding, reference-based, hoặc event tombstone.
- Không phải mặc định đúng cho mọi hệ: CRUD đơn giản không cần event sourcing. 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ó?▸
CRUD ghi đè state → thông tin về quá trình thay đổi bị mất: không còn biết state hiện tại đến từ những hành động nào, vào lúc nào. Event sourcing lưu từng sự kiện đã xảy ra — state là thứ tính ra được từ đó, không lưu trực tiếp.
Hệ quả: (1) audit trail đầy đủ không tốn thêm công; (2) rollback về thời điểm T — replay đến seq=T là xong; (3) debug bằng cách phát lại đúng chuỗi event đó; (4) nhiều projection từ một log — cùng event log tài khoản, xây cả "số dư hiện tại" lẫn "tổng giao dịch theo tháng" mà không cần schema riêng.
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?▸
18.615 event là mức trung bình (3 giao dịch/ngày). Ở tần suất cao (trading, sổ cái), một aggregate dễ đạt 10.000–100.000+ event. Replay 10.000 event mất vài giây; 1 triệu event mất hàng phút — không thể dùng cho request đồng bộ đợi phản hồi.
Snapshot là checkpoint: lưu state tại seq=N kèm sequence number. Lần sau load aggregate, đọc snapshot gần nhất rồi chỉ phát lại các event sau đó. Nếu snapshot ở seq=1000 và log có 1050 event, chỉ cần replay 50 event thay vì 1050. Snapshot không xoá event cũ — event log vẫn còn nguyên, snapshot chỉ là shortcut.
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ì?▸
Event đã ghi là bất biến — sửa tại chỗ phá vỡ tính toàn vẹn của log (audit trail, distributed replica, hash chain). Hơn nữa, giá trị mặc định cho field thiếu là quyết định nghiệp vụ, không phải dữ liệu cũ tự trả lời được.
Cách đúng: event versioning + upcaster. Định nghĩa OrderPlacedV2 với field currency. Ghi event mới theo V2. Viết upcaster: khi đọc event V1 từ store, tự động chuyển thành V2 (điền default currency: "VND"). Hàm applyEvent chỉ nhận V2 — không cần biết lịch sử format cũ.
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?▸
Pure function: không side-effect, không đọc trạng thái ngoài (DB, clock, random), chỉ nhận (state, event) và trả newState. Đây là điều kiện bắt buộc để replay an toàn — replay có thể xảy ra nhiều lần: debug, xây projection mới, load aggregate.
Nếu applyOrderPlaced gọi emailService.sendConfirmation(), mỗi lần replay sẽ gửi thêm một email. Khách hàng nhận hàng chục email xác nhận cho đơn hàng đã đặt từ tháng trước. Side-effect (gửi email, trừ tiền, gọi API) chỉ được chạy một lần tại thời điểm event được tạo — không bao giờ trong vòng lặp fold.
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?▸
Snapshot lưu một bản chụp state tại một mốc sequenceNumber. Thay vì replay toàn bộ từ seq 0, hệ load snapshot gần nhất rồi chỉ fold các event sau mốc đó.
Với snapshot ở seq=59.000: load thẳng state tại đó, rồi fold chỉ ~1.000 event (seq 59.001 → 60.000) thay vì 60.000 — thời gian tụt từ giây xuống mili-giây. Quan trọng: event store không đổi — mọi event cũ vẫn còn nguyên; snapshot chỉ là shortcut bỏ qua replay phần đầu, có thể dựng lại từ event bất kỳ lúc nào.
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?▸
Event store là append-only — không xoá hay sửa event đã ghi. Nếu event lịch sử chứa tên, email, địa chỉ của người dùng dưới dạng plaintext, không có cách xoá mà không phá vỡ tính toàn vẹn của log.
Crypto-shredding là workaround phổ biến nhất: khi ghi event, mã hoá PII bằng khoá riêng của từng user (lưu khoá ở key store riêng). Khi người dùng yêu cầu xoá, xoá khoá của họ — PII trong event cũ trở thành ciphertext vô nghĩa, không thể giải mã, tương đương "đã xoá" về mặt thực tế. Event log vẫn còn nguyên về cấu trúc, chỉ không đọc được PII nữa.
Bài tiếp theo: CQRS — tách read model khỏi write model
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