CQRS không bắt buộc hệ thống phải có microservices, event sourcing hay hai cơ sở dữ liệu. Ý tưởng cốt lõi của Command Query Responsibility Segregation là tách mô hình thay đổi trạng thái khỏi mô hình trả dữ liệu. Command diễn đạt một ý định nghiệp vụ và có thể bị từ chối; query chỉ đọc, không tạo hiệu ứng nghiệp vụ. Mức tách có thể bắt đầu bằng hai class trong cùng một ứng dụng và chỉ tiến xa hơn khi tải, mô hình dữ liệu hoặc cách vận hành thật sự đòi hỏi.
Nhiều dự án áp dụng CQRS như một gói kiến trúc hoàn chỉnh ngay từ ngày đầu, rồi phải gánh message broker, projection, eventual consistency và công cụ vận hành trước khi có nhu cầu. Một số dự án khác giữ một model CRUD cho mọi việc đến mức màn hình báo cáo kéo theo hàng chục join và logic ghi bị rải trong controller. Cách tiếp cận thực dụng nằm giữa hai thái cực: xác định lực kéo cụ thể, chọn mức tách nhỏ nhất có ích và đo chi phí nhất quán.
1. Tách command và query trước khi tách hạ tầng
Một command nên nói bằng ngôn ngữ nghiệp vụ như ApproveInvoice, ReserveInventory hoặc ChangeDeliveryAddress. Nó kiểm tra quyền, invariant và trạng thái hiện tại trước khi commit. Query như InvoiceDetails hay WarehouseAvailability tối ưu cho dữ liệu mà người gọi cần hiển thị. Query không nên âm thầm sửa last_seen_at, phát email hoặc tạo bản ghi vì khi đó cache, retry và phân quyền trở nên khó dự đoán.
| Khía cạnh | Command | Query |
|---|---|---|
| Mục tiêu | Yêu cầu thay đổi trạng thái | Trả dữ liệu mà không tạo hiệu ứng nghiệp vụ |
| Kết quả | Thành công, lỗi nghiệp vụ hoặc xung đột | DTO/read model phù hợp màn hình hay API |
| Tối ưu | Invariant, transaction, idempotency | Độ trễ, lọc, phân trang, hình dạng dữ liệu |
| Mô hình | Entity và hành vi ghi | Projection hoặc truy vấn chuyên biệt |
Chỉ riêng ranh giới này đã đem lại lợi ích: code review dễ nhận ra thao tác nào ghi dữ liệu, quyền được đặt tại use case, và response không cần tuần tự hóa toàn bộ entity. Chưa cần queue hay database thứ hai. Với đa số ứng dụng nghiệp vụ vừa và nhỏ, đây là mức CQRS đủ dùng trong thời gian dài.
2. Những tín hiệu cho thấy mô hình CRUD đang bị kéo căng
CQRS có giá trị khi nhu cầu đọc và ghi khác nhau rõ rệt. Một trang tổng quan có thể tổng hợp mười bảng và phục vụ hàng nghìn lượt đọc, trong khi luồng ghi chỉ cập nhật vài aggregate với invariant nghiêm ngặt. Một model chung lúc đó buộc entity phục vụ cả quy tắc nghiệp vụ lẫn các shape hiển thị, tạo eager loading phức tạp và dễ làm lộ trường không cần thiết.
- Tỷ lệ đọc cao hơn ghi nhiều bậc và query cần scale độc lập.
- Nhiều màn hình cần các shape dữ liệu khác hẳn cấu trúc ghi chuẩn hóa.
- Logic ghi có transition, quyền và invariant phức tạp hơn CRUD.
- Báo cáo nặng làm cạnh tranh tài nguyên với transaction nghiệp vụ.
- Một thay đổi cần cung cấp dữ liệu cho search, analytics hoặc nhiều consumer.
Một query chậm đơn lẻ chưa phải lý do triển khai CQRS phân tán. Trước tiên hãy kiểm tra index, N+1, phân trang, cache và giới hạn dữ liệu. Nếu một materialized view hoặc bảng tổng hợp được làm mới định kỳ giải quyết được vấn đề, đó có thể là lựa chọn đơn giản và dễ vận hành hơn.
3. Bốn mức áp dụng từ nhẹ đến nặng
- Tách class trong cùng ứng dụng: command handler và query service dùng chung database, transaction và deployment.
- Tách model trong cùng database: phía ghi dùng entity; phía đọc dùng SQL/DTO chuyên biệt, view hoặc bảng projection.
- Tách storage logic: read replica, search engine hoặc read database nhận dữ liệu từ outbox/CDC; ứng dụng chấp nhận độ trễ.
- Tách service và vòng đời deploy: command side và query side vận hành độc lập, đòi hỏi contract, quan sát và quy trình phục hồi riêng.
Hãy chọn mức tách nhỏ nhất giải quyết được bottleneck đã đo. CQRS là một phổ thiết kế, không phải công tắc bật/tắt.
Mỗi bước sang phải làm tăng chi phí. Khi có storage riêng, nhóm phải trả lời projection cập nhật bằng gì, lag bao nhiêu là chấp nhận được, rebuild ra sao và khi nào người dùng cần đọc nhất quán ngay sau ghi. Nếu chưa có câu trả lời vận hành, tách hạ tầng chỉ di chuyển độ phức tạp khỏi code sang production.
4. Thiết kế command quanh invariant và idempotency
Command không phải DTO chứa mọi cột của bảng. Nó biểu diễn ý định với dữ liệu tối thiểu cần thiết. ShipOrder nên mang order ID, phiên bản mong đợi và thông tin vận chuyển hợp lệ; client không được tự gửi status=shipped, tổng tiền hay owner ID để mass assignment. Handler tải aggregate trong phạm vi tenant, authorize, kiểm tra precondition, thay đổi trạng thái và commit một transaction.
handle ShipOrder(command):
begin transaction
order = orders.findForUpdate(command.orderId)
authorize(actor, "ship", order)
require order.version == command.expectedVersion
order.ship(command.carrier, command.trackingCode)
outbox.add(OrderShipped.from(order))
commit
Với command có thể retry từ mobile, gateway hoặc message broker, idempotency key phải có scope theo tác nhân và operation. Lưu key, fingerprint request và kết quả trong cùng transaction với thay đổi nghiệp vụ. Cùng key nhưng payload khác phải bị từ chối; cùng key và payload giống có thể trả kết quả cũ. Idempotency không thay thế optimistic concurrency: một lệnh mới với phiên bản cũ vẫn phải nhận conflict thay vì ghi đè dữ liệu mới hơn.
5. Read model phục vụ use case, không sao chép entity
Read model nên trả đúng shape mà endpoint hoặc giao diện cần: nhãn trạng thái đã dịch, tổng số item, tên khách hàng và quyền hành động. Nó có thể denormalize dữ liệu để giảm join, nhưng mỗi trường dẫn xuất phải có nguồn, cơ chế cập nhật và cách rebuild. Tránh tạo một “read model vạn năng” với hàng trăm cột; điều đó tái tạo model chung mà CQRS muốn tránh.
DTO đọc cần ổn định như một contract. Thay đổi tên trường, loại dữ liệu hoặc semantics phải được version hoặc phối hợp với consumer. Phân trang cần sort ổn định và tie-breaker. Authorization vẫn áp dụng trên phía đọc: projection nhanh không được bỏ qua tenant scope hoặc chính sách ẩn dữ liệu. Khi dùng search engine, hãy xem kết quả search là tập ứng viên và kiểm tra quyền phù hợp trước khi trả dữ liệu nhạy cảm.
6. Đồng bộ projection an toàn bằng outbox
Nếu command database và read store khác nhau, dual write trực tiếp là bẫy. Transaction có thể commit nhưng lần ghi search thất bại, hoặc search thành công rồi database rollback. Transactional outbox ghi trạng thái nghiệp vụ và ý định sự kiện trong cùng transaction. Relay đọc outbox, phát message và đánh dấu tiến độ. Việc phát có thể lặp lại, nên projection consumer phải idempotent.
transaction:
update orders set status = 'shipped', version = version + 1
insert into outbox(event_id, aggregate_id, version, payload)
projector:
if event_id already processed: acknowledge
apply event only when version is expected
record event_id and checkpoint atomically
Thứ tự toàn cục hiếm khi cần và rất đắt. Thông thường chỉ cần giữ thứ tự theo aggregate hoặc partition key. Consumer phải định nghĩa cách xử lý duplicate, event đến muộn, khoảng trống phiên bản và poison message. Dead-letter queue không phải nơi chôn lỗi vĩnh viễn; cần dashboard, cảnh báo, công cụ replay có giới hạn và runbook để quyết định sửa dữ liệu hay sửa code.
7. Eventual consistency phải xuất hiện trong UX và API
Sau khi command trả thành công, read model có thể chưa phản ánh thay đổi. Đây không chỉ là chi tiết kỹ thuật. Nếu giao diện lập tức query projection rồi hiển thị trạng thái cũ, người dùng có thể bấm lại và tạo lệnh trùng. API cần một hợp đồng rõ: trả representation từ write side, trả operation ID để polling, hoặc ghi nhận phiên bản tối thiểu mà query phải chờ.
- Hiển thị trạng thái “đang xử lý” thay vì giả vờ dữ liệu đã đồng bộ.
- Dùng command result để cập nhật tạm giao diện khi an toàn.
- Cho phép query yêu cầu checkpoint tối thiểu với timeout giới hạn.
- Không cache response cũ quá lâu sau một thao tác của chính người dùng.
Không nên hứa “eventual” mà không có SLO. Nhóm cần đo p50/p95/p99 projection lag, tuổi event cũ nhất, số event retry và số partition dừng. Một projection trễ ba giây có thể chấp nhận cho dashboard nhưng không chấp nhận cho màn hình xác nhận thanh toán.
8. Rebuild, schema evolution và xử lý dữ liệu sai
Read model có thể bị lỗi do bug projector hoặc thay đổi schema. Thiết kế production cần khả năng rebuild từ nguồn đáng tin cậy mà không làm gián đoạn toàn bộ truy vấn. Một cách phổ biến là tạo projection phiên bản mới song song, backfill, bắt kịp checkpoint, so sánh metric rồi chuyển alias. Không xóa projection cũ trước khi xác minh phiên bản mới.
Sự kiện lâu dài cần version và quy tắc tương thích. Consumer không nên giả định mọi message đều có trường mới. Upcaster hoặc handler theo phiên bản giúp chuyển payload cũ sang shape hiện hành trong lúc replay. Nếu hệ thống không lưu event đầy đủ, rebuild có thể đọc snapshot từ write database rồi áp dụng thay đổi kể từ một watermark; quy trình này cũng cần chống bỏ sót và ghi trùng.
9. Observability và kiểm thử đường đi đầu cuối
Log cần nối command ID, aggregate ID, event ID, correlation ID và projection checkpoint mà không đưa dữ liệu nhạy cảm vào payload. Dashboard nên theo dõi command latency/error/conflict, outbox backlog, thời gian publish, consumer lag, duplicate, dead letter và thời gian rebuild dự kiến. Alert theo xu hướng hoặc SLO thường hữu ích hơn alert cho từng message lỗi.
Bộ test nên bao phủ cả happy path lẫn điểm đứt:
- Command bị từ chối không thay đổi state và không tạo outbox event.
- Commit thành công nhưng relay dừng vẫn được phát sau khi khởi động lại.
- Consumer nhận duplicate không làm tăng bộ đếm hoặc lặp side effect.
- Event đến sai thứ tự được trì hoãn, phát hiện gap hoặc xử lý theo policy.
- Projection rebuild tạo kết quả tương đương trước khi chuyển traffic.
- Người dùng khác tenant không đọc được dữ liệu qua read model.
Test in-memory hữu ích cho logic nhưng không chứng minh transaction isolation, lock hay delivery của broker thật. Các đường quan trọng cần integration test với database/message system gần production và một failure drill dừng relay hoặc projector giữa chừng.
10. Khi không nên dùng CQRS phân tán
Nếu hệ thống chủ yếu là CRUD, tải vừa phải, một nhóm nhỏ vận hành và yêu cầu đọc sau ghi phải nhất quán tức thì, hai storage cùng broker thường không đáng giá. Tách handler và DTO trong monolith có thể đem lại phần lớn lợi ích mà vẫn giữ transaction đơn giản. Cũng không nên dùng CQRS để che schema kém, thiếu index hoặc ranh giới domain chưa rõ.
Các dấu hiệu nên dừng ở mức nhẹ gồm: không có người trực vận hành projection; không có nguồn để rebuild; đội ngũ chưa thống nhất semantics của event; dữ liệu đọc chỉ khác vài trường; và chưa đo được bottleneck. Kiến trúc có thể tiến hóa sau. Một monolith có ranh giới command/query rõ thường dễ tách hơn một hệ phân tán được dựng sớm nhưng thiếu hợp đồng.
Checklist quyết định
- Đã đo vấn đề đọc/ghi thay vì suy đoán.
- Command, invariant, quyền và idempotency có owner rõ ràng.
- Read model gắn với use case và có tenant scope.
- Độ trễ nhất quán có SLO và UX/API tương ứng.
- Outbox, duplicate, ordering, retry và dead letter có policy.
- Projection có checkpoint, metric, replay và kế hoạch rebuild.
- Nhóm có năng lực vận hành mức tách đã chọn.
Kết luận
CQRS hiệu quả nhất khi được áp dụng theo lực kéo thực tế. Hãy bắt đầu bằng command và query rõ ràng trong cùng ứng dụng, giữ transaction và deployment đơn giản, rồi chỉ tách read model hoặc hạ tầng khi dữ liệu đo được chứng minh nhu cầu. Khi đi đến kiến trúc bất đồng bộ, eventual consistency, outbox, idempotency, rebuild và observability không phải phần phụ; chúng chính là chi phí để đổi lấy khả năng tối ưu đọc và ghi độc lập. Một mức tách vừa đủ, có hợp đồng và có đường phục hồi, tốt hơn một sơ đồ CQRS hoành tráng nhưng không thể vận hành.




Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.