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
}

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:

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ề
- 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.
- 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ó.
- 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.