Cách lưu dữ liệu thông thường lưu trạng thái hiện tại: tài khoản có số dư 400. Nhưng thông tin "làm sao đến được 400" bị mất — nạp bao nhiêu, rút bao nhiêu, khi nào. Event sourcing lật ngược điều này: thay vì lưu trạng thái, ta lưu chuỗi sự kiện bất biến đã dẫn tới trạng thái đó, và dựng lại trạng thái bằng cách phát lại (replay) chúng. Bài trước ta thấy CQRS (thường đi kèm event sourcing nhưng độc lập); bài này mổ xẻ chính event sourcing: cơ chế, lợi ích truy vết, và snapshot — thứ giữ cho nó không chậm dần theo thời gian.

Sự kiện là sự thật bất biến

Trong event sourcing, mỗi thay đổi trạng thái là một sự kiện — một sự thật đã xảy ra trong quá khứ, không bao giờ sửa hay xóa. Mỗi sự kiện biết cách áp chính nó lên trạng thái:

type Event interface{ apply(*TaiKhoan) }
type MoTaiKhoan struct{ Chu string }
type NapTien struct{ SoTien int }
type RutTien struct{ SoTien int }

func (e NapTien) apply(t *TaiKhoan) { t.SoDu += e.SoTien }
func (e RutTien) apply(t *TaiKhoan) { t.SoDu -= e.SoTien }

Điểm mấu chốt: trạng thái không được lưu. TaiKhoan{SoDu: 400} không nằm ở đâu cả — nó được dựng lại bằng cách phát lại toàn bộ sự kiện lên một trạng thái rỗng. Event store chỉ append, không bao giờ sửa:

type Store struct{ events []Event }
func (s *Store) Ghi(e Event) { s.events = append(s.events, e) }

func (s *Store) DungLai() *TaiKhoan { // replay
	t := &TaiKhoan{}
	for _, e := range s.events { e.apply(t) } // gấp từng sự kiện
	return t
}

Ảnh chụp đoạn mã Go nền tối minh hoạ event sourcing lưu chuỗi sự kiện thay vì trạng thái hiện tại, sự kiện là sự thật đã xảy ra bất biến type Event interface apply con trỏ TaiKhoan type MoTaiKhoan struct Chu string type NapTien struct SoTien int type RutTien struct SoTien int func e NapTien apply t con trỏ TaiKhoan t SoDu cộng bằng e SoTien func e RutTien apply t con trỏ TaiKhoan t SoDu trừ bằng e SoTien, trạng thái không lưu dựng lại từ chuỗi sự kiện type Store struct events slice Event chỉ append func s con trỏ Store Ghi e Event s events bằng append s events e func s con trỏ Store DungLai trả con trỏ TaiKhoan phát lại replay t bằng TaiKhoan for underscore e range s events e apply t gấp từng sự kiện return t, snapshot chỉ replay sự kiện sau mốc func replaySnapshot snap con trỏ State tuMoc int ev slice Ev trả con trỏ State s bằng State n snap n bắt đầu từ trạng thái đã chụp for range ev tuMoc s n cộng cộng chỉ replay phần mới return s không snapshot log dài dựng lại càng chậm snapshot chặn trần, vì sao dùng audit biết trạng thái tại bất kỳ thời điểm quá khứ không mất thông tin mọi thay đổi được ghi lại debug replay dựng lại đúng lỗi bằng chuỗi sự kiện nguồn sự thật là log sự kiện read model chỉ là chiếu

Hình 1: Sự kiện (MoTaiKhoan, NapTien, RutTien) là sự thật bất biến, mỗi cái biết apply lên trạng thái. Store chỉ append; DungLai replay toàn bộ để dựng trạng thái. Snapshot chỉ replay sự kiện sau một mốc đã chụp.

Đo thật: dựng lại và truy vết lịch sử

Chạy thật trong go-lab (Go 1.23): ghi MoTaiKhoan An, NapTien 500, RutTien 200, NapTien 100, rồi dựng lại:

Trạng thái hiện tại: {Chu:An SoDu:400 Mo:true}
Lịch sử (replay từng bước):
  sau sự kiện 2 (NapTien): số dư = 500
  sau sự kiện 3 (RutTien): số dư = 300
  sau sự kiện 4 (NapTien): số dư = 400

Số dư 400 được dựng lại đúng (500−200+100). Nhưng đây mới là sức mạnh thật: vì ta có toàn bộ chuỗi sự kiện, ta biết trạng thái tại bất kỳ thời điểm nào trong quá khứ — chỉ cần replay tới sự kiện thứ N. Với hệ tài chính, kiểm toán, hay bất cứ nơi nào cần "trạng thái tại thời điểm X", đây là tính năng không thể có với lưu trạng thái thông thường. Không thông tin nào bị mất; mọi thay đổi là một bản ghi vĩnh viễn.

Snapshot: chặn trần chi phí replay

Vấn đề rõ ràng của replay: log càng dài, dựng lại càng chậm — vì phải phát lại mọi sự kiện từ đầu. Giải pháp là snapshot: chụp trạng thái tại một mốc định kỳ, và chỉ replay các sự kiện sau mốc đó:

func replaySnapshot(snap *State, tuMoc int, ev []Ev) *State {
	s := &State{n: snap.n}      // bắt đầu từ trạng thái đã chụp
	for range ev[tuMoc:] { s.n++ } // chỉ replay phần MỚI
	return s
}

Đo thật với 100.000 sự kiện:

Ảnh chụp bảng kết quả đo thật nền tối trạng thái dựng lại từ sự kiện snapshot nhanh hơn replay đầy đủ 1443x, go run cộng go test bench Go 1.23 arm64 10 core, chạy thật ghi sự kiện rồi dựng lại trạng thái MoTaiKhoan An NapTien 500 RutTien 200 NapTien 100 Trạng thái hiện tại Chu An SoDu 400 Mo true dựng từ replay Lịch sử replay từng bước sau sự kiện 2 NapTien số dư 500 sau sự kiện 3 RutTien số dư 300 sau sự kiện 4 NapTien số dư 400 Tổng số sự kiện lưu 4 biết trạng thái tại mọi thời điểm, benchmark replay đầy đủ vs replay từ snapshot 100k sự kiện replay toàn bộ 100k sự kiện 48.616 ns 1 alloc từ snapshot chỉ replay 100 cuối 33,69 ns 1 alloc snapshot nhanh hơn 1443x log càng dài replay đầy đủ càng chậm chụp trạng thái định kỳ rồi chỉ replay phần mới chặn trần, cái giá cần cân nhắc replay chậm dần theo số sự kiện cần snapshot định kỳ schema sự kiện đổi versioning khó sự kiện cũ phải replay được lưu trữ tăng vô hạn append only không xóa đọc trạng thái hiện tại cần replay thường ghép với CQRS, cốt lõi sự kiện sự thật bất biến quá khứ chỉ append trạng thái không lưu dựng lại bằng replay gấp từng sự kiện audit biết trạng thái tại mọi thời điểm mạnh cho tài chính snapshot chụp định kỳ chỉ replay phần mới nhanh hơn 1443x đánh đổi phức tạp versioning khó lưu trữ tăng hay ghép CQRS

Hình 2: Trạng thái dựng lại đúng (SoDu 400) và biết lịch sử tại mọi bước. Benchmark: replay toàn bộ 100k sự kiện 48.616 ns so với replay từ snapshot (chỉ 100 sự kiện cuối) 33,69 ns — nhanh hơn ~1443 lần.

  • replay toàn bộ 100k sự kiện: 48.616 ns/op.
  • từ snapshot, chỉ replay 100 cuối: 33,69 ns/op.

Nhanh hơn ~1443 lần. Nếu không snapshot, một tài khoản hoạt động 10 năm với hàng triệu sự kiện sẽ mất rất lâu để dựng lại mỗi lần. Snapshot chặn trần chi phí: dù log dài bao nhiêu, bạn chỉ replay từ snapshot gần nhất. Đây là kỹ thuật bắt buộc cho event sourcing thực tế.

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

Tiến hóa schema sự kiện (versioning) là chỗ khó nhất. Vì bạn phải replay mọi sự kiện từ quá khứ, một sự kiện NapTien ghi 5 năm trước phải vẫn replay được hôm nay — kể cả khi bạn đã đổi cấu trúc. Bạn không thể "migrate" chúng dễ như một bảng SQL; phải giữ khả năng đọc mọi phiên bản sự kiện cũ, hoặc dùng upcasting (chuyển sự kiện cũ sang định dạng mới lúc đọc). Đây là gánh nặng dài hạn, và là lý do event sourcing không nên dùng bừa.

Lưu trữ tăng vô hạn và đọc phức tạp hơn. Event store chỉ append, không xóa — dung lượng tăng mãi (cần chiến lược lưu trữ lạnh/nén). Và đọc trạng thái hiện tại luôn cần replay (hoặc snapshot), nên event sourcing gần như luôn ghép với CQRS: log sự kiện là nguồn sự thật cho ghi, còn một read model (chiếu từ sự kiện) phục vụ đọc nhanh. Hai mẫu này bổ trợ nhau chặt chẽ.

Chỉ dùng khi truy vết là yêu cầu thật. Event sourcing tỏa sáng ở domain mà lịch sử là dữ liệu nghiệp vụ: tài chính (mọi giao dịch phải truy vết), kho vận, hệ thống kiểm toán, hoặc bất cứ nơi nào "tại sao đến trạng thái này" quan trọng như chính trạng thái. Với CRUD thông thường mà chỉ cần trạng thái hiện tại, nó là phức tạp thừa khổng lồ — lưu trạng thái trực tiếp đơn giản hơn nhiều.

Ba ý mang về

  1. Event sourcing lưu chuỗi sự kiện bất biến thay vì trạng thái hiện tại: mỗi sự kiện là sự thật đã xảy ra, event store chỉ append, và trạng thái được dựng lại bằng replay (gấp từng sự kiện lên trạng thái rỗng) — đo thật số dư 400 dựng từ chuỗi nạp/rút.
  2. Lợi ích cốt lõi là truy vết toàn bộ lịch sử: biết trạng thái tại bất kỳ thời điểm nào trong quá khứ (replay tới sự kiện N), không mất thông tin nào — mạnh cho tài chính và kiểm toán mà lưu trạng thái thông thường không có.
  3. Snapshot chặn trần chi phí replay: đo thật replay toàn bộ 100k sự kiện 48.616 ns so với replay từ snapshot 33,69 ns (~1443 lần) — bắt buộc cho hệ thực tế; nhưng versioning sự kiện khó, lưu trữ tăng vô hạn, và thường phải ghép với CQRS.

Phần sau ta rời các mẫu kiến trúc lớn để về với cách mô hình hóa chính domain: Phần sau mổ xẻ Domain-Driven Design tactical patterns trong Go — entity, value object, aggregate — và cách Go thể hiện chúng khác với ngôn ngữ hướng đối tượng truyền thống.