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.

Bài tập làm thử

Bài 1 (đọc hiểu). Cho chuỗi sự kiện đã ghi: MoTaiKhoan An, NapTien 500, RutTien 200, NapTien 100. Tính số dư sau khi DungLai() chạy, và giải thích từng bước.

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

Số dư cuối = 400. DungLai() bắt đầu từ TaiKhoan{} rỗng (SoDu=0), rồi lần lượt apply từng sự kiện: sau NapTien 500 → SoDu=500; sau RutTien 200 → SoDu=300; sau NapTien 100 → SoDu=400. Trạng thái không được lưu trực tiếp ở đâu — nó luôn được dựng lại bằng cách replay toàn bộ sự kiện từ đầu.

Bài 2 (sửa lỗi). Đoạn mã sau cố "sửa" một sự kiện đã ghi để chỉnh lại số dư, vi phạm nguyên tắc cốt lõi của event sourcing:

func (s *Store) SuaSuKien(idx int, moi Event) {
	s.events[idx] = moi // ghi đè sự kiện cũ
}

Vì sao đây là sai, và cách đúng để "sửa" một giao dịch nhầm là gì?

Đáp án

Sai vì sự kiện trong event sourcing là "sự thật bất biến" — không bao giờ được sửa hay xóa, event store chỉ append. Ghi đè s.events[idx] phá vỡ tính bất biến này và làm mất khả năng truy vết đúng lịch sử thật đã xảy ra. Cách đúng: thêm một sự kiện bù trừ mới (ví dụ RutTien để hoàn lại một NapTien sai), append vào cuối chuỗi — lịch sử vẫn giữ nguyên "đã có một giao dịch sai, và sau đó đã sửa", đúng tinh thần append-only.

Bài 3 (vận dụng). Viết một sự kiện mới DoiChu{ChuMoi string} để đổi tên chủ tài khoản, cùng phương thức apply của nó, sao cho tương thích với interface Event.

Đáp án
type DoiChu struct{ ChuMoi string }

func (e DoiChu) apply(t *TaiKhoan) { t.Chu = e.ChuMoi }

Nhờ Go dùng duck typing, DoiChu tự động thỏa Event (interface{ apply(*TaiKhoan) }) chỉ cần có đúng phương thức apply. Ghi sự kiện này bằng store.Ghi(DoiChu{ChuMoi: "Bình"}), và DungLai() sẽ áp nó vào đúng vị trí trong chuỗi replay.

Bài 4 (bẫy/đánh đổi). Đo thật cho thấy replay từ snapshot (33,69 ns) nhanh hơn replay toàn bộ 100k sự kiện (48.616 ns) khoảng 1443 lần. Nếu một tài khoản hoạt động 10 năm mà hệ thống không bao giờ chụp snapshot, điều gì sẽ xảy ra, và snapshot giải quyết vấn đề đó ra sao?

Đáp án

Không snapshot nghĩa là mỗi lần cần đọc trạng thái hiện tại, hệ thống phải phát lại (replay) toàn bộ hàng triệu sự kiện tích lũy trong 10 năm từ đầu — thời gian dựng lại tăng dần theo độ dài log và cuối cùng trở nên rất chậm. Snapshot giải quyết bằng cách chụp trạng thái tại một mốc định kỳ, sau đó chỉ cần replay các sự kiện sau mốc đó (ev[tuMoc:]) — chặn trần chi phí replay ở một hằng số nhỏ, bất kể log tổng cộng dài bao nhiêu.

Bài 5 (đọc hiểu/đánh đổi). Bài viết nói "tiến hóa schema sự kiện (versioning) là chỗ khó nhất". Vì sao một sự kiện NapTien ghi 5 năm trước lại gây khó khăn khi đội phát triển muốn đổi cấu trúc struct của nó?

Đáp án

Vì event sourcing yêu cầu phải replay được mọi sự kiện từ quá khứ để dựng lại trạng thái hiện tại — không thể "migrate" dữ liệu sự kiện cũ dễ dàng như một bảng SQL (đổi cột, thêm ràng buộc). Nếu đổi cấu trúc NapTien (ví dụ thêm trường bắt buộc), sự kiện cũ được lưu theo cấu trúc cũ vẫn phải đọc và apply được với code mới. Giải pháp là 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 ngay lúc đọc) — một gánh nặng dài hạn mà bài viết coi là lý do event sourcing "không nên dùng bừa".

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.