Hệ thống Dữ liệu Phái sinh/Event sourcing — lưu chuỗi sự kiện thay vì lưu trạng thái
17/21
Bài 17 / 21~13 phútKiến trúc phái sinh (CDC, event sourcing, CQRS)Miễn phí lượt xem

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ánEvent 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ánState = fold toàn bộ event
Bút toán đảo khi phát hiện saiCompensating event
Lịch sử kiểm toán đầy đủEvent log không mất gì
💡 Cách nhớ

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 }

Cot su kien chay canh cot trang thai, do chinh la phep gap

Đ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

Snapshot cat bot phan phai gap lai nhung khong thay the log

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 eventhoặ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ạiNhanh — 1 queryCần replay (cần snapshot nếu log dài)
Audit trailKhô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 TBất khả nếu không backupReplay đế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 serviceCần trigger hoặc pollingEvent log là nguồn publish tự nhiên
Độ phức tạpThấp — CRUD đơn giảnCao hơn (schema versioning, snapshot, upcaster)
Khi nên dùngHầu hết CRUD thông thườngAudit bắt buộc, tài chính, lịch sử quan trọng
⚠️ Event sourcing không phải mặc định đú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 versioningupcaster: đị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

📚 Deep Dive — Event Sourcing

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

Tự kiểm tra
0/6 câu đã trả lời
  1. Q1
    Vì 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ó?
  2. Q2
    Tà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?
  3. Q3
    Bạ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ì?
  4. Q4
    Vì 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?
  5. Q5
    Aggregate 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?
  6. Q6
    GDPR 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

Đặt 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

Bài tiếp theo

CQRS — tách đường ghi khỏi đường đọc và cái giá nhất quán