Mọi hệ thống trong series này tới giờ đều lưu trạng thái hiện tại: số dư là 100, ảnh đại diện là avatar-v3.png, đơn hàng đang ở trạng thái shipped. Mỗi lần có gì thay đổi, ta ghi đè giá trị cũ. Bài này đảo ngược điều đó: lưu chuỗi việc đã xảy ra, và coi trạng thái hiện tại chỉ là kết quả tính ra từ chuỗi đó.

Lab dựng một event store thật trên PostgreSQL với 200.000 sự kiện. Ba phép đo đáng chú ý. Thứ nhất, một projection không idempotent bị chạy lại lần thứ hai cho ra số dư gấp đúng 2,00 lần — 9.132.892 thay vì 4.566.446 — và không có lỗi nào được ném ra, read model chỉ đơn giản là sai. Thứ hai, dựng lại toàn bộ read model từ 200.000 sự kiện mất 235 ms khi replay bằng code ứng dụng, tức replay ở quy mô này là chuyện thường ngày chứ không phải thao tác khẩn cấp. Thứ ba, snapshot giúp aggregate dài nhanh 6,6 lần nhưng lại làm aggregate ngắn chậm đi — nó là công cụ có điều kiện, không phải bước bắt buộc.

ℹ️ Cấu hình máy đo
Apple M1 Max, 10 lõi, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. PostgreSQL 18 trong container native arm64, giới hạn 1 CPU và 512 MB RAM; worker chạy node:22-alpine, không dependency, dùng lại minipg.js — client PostgreSQL viết tay từ Bài 7.

Toàn bộ số liệu đến từ worker/eventstore.js với 200.000 sự kiện trải trên 1.000 aggregate. Cả database lẫn worker nằm cùng một máy, nên các con số mili giây ở đây không tính chi phí mạng thật; hãy đọc chúng như tỉ lệ giữa các phương án, đừng đọc như giá trị tuyệt đối cho hệ thống của bạn.

14.1 Trạng thái là kết quả của lịch sử

Cùng một tài khoản ngân hàng, hai cách lưu. Cách quen thuộc: một dòng, cột balance = 100, mỗi giao dịch là một lệnh UPDATE. Cách của event sourcing: ba dòng Deposited(+50), Deposited(+70), Withdrawn(-20), và số 100 là thứ ta cộng ra khi cần.

Tài khoản ngân hàng ở đây là ví dụ cho một khái niệm sẽ lặp lại xuyên suốt bài này: aggregate — đơn vị mà lịch sử sự kiện được nhóm lại theo nó, mỗi đơn vị có chuỗi sự kiện và số version riêng, tách biệt hoàn toàn với mọi đơn vị khác. Tài khoản của bạn là một aggregate; tài khoản của người khác là một aggregate riêng với chuỗi sự kiện của chính nó. Một đơn hàng, một giỏ hàng, một hợp đồng bảo hiểm — mỗi cái cũng là một aggregate theo đúng nghĩa đó. Ngược lại, một dòng cấu hình hệ thống hay một bản ghi trong danh mục sản phẩm thường không cần trở thành aggregate: không ai cần biết ai đã đổi mô tả sản phẩm ba tháng trước — xem cạm bẫy ngay bên dưới.

Khác biệt không nằm ở chỗ cách nào "đúng hơn" mà ở chỗ chúng bảo toàn được gì và mất gì. Từ chuỗi sự kiện, luôn tính ra được trạng thái hiện tại. Từ trạng thái hiện tại, không thể tái tạo lịch sử — thông tin đó đã bị lệnh UPDATE ghi đè và không nằm ở đâu nữa cả. Phép biến đổi này một chiều, và toàn bộ giá trị của event sourcing nằm ở chiều bị mất đó.

Cùng ba giao dịch — hai cách lưu, hai thứ còn lại sau cùng LƯU TRẠNG THÁI — mỗi giao dịch là một UPDATE balance = 0 balance = 50 balance = 120 balance = 100 ← chỉ ô này còn tồn tại Ba ô mờ ở trên đã bị ghi đè. Không có cách nào biết số dư từng là 120, hay ai đã rút 20, hay lúc nào. Câu hỏi "tháng trước số dư trung bình là bao nhiêu" không trả lời được — dữ liệu để trả lời đã bị xoá. LƯU SỰ KIỆN — chỉ có INSERT, không bao giờ UPDATE Deposited +50 Deposited +70 Withdrawn −20 cả ba đều còn nguyên gấp lại → 100 Trạng thái là thứ TÍNH RA, không phải thứ được lưu. Muốn biết số dư ngày 3/7 thì gấp tới ngày đó.
Từ log tính ra được trạng thái. Từ trạng thái không tính ngược lại được log — phép biến đổi này một chiều.

Ba khả năng có được gần như miễn phí từ cách lưu này. Audit trail đầy đủ: mỗi thay đổi là một dòng có thời điểm và nguyên nhân, không phải một trường updated_at nói rằng "có gì đó đã đổi". Debug bằng cách tua lại: gấp log tới đúng thời điểm trước khi sự cố xảy ra rồi xem trạng thái lúc đó, thay vì đoán từ log ứng dụng. Trả lời câu hỏi chưa nghĩ tới lúc thiết kế — và đây là khả năng đáng giá nhất: khi bộ phận kinh doanh hỏi "bao nhiêu người nạp tiền rồi rút hết trong vòng 24 giờ", với bảng trạng thái bạn phải trả lời "chúng tôi không lưu dữ liệu đó"; với event log, câu trả lời đã nằm sẵn trong lịch sử.

⚠️ Cạm bẫy: áp event sourcing cho CRUD thường
Với một bảng cấu hình, một danh mục sản phẩm, một form hồ sơ người dùng — không ai cần biết trường "mô tả" từng có nội dung gì ba tháng trước. Đổi lại, mọi lập trình viên chạm vào phần đó phải hiểu event, projection, replay, versioning; mọi truy vấn đơn giản trở thành một câu hỏi kiến trúc; và một câu SELECT mà lẽ ra là một dòng thì phải đi qua read model.

Câu hỏi kiểm tra: có ai từng hỏi bạn "trước đây giá trị này là gì" chưa? Nếu chưa, và không có ràng buộc pháp lý nào bắt lưu lịch sử, thì bạn đang trả một cái giá rất cao cho một khả năng không ai dùng. Event sourcing thường chỉ đáng cho vài aggregate cốt lõi — tiền, đơn hàng, quyền hạn — chứ không phải cho toàn hệ thống.

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

Một quy ước đặt tên rất nhỏ nhưng quyết định gần như mọi thứ phía sau: sự kiện được đặt tên ở thì quá khứ. OrderPlaced, không phải PlaceOrder. Cái thứ hai là một command — một yêu cầu, có thể bị từ chối, có thể thất bại. Cái thứ nhất là một fact — nó đã xảy ra rồi, và không có cách nào làm nó chưa từng xảy ra.

Command Event
Đặt tên PlaceOrder, Withdraw OrderPlaced, Withdrawn
Có thể bị từ chối? Có — validate, không đủ số dư, không đủ quyền Không — nó đã xảy ra rồi
Số người nhận Đúng một — người xử lý nó Bao nhiêu cũng được — mỗi projection là một người nghe
Sửa sai bằng cách Không gửi, hoặc gửi lại Ghi thêm sự kiện bù

Dòng cuối là dòng hay bị vi phạm nhất. Khi phát hiện một sự kiện ghi sai số tiền, phản xạ tự nhiên là UPDATE lại cho đúng. Đừng. Ghi thêm một sự kiện AmountCorrected — đúng như kế toán chưa bao giờ tẩy xoá một bút toán mà luôn ghi một bút toán đảo. Lý do không phải sự thuần khiết mà rất thực tế: mọi projection đã dựng từ sự kiện cũ vẫn đang tồn tại, và mọi projection sẽ dựng lại trong tương lai sẽ đọc bản đã sửa. Hai thế giới đó không còn khớp nhau, và không ai truy được vì sao.

⚠️ Cạm bẫy: chạy UPDATE lên bảng event để "sửa nhanh một lỗi"
Đây là lỗi phá huỷ triệt để nhất trong bài này, và nó luôn bắt đầu bằng ý định tốt: một sự kiện ghi sai, sửa một dòng là xong. Nhưng toàn bộ mô hình đứng trên đúng một giả định — log là bất biến. Bỏ giả định đó thì replay không còn tái lập được quá khứ, hai read model dựng ở hai thời điểm khác nhau cho hai kết quả khác nhau, và audit trail — lý do chính bạn chọn event sourcing — trở thành một thứ không tin được.

Phòng vệ rẻ nhất và hiệu quả nhất: thu hồi quyền UPDATEDELETE trên bảng event ở tầng database, để cả bạn cũng không làm được kể cả lúc 3 giờ sáng. Toàn bộ worker/eventstore.js của lab này không có một lệnh UPDATE nào chạm vào bảng events — đó là một ràng buộc tự đặt, và nó là điều kiện để mọi con số trong bài lặp lại được.

Tính bất biến còn cho thêm một thứ miễn phí: khoá đồng thời lạc quan. Nếu mỗi sự kiện mang số version trong aggregate của nó, và có ràng buộc UNIQUE(aggregate, version), thì hai tiến trình cùng đọc "tài khoản đang ở version 1" rồi cùng ghi version 2 sẽ có đúng một người thắng. Lab chạy đúng tình huống đó:

ket_qua_concurrent.json
{
  "role": "concurrent",
  "ketQua": [
    { "who": "A", "ketQua": "THANG — su kien duoc ghi" },
    { "who": "B", "ketQua": "THUA — bi tu choi",
      "loi": "postgres: duplicate key value violates unique constraint \"events_aggregate_version_key\"" }
  ],
  "soSuKienCuoiCung": 2,
  "ghiChu": "Dung 2 su kien: version 1 va version 2. Nguoi thua KHONG de mat du lieu cua nguoi thang."
}

Đáng so sánh với bảng trạng thái thông thường: hai lệnh UPDATE balance = ... chạy cùng lúc thì lệnh sau đè lên lệnh trước, cả hai đều báo thành công, và không ai biết một thay đổi đã biến mất. Ở đây người thua nhận lỗi ngay lập tức — mà "nhận lỗi ngay" là thứ xử lý được: đọc lại version mới nhất, áp lại luật nghiệp vụ, thử lại. Đây chính là lost update mà Bài 10 phải dùng distributed lock để tránh; ở đây nó được chặn bởi một ràng buộc UNIQUE, không cần lock nào cả.

14.3 Projection: đọc log, dựng bảng đọc

Không ai truy vấn thẳng vào event log để hiển thị giao diện — gấp lại mấy nghìn sự kiện cho mỗi lần tải trang là không khả thi. Thay vào đó, một projection worker đọc log và dựng nên các bảng đã tối ưu sẵn cho từng màn hình. Bảng số dư là một projection. Danh sách giao dịch gần đây là một projection khác. Báo cáo tháng là projection thứ ba. Cả ba dựng từ cùng một log.

Một log — nhiều read model — và cách sửa lỗi bằng replay EVENT LOG append-only seq 1 · Deposited seq 2 · Withdrawn seq 3 · Deposited 200.000 sự kiện read_balances cho màn hình tài khoản recent_transactions cho màn hình lịch sử monthly_report thêm hôm nay — replay là có ngay Phát hiện projection tính sai thuế từ 6 tháng trước — sửa thế nào? ① sửa code projection ② xoá read model ③ replay từ đầu 235 ms cho 200.000 sự kiện Không có event log thì bước ③ không tồn tại: dữ liệu sai là dữ liệu duy nhất bạn có, và phải sửa tay từng dòng.
Read model là thứ dùng một lần rồi vứt được. Đó là điều làm nên khác biệt khi có bug.

Lab đo cả hai cách dựng read model từ 200.000 sự kiện:

Cách replay Thời gian Tốc độ Khi nào đúng với thực tế
Một câu SQL GROUP BY 43 ms ~4,6 triệu sự kiện/s Chỉ khi projection gấp gọn được thành một phép tổng hợp
Kéo về ứng dụng rồi gấp trong code 235 ms (174 ms là kéo dữ liệu) ~850 nghìn sự kiện/s Sát thực tế hơn — projection thật có luật nghiệp vụ, rẽ nhánh theo loại sự kiện

Con số cần mang theo là con số thứ hai, và nó nói điều này: ở quy mô hàng trăm nghìn sự kiện, dựng lại toàn bộ read model là chuyện của vài trăm mili giây. Ở quy mô hàng trăm triệu, cùng tốc độ đó cho ra vài phút. Cả hai đều nằm trong khoảng "chạy được trong giờ làm việc", chứ không phải "phải xin lịch bảo trì". Đó là lý do câu "sửa bug rồi replay lại" là một câu nói thật chứ không phải khẩu hiệu.

Nhưng nó chỉ thật khi projection idempotent. Lab chạy đúng một projection ngây thơ — loại chỉ cộng dồn mọi sự kiện nó đọc được — rồi cho chạy lần thứ hai:

ket_qua_replay2x.json
// IDEMPOTENT=0 — projection ngay tho
{
  "soSuKienTrongLog": 200000,
  "dungPhaiLa": 4566446,
  "lan1": { "eventsApplied": 200000, "ms": 44, "tongSoDu": 4566446 },
  "lan2": { "eventsApplied": 200000, "ms": 39, "tongSoDu": 9132892 },
  "ketLuan": "SAI — read model lech 4566446 (gap 2.00 lan)"
}

// IDEMPOTENT=1 — co checkpoint `projection_state.last_seq`
{
  "lan1": { "eventsApplied": 200000, "ms": 43, "tongSoDu": 4566446 },
  "lan2": { "eventsApplied": 0,      "ms": 1,  "tongSoDu": 4566446 },
  "ketLuan": "DUNG — replay bao nhieu lan cung ra cung ket qua"
}

Chi tiết đáng sợ nhất không phải con số gấp đôi mà là dòng "ms": 39: lần chạy sai thành công, nhanh hơn cả lần đầu, không một cảnh báo nào. Read model chỉ đơn giản là sai từ giây đó trở đi. Bản idempotent thì lần thứ hai áp 0 sự kiện và kết thúc trong 1 ms — vì nó nhớ được mình đã xử lý tới seq nào.

⚠️ Cạm bẫy: projection không idempotent
Bạn sẽ replay ngoài ý muốn, không phải theo kế hoạch. Worker bị restart giữa chừng, deploy chạy hai bản cùng lúc trong vài giây, một job cron chạy đè lên chính nó, message queue giao lại một sự kiện đã giao (Bài 12 — at-least-once là tất yếu). Mọi tình huống đó đều dẫn tới cùng một chuyện: một sự kiện được áp hai lần.

Đây chính xác là lý do Bài 11 phải học trước bài này. Hai cách rẻ nhất để có tính idempotent: lưu checkpoint (last_seq đã xử lý, như lab làm), hoặc viết phép cập nhật ở dạng đặt giá trị thay vì cộng dồn (SET balance = <tổng tính lại> thay vì SET balance = balance + x). Phép cộng dồn là thứ không bao giờ idempotent.

14.4 CQRS: tách đường ghi khỏi đường đọc

Khi đã có event log và projection, hệ thống tự nhiên tách thành hai nửa với hai bộ yêu cầu khác hẳn nhau. CQRS (Command Query Responsibility Segregation) chỉ là tên gọi của việc thừa nhận sự tách đó và thiết kế cho đúng nó.

Đường GHI (command) Đường ĐỌC (query)
Việc phải làm Validate luật nghiệp vụ, sinh sự kiện, append Đọc bảng đã dựng sẵn cho đúng màn hình
Tối ưu cho Tính đúng đắn và thứ tự Tốc độ đọc — được phép trùng lặp dữ liệu thoải mái
Scale thế nào Khó — phải giữ thứ tự trong mỗi aggregate Dễ — nhân bản bao nhiêu bản đọc cũng được (Bài 7)
Nhất quán Mạnh — ràng buộc do database thực thi Nhất quán cuối — luôn trễ sau log một nhịp

Dòng cuối là dòng phải trả giá. Read model được dựng sau khi sự kiện đã ghi, nên luôn có một khoảng thời gian mà log đã có sự kiện còn bảng đọc thì chưa. Khoảng đó thường vài mili giây và không ai để ý — trừ đúng một người: chính người vừa bấm nút.

Hai đường tách hẳn nhau — và khoảng trễ nằm ở giữa ĐƯỜNG GHI — command Withdraw(20) validate luật append Withdrawn Nhất quán MẠNH: UNIQUE(aggregate, version) do database thực thi. Khó scale: thứ tự trong mỗi aggregate phải giữ nguyên. ĐƯỜNG ĐỌC — query GET /balance read_balances 1 truy vấn Nhất quán CUỐI: bảng này được dựng SAU khi sự kiện đã ghi. Dễ scale: nhân bao nhiêu bản đọc cũng được, dữ liệu trùng lặp thoải mái. projection worker Khoảng giữa hai đường là chỗ người vừa bấm nút rơi vào Sự kiện đã nằm trong log — nhưng bảng đọc chưa kịp cập nhật. Người dùng tải lại trang và thấy số dư cũ, kết luận "giao dịch thất bại", rồi bấm lại lần nữa. Không có cách ghim-về-primary nào cứu được ở đây.
Read model không phải bản sao chậm của bảng ghi — nó có cấu trúc khác hẳn, nên bản vá của Bài 7 không áp dụng được.
🚨 Đây chính là bug read-your-writes của Bài 7, ở tầng kiến trúc
Bài 7 gặp đúng hiện tượng này giữa primary và replica: ghi xong đọc ngay lại thấy dữ liệu cũ. Ở đó có một bản vá gọn — ghim lệnh đọc của người vừa ghi về primary trong vài trăm mili giây.

Với CQRS thì bản vá đó không tồn tại, vì read model không phải bản sao chậm của bảng ghi — nó có cấu trúc hoàn toàn khác, và không có "primary" nào để ghim về. Ba cách thường dùng thay thế: (1) đường ghi trả về luôn kết quả đã tính, giao diện dùng nó mà không đọc lại; (2) optimistic update — giao diện tự vẽ trạng thái mới rồi đồng bộ sau; (3) đường ghi trả về số version, giao diện chờ read model đạt tới version đó rồi mới đọc. Điểm chung: cả ba đều phải thiết kế từ đầu ở tầng giao diện, không vá được sau.
💡 CQRS không bắt buộc đi kèm event sourcing
Hai khái niệm này bị gộp làm một quá thường xuyên. Bạn hoàn toàn có thể làm CQRS trên một database quan hệ bình thường: đường ghi làm việc với bảng chuẩn hoá, đường đọc dùng vài bảng phi chuẩn hoá được cập nhật bằng trigger hoặc job nền. Đó vẫn là CQRS, và nó rẻ hơn nhiều.

Chiều ngược lại cũng đúng: có event sourcing mà không CQRS cũng được, nếu bạn chỉ cần một read model duy nhất. Cái đắt là event sourcing, không phải CQRS — nên nếu vấn đề của bạn chỉ là "đọc chậm", hãy thử CQRS trước và giữ nguyên cách lưu trạng thái.

14.5 Vận hành thật: snapshot, đổi schema, và những gì bạn mất

Mục 14.3 nói replay 200.000 sự kiện mất 235 ms. Nhưng đó là replay toàn bộ hệ thống để dựng read model — một việc hiếm. Việc thường xuyên hơn là đọc trạng thái hiện tại của một aggregate để xử lý một command, và khi một aggregate tích luỹ hàng chục nghìn sự kiện, gấp lại từ đầu mỗi lần là không chấp nhận được. Snapshot là câu trả lời: lưu sẵn trạng thái tại version N, rồi chỉ gấp phần sự kiện sau đó.

Aggregate Gấp lại toàn bộ Snapshot + phần đuôi Kết quả
Dài — 100.000 sự kiện, snapshot tại v90.000 7,398 ms 1,120 ms Nhanh hơn 6,6 lần
Ngắn — 200 sự kiện, snapshot tại v180 0,377 ms 0,981 ms Chậm hơn 2,6 lần

Kết quả thứ hai là kết quả đáng nhớ hơn. Với aggregate ngắn, snapshot làm chậm đi — vì nó thêm một vòng truy vấn nữa (đọc snapshot rồi mới đọc phần đuôi), và chi phí vòng đó lớn hơn phần tiết kiệm được. Snapshot không phải bước bắt buộc của event sourcing; nó là tối ưu có điều kiện, và điều kiện là aggregate phải đủ dài. Cách làm đúng là đo phân bố độ dài aggregate của chính bạn rồi đặt ngưỡng, thay vì bật snapshot cho tất cả.

🔬 Snapshot là cache, và mọi luật của cache đều áp dụng
Snapshot có thể xoá bất cứ lúc nào mà không mất dữ liệu — dựng lại từ log là được. Điều đó có nghĩa nó là một cache, và toàn bộ Bài 5 áp dụng nguyên vẹn: nó cần chiến lược vô hiệu hoá (đổi luật gấp sự kiện thì mọi snapshot cũ thành rác), nó có thể bị lệch (snapshot ghi từ một bản code có bug thì mọi lần đọc sau đều mang theo bug đó), và nó tốn dung lượng lưu trữ.

Hệ quả thực tế quan trọng nhất: khi bạn sửa logic gấp sự kiện, phải xoá toàn bộ snapshot cùng lúc với việc deploy. Quên bước này thì hệ thống sẽ trộn lẫn trạng thái tính bằng luật cũ (từ snapshot) với sự kiện gấp bằng luật mới (phần đuôi) — một loại sai rất khó phát hiện vì nó chỉ xuất hiện ở những aggregate đã có snapshot.

Vấn đề vận hành thứ hai là schema của sự kiện đổi theo thời gian. Sự kiện năm ngoái có trường amount tính bằng đồng; năm nay bạn thêm currency. Log là bất biến nên không thể sửa các sự kiện cũ — thay vào đó, mã đọc phải hiểu được cả hai định dạng, thường bằng một lớp upcasting: đọc sự kiện phiên bản cũ và nâng nó lên dạng mới ngay lúc nạp. Lớp đó chỉ lớn dần theo năm tháng và không bao giờ xoá được — đó là một khoản nợ có thật, cần tính vào chi phí ngay từ lúc quyết định.

⚠️ Cạm bẫy: mất khả năng truy vấn tuỳ ý
Với bảng trạng thái, câu hỏi "tài khoản nào có số dư âm" là một câu WHERE balance < 0. Với event log thuần, câu hỏi đó không có câu trả lời trực tiếp — số dư không tồn tại ở đâu cả cho tới khi có ai gấp log lại. Bạn chỉ trả lời được những câu hỏi mà bạn đã dựng sẵn projection cho chúng.

Với người vận hành, điều này rất khó chịu: mỗi câu hỏi phát sinh lúc đang xử lý sự cố đều trở thành "phải viết một projection mới rồi chờ replay". Cách giảm đau thông dụng là luôn duy trì một read model dạng bảng "trạng thái hiện tại" phẳng, đủ giống mô hình quan hệ để truy vấn tuỳ ý được — nhưng lúc đó bạn đang trả chi phí của cả hai mô hình cùng lúc, và đó là sự đánh đổi cần nói ra trước khi bắt đầu chứ không phải sau.

Tự làm lại các phép đo

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab

# --- Write 200,000 events into the append-only log ---
./tools/eventstore-test.sh seed 200000     # 1802ms · 111,003 events/s

# --- Run the projection AGAIN: idempotency decides everything ---
./tools/eventstore-test.sh replay2x 0      # naive:       run 2 -> 9,132,892 (DOUBLED)
./tools/eventstore-test.sh replay2x 1      # idempotent:  run 2 -> 0 events, 1ms

# --- Replay in application code: a more honest number than one GROUP BY ---
./tools/eventstore-test.sh projectApp      # 200k events / 235ms (174ms is pulling data)

# --- Snapshot only pays off for a LONG aggregate ---
./tools/eventstore-test.sh snapshot        # 100k events: 7.398ms -> 1.120ms  (6.6x)
./tools/eventstore-test.sh snapshot acc-0  # 200 events:  0.377ms -> 0.981ms  (SLOWER)

# --- Two people writing version 2 at once: UNIQUE(aggregate, version) blocks the lost update ---
./tools/eventstore-test.sh concurrent      # A wins · B gets a duplicate-key error

Tóm lại

Event sourcing đổi câu hỏi "trạng thái hiện tại là gì" thành "những gì đã xảy ra". Phép biến đổi đó một chiều: từ log luôn tính ra được trạng thái, từ trạng thái không dựng lại được log. Toàn bộ giá trị — audit trail, tua lại để debug, trả lời câu hỏi chưa nghĩ tới lúc thiết kế — nằm ở chiều bị mất đó.

Cái giá cũng thật và phải nói cho đủ: mọi lập trình viên chạm vào phần đó phải hiểu thêm một mô hình; lớp upcasting chỉ lớn dần theo năm tháng; và truy vấn tuỳ ý — thứ rẻ nhất với bảng quan hệ — trở thành "viết projection mới rồi chờ replay". Đó là lý do event sourcing thường chỉ đáng cho vài aggregate cốt lõi chứ không phải cho cả hệ thống.

Về vận hành, hai con số đáng mang theo. Replay 200.000 sự kiện bằng code ứng dụng mất 235 ms — đủ nhanh để "sửa bug rồi dựng lại read model" là việc làm trong giờ hành chính, và đó chính là siêu năng lực của mô hình này. Nhưng nó chỉ đúng khi projection idempotent: bản ngây thơ chạy lại lần hai cho ra số dư gấp đúng 2,00 lần, thành công, nhanh hơn cả lần đầu, không một cảnh báo nào.

Và snapshot không phải bước bắt buộc mà là tối ưu có điều kiện: nhanh hơn 6,6 lần với aggregate 100.000 sự kiện, nhưng chậm hơn 2,6 lần với aggregate 200 sự kiện. Nó là một cache, nên mọi luật của cache ở Bài 5 áp dụng nguyên vẹn — kể cả luật khó chịu nhất: đổi logic thì phải xoá sạch snapshot cùng lúc deploy.

Bài 15 lùi lại một bước để nhìn toàn cảnh: sau khi đã tự trả giá cho network hop, lock phân tán, idempotency và nhất quán cuối, ta mới đủ dữ kiện để đánh giá câu hỏi mà mọi đội đều gặp — nên tách hệ thống thành microservices hay không, và tách thì mất gì. Lần này cái giá sẽ được đo, không phải phỏng đoán.

📖 Tài liệu tham khảo

Tải mã nguồn thực hành

Event store đầy đủ dùng trong lab: log append-only, hai kiểu projection (idempotent và không), snapshot, và chế độ đo hai người ghi cùng version — mọi con số trong bài đến từ file này:

Tải về eventstore.js (event log, projection, snapshot — 0 dependency)

Bài viết liên quan trong series

Bài 13: Rate Limiting & Backpressure Bài 15: Monolith vs Microservices Quay lại Lộ trình Thiết Kế Hệ Thống

Bình luận