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.
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 đó.
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ử.
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.
UPDATE lên bảng event để "sửa nhanh một lỗi"
Phòng vệ rẻ nhất và hiệu quả nhất: thu hồi quyền
UPDATE và
DELETE 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 đó:
{
"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.
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:
// 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.
Đâ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.
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.
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ả.
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.
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
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
- Martin Fowler — Event Sourcing: bài viết gốc định hình thuật ngữ này, nguồn cho toàn bộ mục 14.1 và 14.2
- Martin Fowler — CQRS: định nghĩa gốc của Command Query Responsibility Segregation, nguồn cho mục 14.4
- Microsoft Azure Architecture Center — Event Sourcing pattern: mô tả chính thức về projection, replay và snapshot, nguồn cho mục 14.3 và 14.5
- Microsoft Azure Architecture Center — CQRS pattern: nhất quán cuối giữa đường ghi và đường đọc, nguồn cho callout read-your-writes ở mục 14.4
-
Wikipedia — Optimistic concurrency control: cơ chế đứng sau ràng buộc
UNIQUE(aggregate, version)ở mục 14.2 - PostgreSQL — UNIQUE Constraints: tài liệu chính thức cho ràng buộc dùng để chặn lost update trong lab
- Wikipedia — Domain-driven design: nguồn gốc khái niệm aggregate được định nghĩa ở mục 14.1
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ình luận