Một cửa hàng online bắt đầu bằng bảng orders và một lớp OrderService làm mọi việc. Sau một năm, màn hình quản trị cần liệt kê đơn kèm tổng tiền, câu truy vấn nối năm bảng chạy chậm, và thêm index lại làm chậm việc ghi. Đó là lúc nhiều đội nghe nói tới CQRS (Command Query Responsibility Segregation) rồi áp dụng cho cả hệ thống, và phần lớn kết cục không tốt. Bài này giải thích CQRS cùng Event Sourcing bằng một đơn hàng Java 21 chạy thật trên H2: phía ghi sinh sự kiện, phía đọc dựng bảng phẳng, khoá lạc quan, replay và upcasting. Quan trọng hơn, bài chỉ ra khi nào không nên dùng CQRS.
Bài nối tiếp Nâng cấp lên Spring Boot 3: Jakarta EE, Java 17 và observability, nơi ta đã có ứng dụng quan sát được. Mô hình miền dùng cho CQRS ở đây là Aggregate của DDD trong Java: Entity, Value Object và Aggregate; lớp lưu trữ khác với Repository pattern và Unit of Work với JPA ở chỗ ta lưu sự kiện thay vì trạng thái.
CQRS là gì: tách mô hình ghi khỏi mô hình đọc
Martin Fowler tóm tắt ngắn gọn trong bài về CQRS: dùng một mô hình để cập nhật thông tin và một mô hình khác để đọc. Hướng dẫn trên Azure Architecture Center mô tả cùng ý: lệnh (command) thay đổi dữ liệu và biểu diễn một ý định nghiệp vụ như "Đặt phòng", còn truy vấn (query) không bao giờ thay đổi dữ liệu và trả về DTO phù hợp màn hình. Trang này nêu các vấn đề của mô hình CRUD dùng chung một lược đồ cho cả đọc và ghi, như biểu diễn dữ liệu đọc và ghi khác nhau, tranh chấp khoá và truy vấn phức tạp.
Điều dễ hiểu sai là mức độ tách. CQRS có hai mức:
- Cùng một database: hai mô hình code khác nhau (ví dụ entity JPA cho ghi, câu SQL hoặc projection trả DTO cho đọc), cùng bảng. Chi phí thấp, vẫn nhất quán ngay lập tức.
- Hai kho dữ liệu: phía đọc có bảng, index hoặc công cụ tìm kiếm riêng, được cập nhật từ phía ghi qua sự kiện. Mở khoá việc scale riêng, nhưng kéo theo eventual consistency.
Event Sourcing là một ý tưởng khác, hay đi cùng CQRS nhưng không bắt buộc. Theo Fowler, Event Sourcing ghi lại mọi thay đổi trạng thái thành một chuỗi sự kiện theo thứ tự; trạng thái hiện tại suy ra bằng cách phát lại (replay) chuỗi đó. Bạn có thể dùng CQRS mà vẫn lưu trạng thái bình thường, và có thể dùng Event Sourcing mà chỉ có một mô hình đọc đơn giản. Hai thứ này kết hợp tự nhiên vì kho sự kiện là mô hình ghi, còn các bảng phẳng dựng từ sự kiện là mô hình đọc.
Phía ghi của CQRS: lệnh quyết định, sự kiện là sự thật
Bắt đầu từ sự kiện. Sự kiện là điều đã xảy ra, đặt tên ở thì quá khứ và bất biến. Dùng sealed interface với record để trình biên dịch kiểm tra đầy đủ các trường hợp khi switch:
package demo.cqrs;
/** Sự kiện miền: đã xảy ra, không đổi, đặt tên ở thì quá khứ. */
public sealed interface OrderEvent {
record OrderPlaced(String orderId, String customer, String currency) implements OrderEvent {}
record ItemAdded(String orderId, String sku, int qty, long unitPriceVnd) implements OrderEvent {}
record OrderPaid(String orderId, long amountVnd) implements OrderEvent {}
record OrderCancelled(String orderId, String reason) implements OrderEvent {}
String orderId();
}Aggregate Order có hai nửa tách bạch. Nửa decide nhận lệnh, kiểm tra quy tắc và trả về danh sách sự kiện mới, không đổi trạng thái. Nửa apply nhận sự kiện và đổi trạng thái, luôn thành công vì sự kiện đã xảy ra rồi. Cũng chính apply được dùng khi nạp lại aggregate từ lịch sử, nên quy tắc nghiệp vụ không bị chạy lại khi replay:
public List<OrderEvent> addItem(String sku, int qty, long unitPriceVnd) {
if (status != Status.PLACED) throw new IllegalStateException("Chỉ thêm hàng khi đơn ở trạng thái PLACED, hiện là " + status);
if (qty <= 0 || unitPriceVnd <= 0) throw new IllegalArgumentException("Số lượng và giá phải dương");
return List.of(new ItemAdded(id, sku, qty, unitPriceVnd));
} public void apply(OrderEvent e) {
switch (e) {
case OrderPlaced p -> { id = p.orderId(); status = Status.PLACED; }
case ItemAdded i -> { totalVnd += i.qty() * i.unitPriceVnd(); itemCount += i.qty(); }
case OrderPaid p -> status = Status.PAID;
case OrderCancelled c -> status = Status.CANCELLED;
}
version++;
}Event store là một bảng chỉ thêm. Khoá chính (stream_id, version) chính là cơ chế khoá lạc quan: hai người cùng ghi version tiếp theo của một đơn thì người thứ hai vi phạm ràng buộc và bị từ chối. Đây là cách Azure Architecture Center mô tả: event store từ chối việc ghi nếu stream đã đổi kể từ lúc đọc. Cột schema_ver phục vụ phần upcasting ở dưới. Hàm append bọc cả lô sự kiện trong một transaction: nếu sự kiện thứ hai vi phạm khoá chính thì sự kiện thứ nhất cũng rollback.
public void append(String streamId, int expectedVersion, List<? extends OrderEvent> events) {
int v = expectedVersion;
try (PreparedStatement ps = cx.prepareStatement(
"INSERT INTO events(stream_id, version, type, schema_ver, payload) VALUES (?,?,?,?,?)")) {
cx.setAutoCommit(false);
try {
for (OrderEvent e : events) {
v++;
ps.setString(1, streamId); ps.setInt(2, v);
ps.setString(3, e.getClass().getSimpleName());
ps.setInt(4, e instanceof OrderPlaced ? 2 : 1);
ps.setString(5, json.writeValueAsString(e));
ps.addBatch();
}
ps.executeBatch();
cx.commit();
} catch (Exception e) { cx.rollback(); throw e; }
finally { cx.setAutoCommit(true); }
} catch (SQLException e) {
if (e.getSQLState() != null && e.getSQLState().startsWith("23"))
throw new ConcurrencyException("Stream " + streamId + " đã bị ghi bởi người khác (mong đợi v" + expectedVersion + ")", e);
throw new RuntimeException(e);
} catch (Exception e) { throw new RuntimeException(e); }
}Bộ xử lý lệnh chỉ nạp aggregate từ lịch sử, gọi hành vi và ghi sự kiện với expectedVersion. Chạy kịch bản đầu tiên (code/s6-26, lệnh java -cp target/classes:$(cat cp.txt) demo.cqrs.Main, JDK 21, H2 trong bộ nhớ):
=== 1. Phía ghi: lệnh -> sự kiện ===
Trạng thái sau 3 lệnh: Order[DH001, PLACED, total=390000, items=3, v2]
Các sự kiện đã lưu:
OrderPlaced[orderId=DH001, customer=Nguyễn Văn A, currency=VND]
ItemAdded[orderId=DH001, sku=AO-THUN-M, qty=2, unitPriceVnd=150000]
ItemAdded[orderId=DH001, sku=NON-LUOI-TRAI, qty=1, unitPriceVnd=90000]Ba lệnh sinh ba sự kiện; không có bảng orders nào, trạng thái total=390000 được suy ra từ các sự kiện. Kịch bản hai cho thấy quy tắc nằm ở decide: đơn đã thanh toán thì lệnh thêm hàng bị từ chối và số sự kiện dừng ở 4 (đặt, hai lần thêm hàng, thanh toán).
=== 2. Quy tắc miền nằm ở decide: lệnh sai bị từ chối, không ghi sự kiện nào ===
Từ chối: Chỉ thêm hàng khi đơn ở trạng thái PLACED, hiện là PAID
Số sự kiện của DH001: 4Phía đọc của CQRS: bảng phẳng dựng từ sự kiện
Phía đọc trong CQRS không biết gì về Order. Mô hình đọc chỉ có một bảng phẳng order_summary với đúng các cột màn hình cần, và một projector đọc sự kiện theo thứ tự ghi toàn cục (position) để cập nhật bảng đó. Projector lưu checkpoint trong cùng transaction với việc cập nhật bảng: nếu lỗi giữa chừng, cả hai cùng rollback nên không áp dụng sự kiện hai lần.
public int runOnce(int batch) throws SQLException {
long pos = checkpoint();
List<EventStore.Stored> events = store.loadAfter(pos, batch);
cx.setAutoCommit(false);
try {
for (EventStore.Stored s : events) { project(s.event()); pos = s.position(); }
try (PreparedStatement ps = cx.prepareStatement("UPDATE projection_checkpoint SET position=? WHERE name='order_summary'")) {
ps.setLong(1, pos); ps.executeUpdate();
}
cx.commit();
} catch (SQLException | RuntimeException e) { cx.rollback(); throw e; }
finally { cx.setAutoCommit(true); }
return events.size();
}Điểm mấu chốt của CQRS hai kho dữ liệu là khoảng trễ giữa ghi và đọc. Kịch bản 3 truy vấn read model trước và sau khi projector chạy:
=== 3. Phía đọc: eventual consistency ===
Truy vấn TRƯỚC khi projection chạy: (chưa có trong read model)
Projection áp dụng 4 sự kiện
Truy vấn SAU khi projection chạy: Nguyễn Văn A | 3 món | 390,000 VND | PAIDNgay sau khi lệnh thanh toán thành công, truy vấn vẫn trả "chưa có trong read model". Trong thực tế projector chạy ngầm sau vài mili giây tới vài giây, nhưng giao diện phải chịu được khoảng trễ đó. Azure Architecture Center nêu rõ đây là đánh đổi: khi tách kho đọc và ghi, dữ liệu đọc có thể chưa phản ánh thay đổi mới nhất. Cách xử lý thường dùng: hiển thị kết quả từ phản hồi của lệnh thay vì truy vấn lại, hoặc trả version của stream để client chờ read model bắt kịp.
Ở môi trường thật, ghi sự kiện và phát lên message broker không nằm trong một transaction phân tán, nên Azure khuyên dùng Transactional Outbox và consumer idempotent để chịu được việc giao trùng. Ví dụ trong bài để projector đọc thẳng bảng sự kiện cùng database, là cách đơn giản nhất. Giới hạn: demo chỉ có một writer. Với nhiều writer đồng thời, position tự tăng có thể commit không theo thứ tự (giao dịch có position nhỏ commit sau giao dịch có position lớn) và projector dùng position > checkpoint sẽ bỏ sót sự kiện đó; khi đó phải xử lý khoảng trống, chỉ đọc tới ngưỡng đã commit hoặc dùng outbox.
Xung đột đồng thời trong CQRS: khoá lạc quan
Hai nhân viên cùng mở đơn DH002 ở version 0; A thêm áo, B thêm quần. Với CRUD thiếu cột version, người ghi sau đè người ghi trước (lost update). Với event store, mỗi lần ghi mang theo expectedVersion:
=== 4. Khoá lạc quan: hai người cùng sửa một đơn ===
B bị từ chối: Stream DH002 đã bị ghi bởi người khác (mong đợi v0)
DH002 sau xung đột: Order[DH002, PLACED, total=100000, items=1, v1]B bị từ chối, và việc cần làm là nạp lại aggregate, kiểm tra lại quy tắc rồi thử lại hoặc báo người dùng. Xung đột được phát hiện ở mức một stream, rẻ và rõ ràng.
Replay: dựng lại read model của CQRS khi yêu cầu đổi
Giả sử bộ phận kinh doanh muốn thêm một báo cáo mới. Với CRUD, dữ liệu chưa từng lưu thì không còn. Với Event Sourcing, mọi thông tin nằm trong sự kiện; chỉ cần sửa projector, xoá read model và phát lại. Kịch bản 5 làm đúng việc này:
=== 5. Dựng lại read model từ đầu bằng replay ===
Trước rebuild: DH002 = Trần Thị B | 1 món | 100,000 VND | PLACED
Đã replay 6 sự kiện
Sau rebuild: DH001 = Nguyễn Văn A | 3 món | 390,000 VND | PAID
Sau rebuild: DH002 = Trần Thị B | 1 món | 100,000 VND | PLACEDSáu sự kiện được replay và read model trở về đúng trạng thái. Fowler cũng chỉ ra hệ quả: replay sẽ gửi lại thông điệp ra bên ngoài nếu code xử lý sự kiện có tác dụng phụ (email, cổng thanh toán). Vì vậy projector phải thuần tuý, chỉ ghi vào read model.
Snapshot: khi stream quá dài
Replay toàn bộ chậm dần khi stream dài; snapshot lưu trạng thái tại một version để chỉ replay phần đuôi. Stream của một đơn hàng thường chỉ vài chục sự kiện nên snapshot hiếm khi cần; nó có ý nghĩa với aggregate sống lâu như tài khoản hay sổ cái. Chúng tôi đo bằng demo.cqrs.Bench (Apple M4, 16 GB, JDK 21, H2 trong bộ nhớ, trung bình 200 lần sau khi khởi động JIT, chạy hai lần liên tiếp để thấy độ dao động):
Số sự kiện Replay toàn bộ (ms) Snapshot + 50 sự kiện (ms)
100 0.19 0.08
1000 0.52 0.02
10000 4.69 0.02
Số sự kiện Replay toàn bộ (ms) Snapshot + 50 sự kiện (ms)
100 0.26 0.12
1000 0.48 0.03
10000 3.77 0.02 Với 10.000 sự kiện, nạp đầy đủ mất khoảng 4 đến 5 ms trên H2 trong bộ nhớ, còn snapshot cộng 50 sự kiện đuôi chỉ cỡ 0,02 ms. Database nằm trong cùng tiến trình, không có độ trễ mạng; với PostgreSQL hay MySQL thật, thời gian đọc sẽ lớn hơn nhiều. Điều đáng nhớ là xu hướng: replay tăng theo độ dài stream, snapshot gần như không đổi. Azure Architecture Center gợi ý lập snapshot sau mỗi N sự kiện và nhấn mạnh snapshot chỉ là tối ưu hoá; luồng sự kiện vẫn là nguồn sự thật và có thể dựng lại snapshot bất cứ lúc nào.
Đổi cấu trúc sự kiện: upcasting
Sự kiện đã ghi thì không sửa. Nhưng yêu cầu thì thay đổi: tháng 3 bạn thêm trường currency vào OrderPlaced, trong khi hàng triệu sự kiện cũ không có trường này. Azure Architecture Center nêu bốn chiến lược: đọc linh hoạt (bỏ qua trường lạ, dùng mặc định cho trường thiếu), gắn phiên bản vào sự kiện, upcasting (chuyển sự kiện cũ sang schema mới khi đọc, dữ liệu lưu giữ nguyên) và migrate tại chỗ (phương án cuối vì phá tính bất biến). Axon Framework cũng dùng upcaster xâu chuỗi theo từng revision và gọi đây là "non-destructive refactoring". Ví dụ của chúng tôi cài upcasting đơn giản trong hàm đọc:
private OrderEvent read(String type, int schemaVer, String payload) throws Exception {
JsonNode n = json.readTree(payload);
if (type.equals("OrderPlaced") && schemaVer < 2) ((ObjectNode) n).put("currency", "VND");
return switch (type) {
case "OrderPlaced" -> json.treeToValue(n, OrderPlaced.class);
case "ItemAdded" -> json.treeToValue(n, ItemAdded.class);
case "OrderPaid" -> json.treeToValue(n, OrderPaid.class);
case "OrderCancelled" -> json.treeToValue(n, OrderCancelled.class);
default -> throw new IllegalStateException("Loại sự kiện lạ: " + type);
};Kịch bản 6 chèn thẳng một sự kiện schema v1 (không có currency) rồi đọc ra bằng code mới:
=== 6. Upcasting: sự kiện cũ (schema v1) chưa có currency ===
Đọc ra: OrderPlaced[orderId=DH000, customer=Khách cũ, currency=VND]Sự kiện cũ được đọc ra với currency=VND mà dòng đã lưu không đổi. Vì thế schema_ver nên có từ ngày đầu.
Khi nào không nên dùng CQRS và Event Sourcing
Đây là phần quan trọng nhất của bài về CQRS. Fowler viết rằng CQRS là bước nhảy tư duy lớn, chỉ hợp với một thiểu số nhỏ các miền, và theo kinh nghiệm của ông, phần lớn các nỗ lực áp dụng CQRS diễn ra không tốt. Azure Architecture Center ghi CQRS không phù hợp khi quy tắc nghiệp vụ đơn giản và giao diện CRUD là đủ. Về Event Sourcing, tài liệu này cảnh báo việc chuyển sang hoặc rời khỏi nó rất tốn kém, và với đa số hệ thống, quản lý dữ liệu truyền thống là đủ.
| Tình huống | Nên chọn | Lý do |
|---|---|---|
| Trang quản trị CRUD, danh mục, cấu hình | CRUD thường | CQRS không đem lại lợi ích đủ bù độ phức tạp |
| Truy vấn báo cáo chậm, ghi bình thường | Read replica, view, bảng báo cáo | Fowler: nếu chỉ cần chuyển tải truy vấn nặng, một database báo cáo có thể là đủ |
| Đọc nhiều gấp hàng chục lần ghi, cần scale riêng | CQRS cùng database, rồi tách kho đọc nếu cần | Tách dần, mỗi bước đo được lợi ích |
| Cần lịch sử đầy đủ, kiểm toán, tái dựng trạng thái (sổ cái, thanh toán) | Event Sourcing cho đúng aggregate đó | Lịch sử là yêu cầu nghiệp vụ, không phải tiện ích |
| Cần đọc ngay thứ vừa ghi, nhất quán mạnh | Không tách kho đọc | Eventual consistency là bản chất của tách kho |
| Đội chưa quen kiến trúc hướng sự kiện, MVP, hệ thống ngắn hạn | CRUD | Chi phí học và vận hành cao, khó quay đầu |
Nguyên tắc hợp lý: áp dụng CQRS theo từng bounded context, không phải toàn hệ thống, như Fowler nói rõ. Ví dụ, sổ ghi nhận thanh toán dùng Event Sourcing, còn danh mục sản phẩm và hồ sơ khách hàng vẫn là CRUD qua cổng và adapter quen thuộc.
Những vấn đề thực tế của Event Sourcing
- Dữ liệu cá nhân và quyền được xoá: sự kiện bất biến mâu thuẫn với yêu cầu xoá dữ liệu cá nhân. Giải pháp: lưu dữ liệu cá nhân ngoài event store và tham chiếu bằng id, hoặc mã hoá theo từng chủ thể rồi xoá khoá (crypto-shredding); phải thiết kế từ đầu.
- Thiết kế sự kiện theo ý định:
ItemAddedcó ý nghĩa hơnTotalChanged. Sự kiện chỉ ghi trạng thái mới biến event store thành nhật ký thay đổi vô nghĩa. - Message broker không phải event store: Kafka thích hợp để phân phối sự kiện tới projector và hệ thống khác, nhưng thiếu truy vấn theo stream và khoá lạc quan theo từng thực thể.
Lỗi thường gặp
- Dùng CQRS cho mọi thứ: biến một trang CRUD thành năm lớp và hai bảng.
- Gộp quy tắc vào
apply:applyném lỗi nghiệp vụ thì sự kiện cũ không replay được.
Checklist trước khi chọn CQRS cho một hệ thống
- Trước khi chọn CQRS, đã thử tối ưu truy vấn, index, read replica, view chưa? Có số đo cho thấy chưa đủ không?
- Miền có quy tắc phức tạp ở phía ghi để CQRS có đất dụng võ, hay chỉ là CRUD?
- Giao diện có chịu được eventual consistency không, và đã nghĩ cách hiển thị kết quả ngay sau khi ghi chưa?
- Nếu dùng Event Sourcing: có yêu cầu nghiệp vụ về lịch sử không? Có kế hoạch cho upcasting, snapshot, dữ liệu cá nhân?
Tóm lại, CQRS giải một bài toán cụ thể, còn Event Sourcing chỉ đáng khi lịch sử có giá trị nghiệp vụ. Hãy bắt đầu từ phiên bản nhẹ nhất, trong một bounded context nhỏ, và để số đo quyết định.
Bài tiếp theo
Khi đã tách phía ghi và phía đọc theo CQRS, dịch vụ nào cũng phải gọi dịch vụ khác qua mạng, và mạng thì không đáng tin. Mời bạn đọc tiếp Circuit Breaker, Retry và Bulkhead với Resilience4j để xem cách một ứng dụng Spring tự bảo vệ khi dịch vụ phụ thuộc chậm hoặc lỗi.



