Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn đang là kiến trúc sư cho một sàn thương mại điện tử kiểu Tiki hay Shopee. Vào ngày sale 12/12, có hai loại người dùng cùng lúc "đụng" vào hệ thống của bạn. Nhóm thứ nhất: hàng chục nghìn khách đang lướt, xem sản phẩm, đọc đánh giá, so sánh giá — họ chỉ đọc dữ liệu. Nhóm thứ hai: một số ít hơn nhưng cực kỳ quan trọng, đang bấm "Đặt hàng", trừ kho, tạo đơn — họ đang ghi dữ liệu.
Trong kiến trúc CRUD truyền thống, cả hai nhóm này dùng chung một model, chung một bảng dữ liệu, chung một cấu trúc. Và đó chính là chỗ đau: hành vi đọc và hành vi ghi có đặc tính hoàn toàn khác nhau, nhưng chúng ta lại ép chúng vào một khuôn. Kết quả là bạn phải hy sinh: hoặc tối ưu cho đọc thì ghi bị chậm, hoặc tối ưu cho ghi thì truy vấn phức tạp lại lê lết.
CQRS — Command Query Responsibility Segregation — là câu trả lời cho bài toán này. Đây là một trong những pattern bị hiểu lầm nhiều nhất trong ngành: có người thần thánh hóa nó, có người sợ nó, và rất nhiều người áp dụng sai chỗ. Là một người ra quyết định kiến trúc, bạn không cần "biết CQRS là gì" — bạn cần biết khi nào nó xứng đáng với cái giá phải trả, và khi nào nó chỉ là over-engineering. Đó chính là mục tiêu của bài học này.
Khái niệm cốt lõi
CRUD truyền thống — một model cho tất cả
Trong mô hình quen thuộc mà hầu hết chúng ta lớn lên cùng, một entity như Order có một model duy nhất. Bạn dùng chính cái model đó để tạo đơn (Create), đọc đơn (Read), cập nhật (Update) và xóa (Delete). Cùng một class, cùng một bảng, cùng một repository. Đơn giản, dễ hiểu, và đúng đắn cho phần lớn ứng dụng.
Vấn đề chỉ xuất hiện khi hệ thống phức tạp lên. Model dùng để ghi cần tuân thủ các quy tắc nghiệp vụ nghiêm ngặt (không được đặt hàng khi hết kho, không được hủy đơn đã giao). Trong khi đó, model dùng để đọc lại muốn dữ liệu đã được "làm phẳng" sẵn: tên khách hàng, tên sản phẩm, tổng tiền, trạng thái giao hàng — tất cả trong một cú query, không cần join 7 bảng.
CQRS — tách đôi trách nhiệm
CQRS nói một câu rất đơn giản: hãy tách model dùng để thay đổi dữ liệu (write model) ra khỏi model dùng để đọc dữ liệu (read model).
- Command (lệnh): là ý định thay đổi trạng thái hệ thống. Ví dụ
PlaceOrderCommand,CancelOrderCommand. Command đi qua write model, thực thi validation nghiệp vụ, và tạo ra thay đổi. Command không trả về dữ liệu (hoặc chỉ trả về tối thiểu như ID hoặc mã kết quả). - Query (truy vấn): là yêu cầu đọc dữ liệu, không được thay đổi bất cứ điều gì. Ví dụ
GetOrderDetailsQuery. Query đi qua read model, được tối ưu cho việc hiển thị.
Ba cấp độ của CQRS
Đây là phần mà nhiều người bỏ sót, dẫn đến hiểu CQRS quá cực đoan. CQRS không phải "có hoặc không" — nó là một dải phổ:
- Tách ở tầng code (cùng một database): Bạn có
OrderWriteModelvàOrderReadModellà hai class khác nhau, hai luồng code khác nhau, nhưng đọc/ghi vẫn từ cùng một bảng. Đây là mức nhẹ nhất, ít rủi ro nhất, và thường là điểm khởi đầu hợp lý.
- Tách ở tầng schema (cùng một database, khác bảng/view): Write vào bảng chuẩn hóa (normalized), read từ các bảng/materialized view đã được phi chuẩn hóa (denormalized) để truy vấn nhanh. Vẫn một database nên vẫn nhất quán tức thời.
- Tách ở tầng database (hai kho dữ liệu riêng): Write model dùng PostgreSQL đảm bảo tính toàn vẹn giao dịch, read model dùng Elasticsearch cho tìm kiếm hoặc một read-replica cho truy vấn. Dữ liệu được đồng bộ từ write sang read qua sự kiện. Đây là mức mạnh nhất — và nó đưa vào bài toán eventual consistency (nhất quán cuối cùng): read model có thể "trễ" so với write model vài mili-giây đến vài giây.
Vì sao lại tách? — Read và write scale khác nhau
Đây là lý do cốt lõi. Trong đại đa số hệ thống, tỷ lệ đọc/ghi lệch nhau khủng khiếp — thường 10:1, 100:1, thậm chí 1000:1. Một bài báo trên VnExpress được viết một lần nhưng đọc hàng triệu lần. Một sản phẩm trên sàn được cập nhật giá vài lần một ngày nhưng được xem hàng chục nghìn lần.
Khi đọc và ghi chung một model, bạn không thể scale chúng độc lập. Nhưng khi đã tách:
- Scale độc lập: Bạn có thể đặt 10 read-replica cho read model để phục vụ lượng đọc khổng lồ, trong khi write model chỉ cần một node master.
- Tối ưu store phù hợp: Write cần ACID → dùng SQL. Read cần full-text search → dùng Elasticsearch. Read cần đồ thị quan hệ → dùng graph DB. Mỗi bên chọn công cụ tốt nhất.
- Model gọn hơn: Write model chỉ chứa logic nghiệp vụ, không bị "phình" bởi các trường phục vụ hiển thị. Read model là DTO phẳng, không có logic, dễ cache.
- Bảo mật rõ ràng: Phân quyền ghi và phân quyền đọc tách bạch, dễ audit.
Tình huống thực tế
Tình huống 1 — Sàn thương mại điện tử Việt: trang chi tiết sản phẩm chậm chết
Một công ty thương mại điện tử tầm trung ở TP.HCM (gọi là "ShopViet") có trang chi tiết sản phẩm ngày càng ì ạch. Nguyên nhân: mỗi lần render trang, backend phải join bảng products, variants, inventory, reviews, sellers, promotions, shipping_zones — bảy bảng, với logic tính giá khuyến mãi phức tạp. Vào giờ cao điểm, p95 latency lên tới 1,8 giây.
Đội kỹ thuật áp dụng CQRS ở cấp độ 3. Write model vẫn giữ nguyên: khi seller cập nhật sản phẩm hay khi có đơn hàng trừ kho, dữ liệu ghi vào PostgreSQL chuẩn hóa như cũ. Nhưng mỗi thay đổi phát ra một sự kiện (ví dụ ProductUpdated, InventoryChanged), và một consumer lắng nghe các sự kiện đó để dựng sẵn một document phẳng cho mỗi sản phẩm — gồm đầy đủ giá đã tính, tồn kho, điểm đánh giá trung bình — rồi lưu vào Elasticsearch. Trang chi tiết giờ chỉ đọc một document duy nhất từ Elasticsearch.
Kết quả: p95 latency của trang giảm còn 90ms. Đổi lại, họ chấp nhận eventual consistency: khi seller đổi giá, khách có thể thấy giá cũ trong khoảng 1-2 giây. Với bài toán này, đó là sự đánh đổi hoàn toàn chấp nhận được — không ai kiện vì thấy giá cũ trong một giây.
Bài học: CQRS cấp độ 3 tỏa sáng khi truy vấn đọc phức tạp và lượng đọc áp đảo lượng ghi. Chìa khóa là xác định được domain nào chịu được độ trễ của read model.
Tình huống 2 — Fintech và cái bẫy nhất quán tức thời
Một startup fintech ở Singapore làm ví điện tử. Nghe nói CQRS "xịn", một kỹ sư mới áp dụng cấp độ 3 cho toàn bộ hệ thống, bao gồm cả số dư ví. Write vào PostgreSQL, read từ một bảng số dư đồng bộ qua sự kiện, có độ trễ ~500ms.
Thảm họa xảy ra: người dùng nạp 500.000đ, thấy màn hình vẫn hiển thị số dư cũ (do read model chưa cập nhật), tưởng nạp lỗi nên nạp lại. Tệ hơn, ở khâu rút tiền, có người khai thác được cửa sổ 500ms để rút vượt số dư thực — vì kiểm tra số dư đọc từ read model bị trễ.
Đội phải khẩn cấp sửa: với số dư và các thao tác tài chính nhạy cảm, họ đọc trực tiếp từ write model (nhất quán mạnh), chỉ dùng CQRS cấp độ 3 với read model eventual consistency cho lịch sử giao dịch — thứ mà trễ vài trăm ms không gây hại gì.
Bài học: Không phải mọi phần của hệ thống đều chịu được eventual consistency. Với dữ liệu mà "sai một khoảnh khắc" gây hậu quả tài chính hoặc pháp lý (số dư, tồn kho vé máy bay, số ghế còn lại), hãy đọc từ write model hoặc dùng CQRS cấp độ 1-2. CQRS áp dụng theo từng bounded context, không phải cả hệ thống.
Tình huống 3 — Đội nhỏ, CRUD là đủ
Một team 4 người xây dựng phần mềm quản lý phòng khám nha khoa. Họ đọc blog về CQRS và định tách toàn bộ. May mắn là tech lead can lại: hệ thống có vài trăm lượt truy cập mỗi ngày, truy vấn đơn giản, đọc/ghi cân bằng, không có nút thắt hiệu năng nào.
Họ giữ CRUD. Sáu tháng sau, khi một màn hình báo cáo tổng hợp bắt đầu chậm vì query phức tạp, họ chỉ áp dụng CQRS cấp độ 1 đúng cho phần báo cáo đó: tạo một read model riêng đọc từ vài view đã tối ưu, còn phần nhập liệu vẫn CRUD.
Bài học: CQRS không phải là mặc định. Chi phí lớn nhất của nó là chi phí con người — code nhiều hơn, tư duy phức tạp hơn, người mới khó vào hơn. Với đội nhỏ và hệ thống đơn giản, CRUD gần như luôn thắng. Và khi cần, hãy áp dụng CQRS cục bộ cho đúng phần đau, không phải toàn hệ thống.
Hướng dẫn từng bước
Khi bạn đứng trước quyết định "có nên dùng CQRS ở đây không", đây là quy trình tôi khuyên bạn đi qua:
- Đo lường trước, đừng đoán. Xác định thật sự có nút thắt hiệu năng ở đọc hay ghi không. Nhìn vào p95/p99 latency, tỷ lệ read/write, số bảng phải join. Nếu chưa có vấn đề thật, dừng lại — bạn chưa cần CQRS.
- Khoanh vùng bounded context. CQRS áp dụng cho một context cụ thể (ví dụ: "Catalog", "Order History"), không phải cả hệ thống. Chọn đúng context đang đau.
- Chọn cấp độ thấp nhất giải quyết được vấn đề. Bắt đầu từ cấp độ 1 (tách code). Nếu chưa đủ, lên cấp độ 2 (tách schema/view). Chỉ lên cấp độ 3 (tách database + sự kiện) khi thực sự cần scale đọc độc lập hoặc cần store chuyên biệt.
- Thiết kế write model quanh nghiệp vụ. Command thể hiện ý định (
CancelOrderchứ không phảiUpdateOrderStatus). Đặt toàn bộ validation và bất biến nghiệp vụ ở đây.
- Thiết kế read model quanh màn hình. Read model là DTO phẳng, dựng riêng cho từng nhu cầu hiển thị. Đừng ngại có nhiều read model cho cùng một dữ liệu.
- Nếu chọn cấp độ 3, thiết kế cơ chế đồng bộ. Thường qua sự kiện: write phát sự kiện, projector cập nhật read model. Quyết định rõ độ trễ chấp nhận được và cách xử lý khi projector chết (replay được không?).
- Quyết định ranh giới consistency một cách tường minh. Ghi rõ trong ADR: phần nào đọc từ write model (strong), phần nào chịu được eventual consistency. Đây là quyết định kiến trúc quan trọng nhất.
- Xử lý trải nghiệm người dùng cho eventual consistency. Ví dụ: sau khi user tạo đơn, hiển thị ngay dữ liệu từ chính command (optimistic UI) thay vì đọc lại read model chưa kịp cập nhật.
Lỗi thường gặp & mẹo
Lỗi 1 — Áp dụng CQRS cho cả hệ thống. CQRS là dao mổ, không phải búa tạ. Áp cho toàn bộ ứng dụng khiến những CRUD đơn giản trở nên phức tạp vô lý. Mẹo: mặc định là CRUD, chỉ dùng CQRS cho các context thực sự đau.
Lỗi 2 — Nhầm CQRS với Event Sourcing. Đây là hai pattern độc lập. CQRS chỉ nói về tách read/write. Event Sourcing (Bài 34) là về việc lưu trạng thái dưới dạng chuỗi sự kiện. Chúng thường đi cùng nhau nhưng không bắt buộc. Bạn hoàn toàn có thể làm CQRS mà không cần Event Sourcing.
Lỗi 3 — Dùng eventual consistency ở nơi cần strong consistency. Như tình huống fintech: số dư, tồn kho giới hạn, số ghế — những thứ này thường cần đọc nhất quán. Mẹo: liệt kê các truy vấn "phải luôn đúng ngay lập tức" và cho chúng đọc thẳng write model.
Lỗi 4 — Quên xử lý khi read model lệch. Ở cấp độ 3, projector có thể chết, sự kiện có thể mất. Nếu không có cơ chế phát hiện lệch và dựng lại (rebuild) read model, dữ liệu sẽ sai âm thầm. Mẹo: đảm bảo projection có thể replay lại từ đầu để dựng lại read model bất cứ lúc nào.
Lỗi 5 — Command trả về quá nhiều dữ liệu. Command nên trả về tối thiểu (ID, mã kết quả). Nếu bạn thấy command trả về cả object đầy đủ để "tiện", bạn đang phá vỡ ranh giới CQS.
Mẹo hay: Đặt tên Command theo ngôn ngữ nghiệp vụ, ở thể mệnh lệnh — ApproveLoan, ShipOrder, SuspendAccount. Điều này khiến code phản ánh đúng ý định người dùng và dễ đối chiếu với nghiệp vụ thực tế.
Bài tập thực hành
Bài 1 — Phân loại. Cho hệ thống đặt vé xem phim (kiểu CGV/Galaxy). Hãy liệt kê 5 thao tác, phân loại từng cái là Command hay Query, và với mỗi Query, ghi rõ nó chịu được eventual consistency hay cần strong consistency. (Gợi ý: "xem danh sách phim đang chiếu" khác hẳn "kiểm tra ghế còn trống khi thanh toán".)
Bài 2 — Chọn cấp độ. Với ba tình huống sau, hãy chọn cấp độ CQRS phù hợp (1, 2, 3 hay CRUD thuần) và giải thích trong 2-3 câu: (a) Blog nội bộ công ty, 50 nhân viên đọc/viết. (b) Trang tìm kiếm bất động sản với hàng triệu lượt tìm mỗi ngày, filter phức tạp theo giá/vị trí/diện tích. (c) Màn hình dashboard báo cáo doanh thu tổng hợp trong một ERP vốn đang chạy CRUD ổn định.
Bài 3 — Thiết kế ranh giới. Cho một ví điện tử, hãy vẽ ranh giới: phần nào của hệ thống bạn cho đọc từ write model (strong), phần nào cho phép read model eventual consistency. Viết một đoạn ngắn (giống một mục trong ADR) biện minh cho quyết định của bạn.
Bài 4 — Nhận diện over-engineering. Đọc lại một dự án bạn từng làm hoặc đang làm. Có chỗ nào đã (hoặc suýt) dùng CQRS mà thực ra CRUD là đủ không? Ngược lại, có chỗ nào CRUD đang gồng mình và đáng ra nên tách read/write không?
Tóm tắt
- CQRS = tách read model khỏi write model. Command thay đổi trạng thái (có side effect, không trả dữ liệu); Query đọc dữ liệu (không side effect). Khác với CRUD dùng chung một model cho cả hai.
- Lý do cốt lõi để tách: đọc và ghi có đặc tính và tỷ lệ tải khác nhau (thường lệch rất mạnh), nên tách ra để scale độc lập, tối ưu store phù hợp, và giữ model gọn gàng.
- CQRS là một dải phổ, không phải bật/tắt: cấp độ 1 (tách code), cấp độ 2 (tách schema/view), cấp độ 3 (tách database + đồng bộ qua sự kiện). Chọn cấp độ thấp nhất giải quyết được vấn đề.
- Cái giá phải trả: phức tạp hơn, nhiều code hơn, và ở cấp độ 3 là eventual consistency. Không áp dụng cho dữ liệu cần nhất quán tức thời như số dư, tồn kho giới hạn.
- Nguyên tắc vàng của người ra quyết định: mặc định CRUD, áp dụng CQRS cục bộ theo từng bounded context đang thực sự đau, sau khi đã đo lường — chứ không phải vì nó "nghe xịn". CQRS và Event Sourcing là hai chuyện khác nhau, đừng gộp làm một.