Tại sao phải làm khổ mình với Event Sourcing và CQRS?
Lối tư duy CRUD (Create, Read, Update, Delete) truyền thống vốn đã quá quen thuộc. Khi người dùng đổi tên, bạn chỉ cần thực hiện một lệnh UPDATE. Xong! Tuy nhiên, hành động này vô tình xóa sạch dấu vết cũ. Chắc chắn bạn không muốn rơi vào cảnh khách hàng hỏi: “Tại sao số dư ví của tôi hụt 500k lúc 2 giờ sáng?” mà trong DB chỉ còn lại con số cuối cùng.
Tôi từng tham gia tái cấu trúc một hệ thống ví điện tử xử lý hơn 50.000 giao dịch mỗi ngày. Bài học xương máu là nếu không có cơ chế lưu vết (audit log) chuẩn chỉnh, việc debug hay đối soát dữ liệu sẽ trở thành thảm họa. Đó là lúc Event Sourcing và CQRS phát huy sức mạnh. Thay vì lưu trạng thái tĩnh, chúng ta lưu lại mọi hành động đã xảy ra. Đồng thời, việc tách biệt Model đọc và ghi giúp hệ thống không bị nghẽn khi lượng truy cập tăng đột biến.
Triển khai nhanh: Biến PostgreSQL thành Event Store
Bạn không nhất thiết phải dùng đến Kafka hay EventStoreDB ngay từ đầu. PostgreSQL với kiểu dữ liệu JSONB cực kỳ mạnh mẽ, đủ sức gánh vác vai trò này cho các dự án vừa và lớn.
Bước 1: Thiết kế bảng lưu trữ sự kiện
Cấu trúc bảng cần tối giản nhưng đủ thông tin để tái hiện quá khứ.
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL, -- Ví dụ: Order ID hoặc User ID
event_type VARCHAR(50) NOT NULL, -- MoneyDeposited, OrderCancelled
payload JSONB NOT NULL, -- Dữ liệu thô của sự kiện
version INT NOT NULL, -- Chống xung đột dữ liệu (Concurrency)
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
Bước 2: Xử lý Command Side với Node.js
Hãy xem cách chúng ta ghi lại một sự kiện nạp tiền vào tài khoản.
async function depositMoney(userId, amount) {
const event = {
aggregate_id: userId,
event_type: 'MoneyDeposited',
payload: { amount, currency: 'VND' },
version: await getNextVersion(userId)
};
await db.query(
'INSERT INTO events (aggregate_id, event_type, payload, version) VALUES ($1, $2, $3, $4)',
[event.aggregate_id, event.event_type, event.payload, event.version]
);
console.log(`[Event Saved] User ${userId} nạp thành công ${amount} VND`);
}
Giải mã sự kết hợp: Khi quá khứ định nghĩa hiện tại
Event Sourcing: Quá khứ là sự thật duy nhất
Trong thế giới này, số dư tài khoản không phải là một con số cố định trong DB. Nó là kết quả của việc chạy lại (replay) toàn bộ lịch sử giao dịch.
- Giao dịch 1: +1.000.000đ
- Giao dịch 2: -200.000đ
- Giao dịch 3: +50.000đ
- => Trạng thái hiện tại: 850.000đ
Nếu logic tính toán bị lỗi, bạn chỉ cần sửa code và replay lại. Dữ liệu của bạn sẽ luôn chính xác tuyệt đối mà không sợ bị ghi đè mất dấu.
CQRS: Tách biệt để bứt phá
CQRS (Command Query Responsibility Segregation) chia ứng dụng làm hai nhánh riêng biệt:
- Write Side (Command): Tiếp nhận yêu cầu, kiểm tra logic và ghi Event. Nhánh này ưu tiên tính toàn vẹn dữ liệu.
- Read Side (Query): Sử dụng database đã được dàn phẳng (denormalized) để truy vấn thần tốc.
Thực tế cho thấy, việc replay 1 triệu sự kiện để xem số dư mỗi khi người dùng mở App là bất khả thi. Vì vậy, chúng ta cần một Worker nghe các sự kiện mới nhất để cập nhật vào một bảng user_balances. Lúc này, phía Frontend chỉ cần thực hiện một lệnh SELECT đơn giản.
Tối ưu Read Model (Projections)
Đây là cầu nối giúp hệ thống đạt được tính nhất quán sau cùng (Eventual Consistency).
async function projectMoneyDeposited(event) {
// Cập nhật ngay lập tức bảng Read Model để user thấy số dư mới
await db.query(
'UPDATE user_balances SET balance = balance + $1, updated_at = NOW() WHERE user_id = $2',
[event.payload.amount, event.aggregate_id]
);
}
Để hệ thống mượt mà hơn, hãy cân nhắc sử dụng Redis Pub/Sub hoặc RabbitMQ. Khi Write Side vừa lưu xong Event, Read Side sẽ nhận được tín hiệu và cập nhật dữ liệu trong vài mili giây.
Kinh nghiệm thực chiến: Những hố đen cần tránh
Áp dụng bộ đôi này vào dự án thực tế không màu hồng như lý thuyết. Dưới đây là 3 lưu ý quan trọng tôi rút ra được:
1. Đừng “đao to búa lớn” quá sớm
Nếu bạn chỉ làm app quản lý Todo list hoặc trang tin tức, hãy dùng CRUD cho nhẹ đầu. Event Sourcing chỉ thực sự đáng tiền khi logic nghiệp vụ cực kỳ rắc rối hoặc yêu cầu kiểm toán (Audit) khắt khe.
2. Snapshot – Giải pháp cho hiệu năng
Khi một khách hàng trung thành có hơn 5.000 giao dịch, việc replay sẽ bắt đầu chậm lại. Giải pháp là dùng Snapshot. Cứ sau mỗi 100 sự kiện, hãy lưu lại trạng thái tại thời điểm đó. Khi cần tính số dư, bạn chỉ cần lấy Snapshot gần nhất và cộng dồn thêm vài sự kiện mới phát sinh.
3. Versioning và tính bất biến
Dữ liệu sự kiện một khi đã ghi là không thể sửa đổi (Immutable). Tuy nhiên, yêu cầu kinh doanh thì luôn thay đổi. Nếu bạn thêm một trường bắt buộc vào Event mới, hãy đảm bảo code cũ vẫn có thể xử lý các Event phiên bản 1.0 mà không gây crash hệ thống.
Lời kết
Triển khai Event Sourcing và CQRS với Node.js và PostgreSQL không quá khó. Cái khó nhất là rèn luyện tư duy từ “lưu trạng thái” sang “lưu hành động”. Khi đã làm chủ được kỹ thuật này, bạn sẽ tự tin hơn khi thiết kế những hệ thống quy mô lớn, đòi hỏi độ tin cậy cực cao.
Bạn đã sẵn sàng từ bỏ UPDATE để chuyển sang INSERT mọi thứ chưa? Hãy thử áp dụng cho một module nhỏ trong dự án tới để thấy sự khác biệt.

