Cách lưu dữ liệu quen thuộc nhất là lưu trạng thái hiện tại: UPDATE account SET balance = 200. Mỗi lần số dư đổi, bạn ghi đè giá trị cũ. Đơn giản — nhưng bạn vừa xoá vĩnh viễn thông tin: giờ bạn biết số dư là 200, nhưng không biết vì sao, không biết nó từng là bao nhiêu, không dựng lại được trạng thái ở một thời điểm quá khứ. Event sourcing đảo ngược hoàn toàn: thay vì lưu trạng thái, lưu chuỗi sự kiện dẫn tới trạng thái đó (nạp 100, rút 30, nạp 50...). Trạng thái hiện tại không được lưu — nó được tính lại bằng cách "phát lại" (replay) toàn bộ sự kiện. Và một message log như Kafka là nơi hoàn hảo để lưu chuỗi sự kiện đó. Bài này (phần 9 loạt Message Queue) chạy thật để thấy event sourcing cho những gì mà lưu trạng thái không bao giờ cho.

Trạng thái là một hàm của sự kiện

Ý tưởng cốt lõi: trạng thái = fold(toàn bộ sự kiện). Số dư không phải một con số được lưu — nó là kết quả của việc cộng/trừ qua từng sự kiện từ đầu:

  • Cách thường: UPDATE balance = 200 — ghi đè, mất lịch sử. Bạn chỉ có "ảnh chụp" hiện tại.
  • Event sourcing: append(deposit 100), append(withdraw 30)... — chỉ thêm, không bao giờ ghi đè. Trạng thái tính bằng fold:
# balance = fold(events)
b = 0
for e in events:
    if e.type == "deposit": b += e.amount
    else:                   b -= e.amount
# b là trạng thái hiện tại, dựng lại từ chuỗi sự kiện

Vì event log chỉ thêm, không ghi đè, nó giữ toàn bộ lịch sử. Và từ lịch sử đầy đủ đó, bạn có những khả năng mà lưu trạng thái không thể: dựng lại trạng thái ở bất kỳ thời điểm nào (replay tới điểm đó), audit mọi thay đổi (sự kiện nào, khi nào), và sửa logic rồi tính lại toàn bộ (replay với code mới).

# topic Kafka làm event log; replay toàn bộ → trạng thái hiện tại
kafka-console-consumer.sh --topic mq09-events --from-beginning \
  | awk -F: '{ if($1=="deposit") b+=$2; else b-=$2 }'
# time-travel: replay tới điểm N
kafka-console-consumer.sh ... --max-messages N

Ảnh chụp code nền tối event sourcing lưu sự kiện không lưu trạng thái trạng thái bằng fold toàn bộ event log là nguồn sự thật, cách thường lưu trạng thái ghi đè UPDATE account SET balance 200 mất lịch sử chỉ biết giờ là 200 không biết vì sao, event sourcing append sự kiện không ghi đè append deposit 100 append withdraw 30 balance bằng fold events for e in events if deposit b cộng amount else b trừ amount, demo thật topic Kafka là event log replay toàn bộ kafka-console-consumer from-beginning awk fold time-travel replay tới điểm N max-messages N

Hình 1: Cách thường lưu trạng thái và ghi đè (mất lịch sử); event sourcing chỉ append sự kiện và tính trạng thái bằng fold. Vì log chỉ thêm không ghi đè, nó giữ toàn bộ lịch sử — cho phép replay, time-travel và audit. Topic Kafka làm event log tự nhiên.

Đo thật: replay, time-travel, thêm event, audit

Mình dùng một topic Kafka làm event log của một tài khoản, produce chuỗi sự kiện rồi replay bằng awk:

Ảnh chụp output thật nền tối replay event log dựng lại trạng thái Kafka event log fold bằng awk, replay toàn bộ fold từng event deposit 100 bằng 100 withdraw 30 bằng 70 deposit 50 bằng 120 withdraw 20 bằng 100 deposit 200 bằng 300 withdraw 100 bằng 200 balance cuối bằng 200, time-travel replay 4 event đầu balance tại thời điểm đó bằng 100 là 100 trừ 30 cộng 50 trừ 20, thêm event deposit 500 replay lại tổng event bằng 7 balance mới bằng 700 là 200 cộng 500, audit toàn bộ lịch sử còn nguyên số event trong log bằng 7 không event nào bị ghi đè, log cho trạng thái mọi thời điểm cộng audit đầy đủ cộng rebuild đổi lại replay tốn thời gian cần snapshot khi log dài

Hình 2: Kết quả thật. Replay toàn bộ event log fold ra balance cuối = 200 (100−30+50−20+200−100). Time-travel: replay chỉ 4 event đầu cho balance = 100 tại thời điểm đó. Thêm event deposit 500 rồi replay lại → 7 event, balance 700. Audit: toàn bộ 7 event còn nguyên trong log, không event nào bị ghi đè.

Đọc kết quả:

  • Replay toàn bộ → dựng lại trạng thái hiện tại: fold qua 6 event (deposit 100 → 100, withdraw 30 → 70, ...) cho balance cuối = 200. Trạng thái không được lưu ở đâu cả — nó được tính từ log mỗi lần cần. Đây là bản chất event sourcing: log là nguồn sự thật duy nhất.
  • Time-travel → trạng thái ở quá khứ: replay chỉ 4 event đầu (dừng ở offset 4) cho balance = 100 — đó là số dư tại thời điểm sau event thứ 4. Với lưu trạng thái thông thường, thông tin này đã mất từ lâu; với event sourcing, bạn chỉ cần replay tới điểm mong muốn. Cực kỳ giá trị cho điều tra sự cố ("số dư lúc 3h chiều là bao nhiêu?") và debug.
  • Thêm event → replay vẫn đúng: thêm deposit 500, log giờ có 7 event, replay lại cho balance = 700 (200 + 500). Thêm event chỉ là append — không sửa gì cũ, và replay tự nhiên cho ra trạng thái mới đúng.
  • Audit đầy đủ: log chứa đúng 7 event, không event nào bị ghi đè hay mất. Bạn có bản ghi hoàn chỉnh và bất biến của mọi thay đổi — ai cũng mơ ước điều này khi điều tra gian lận hay sự cố, và nó miễn phí với event sourcing (vì bản chất log là append-only).

Thông điệp cốt lõi: event sourcing đổi "một ảnh chụp" lấy "cả cuốn phim". Lưu trạng thái cho bạn biết hiện tại; event log cho bạn biết toàn bộ hành trình — và từ hành trình, bạn tính lại được bất kỳ ảnh chụp nào.

Event sourcing và CQRS

Event sourcing thường đi cùng CQRS (Command Query Responsibility Segregation) vì một lý do thực tế: replay toàn bộ log mỗi lần cần đọc trạng thái thì chậm. Giải pháp: tách ghi (append event vào log) khỏi đọc (query từ một "read model" được dựng sẵn). Một consumer đọc event log và cập nhật dần một bảng trạng thái (ví dụ bảng account_balance trong PostgreSQL) — đây là read model, tối ưu cho truy vấn nhanh. Khi cần, read model được dựng lại hoàn toàn bằng replay. Như vậy bạn có cả hai: log bất biến làm nguồn sự thật (ghi), và read model nhanh (đọc). Đây chính là lý do event sourcing hợp với kiến trúc message-driven — event log là kênh nối giữa write side và read side.

Đánh đổi cần cân nhắc

Replay tốn thời gian — cần snapshot khi log dài. Demo chỉ 7 event nên replay tức thì. Nhưng một tài khoản sống 10 năm có thể có hàng triệu event; replay từ đầu mỗi lần là không khả thi. Giải pháp: snapshot — định kỳ lưu trạng thái tại một offset (ví dụ "balance = X tại event thứ 1.000.000"), rồi chỉ replay các event sau snapshot. Snapshot là tối ưu hoá, không phải nguồn sự thật — có thể xoá và dựng lại từ log bất kỳ lúc nào. Đây là cái giá của event sourcing: thêm cơ chế snapshot để replay không chậm dần theo thời gian.

Thay đổi schema event là vĩnh viễn — event cũ sống mãi. Vì log bất biến và event cũ không bao giờ bị sửa, một event ghi sai định dạng 3 năm trước vẫn ở đó và code replay phải xử lý được nó. Bạn không thể "migrate" event log như migrate bảng SQL. Điều này khiến schema evolution (bài sau) trở nên cực kỳ quan trọng với event sourcing: code replay phải đọc được mọi phiên bản event từng tồn tại. Đây là gánh nặng bảo trì thật, không nên xem nhẹ.

Event sourcing là phức tạp — đừng dùng cho mọi thứ. Event sourcing giải quyết tốt các domain cần audit, lịch sử, time-travel (tài chính, y tế, kho vận). Nhưng nó thêm đáng kể phức tạp: replay, snapshot, read model, schema evolution, xử lý event trùng. Với CRUD đơn giản (quản lý danh mục sản phẩm) nơi bạn chỉ cần trạng thái hiện tại, lưu trạng thái thông thường đơn giản hơn nhiều và đủ dùng. Chọn event sourcing khi giá trị của lịch sử vượt chi phí phức tạp — không phải vì nó "nghe hay".

Ba ý mang về

  1. Trạng thái = fold(sự kiện), log là nguồn sự thật: đo thật replay toàn bộ event log dựng lại balance 200 mà không lưu trạng thái ở đâu — event log chỉ append không ghi đè nên giữ toàn bộ lịch sử.
  2. Log cho time-travel và audit miễn phí: đo thật replay 4 event đầu cho balance 100 tại quá khứ, thêm event rồi replay cho 700, và 7 event còn nguyên làm audit — những khả năng mà lưu trạng thái (ghi đè) không bao giờ có.
  3. Đổi lại là phức tạp và chi phí replay: cần snapshot khi log dài (replay triệu event không khả thi), đi cùng CQRS để đọc nhanh, phải lo schema evolution vì event cũ sống mãi — chỉ dùng khi giá trị lịch sử vượt chi phí, không cho CRUD đơn giản.

Nguồn

Phần sau ta xử lý giao dịch phân tán qua nhiều service: saga pattern — chuỗi bước cục bộ với các bước bù trừ khi lỗi, demo thật một saga rollback khi một bước giữa chừng thất bại.