CQRS — tách đường ghi khỏi đường đọc và cái giá nhất quán
CQRS tách mô hình ghi (command) khỏi mô hình đọc (query): dựng nhiều read model tối ưu cho từng truy vấn. Vì sao tách, và cái giá của nhất quán cuối cùng.
TL;DR: CQRS (Command Query Responsibility Segregation) tách mô hình ghi (write model / command) ra khỏi mô hình đọc (read model / query). Thay vì một bảng DB vừa nhận INSERT/UPDATE vừa phục vụ SELECT phức tạp, bạn dựng nhiều materialized view tối ưu riêng cho từng truy vấn — mỗi view cập nhật bất đồng bộ khi có sự kiện mới. Điểm then chốt: read model trễ sau write model (eventual consistency), lag thông thường ~50ms nhưng có thể lên vài giây khi tải cao. Martin Fowler cảnh báo CQRS thêm complexity đáng kể — chỉ xứng đáng khi read/write load thật sự khác nhau và hệ thống đủ lớn để hưởng lợi.
Hệ thống thanh toán của bạn xử lý 50.000 giao dịch mỗi phút vào giờ cao điểm. Bảng transactions vừa phải nhận INSERT liên tục, vừa phải phục vụ truy vấn tổng hợp phức tạp: "số dư tài khoản hiện tại", "lịch sử 30 ngày gần nhất có phân trang", "bảng điều khiển analytic theo khu vực", "báo cáo thuế 12 tháng". Mỗi truy vấn cần index khác nhau, schema khác nhau — và chúng đang block lẫn nhau trên cùng một bảng.
Bài này giải thích CQRS giải quyết bài toán đó bằng cách nào, so sánh rõ với CRUD truyền thống, và buộc bạn lập luận khi nào chấp nhận nhất quán cuối cùng là đáng đổi.
1. Analogy — nhà hàng tách cửa đặt món khỏi bảng trạng thái
Hình dung nhà hàng lớn vào giờ cao điểm.
Mô hình cũ (CRUD): cùng một cửa duy nhất cho cả đặt món, bếp trả món, thu ngân, và thực khách hỏi trạng thái đơn. Khi khách xếp hàng đặt món, đội bưng bê cũng phải chờ ở cùng cửa — hai luồng block lẫn nhau.
Mô hình mới (CQRS): bếp có hai cửa tách biệt: cửa đặt món (command — chỉ nhận lệnh ghi, không trả dữ liệu) và bảng trạng thái điện tử (query — chỉ phục vụ đọc, cập nhật bất đồng bộ từ bếp). Thực khách xem bảng mà không phải hỏi bếp trực tiếp.
| Nhà hàng | CQRS |
|---|---|
| Cửa đặt món (chỉ nhận order) | Command / Write model |
| Bảng hiển thị trạng thái | Read model / materialized view |
| Bảng cập nhật sau khi bếp nấu xong | Eventual consistency |
| Bảng chưa phản ánh order vừa đặt | Read-after-write stale |
| Bảng menu khác bảng trạng thái đơn | Nhiều read model cho nhiều truy vấn |
Command = cửa đặt món (ghi, không trả dữ liệu). Query = bảng trạng thái (đọc, không thay đổi gì). Hai cửa hoàn toàn tách nhau — bếp tự cập nhật bảng bất đồng bộ sau khi nhận order.
2. CQRS là gì — tách command khỏi query
CQRS (Command Query Responsibility Segregation) là nguyên tắc phân tách trách nhiệm: mọi thao tác hệ thống thuộc về đúng một trong hai loại:
- Command (lệnh ghi): thay đổi trạng thái, không trả dữ liệu. Ví dụ:
PlaceOrder,TransferFunds,UpdateProfile. Command chạy qua write model — thường là một aggregate lưu trong DB chuẩn hoá, hoặc event store nếu kết hợp với event sourcing. - Query (truy vấn đọc): trả dữ liệu, không thay đổi trạng thái. Query đọc từ read model — một hoặc nhiều materialized view được xây riêng cho từng trường hợp dùng.
Materialized view là kết quả tính sẵn của một truy vấn phức tạp, lưu trữ dưới dạng bảng hoặc document riêng. Ví dụ: account_balance_view tính sẵn số dư từ transactions; order_history_view denormalize bảng orders cùng products thành một document duy nhất để GET và phân trang nhanh mà không cần JOIN.
Khi write model nhận command và thay đổi state, nó phát ra một domain event (ví dụ MoneyTransferred, OrderPlaced). Read model lắng nghe event đó và cập nhật materialized view bất đồng bộ.
flowchart TB
CLIENT["Client"]
CMD["Command Handler<br/>(Write Model)"]
WDB["Write DB<br/>(normalized)"]
EVT["Domain Event<br/>e.g. MoneyTransferred"]
PROJ["Projection<br/>(event handler)"]
RDB1["Read Model 1<br/>account_balance_view"]
RDB2["Read Model 2<br/>order_history_view"]
QH["Query Handler<br/>(Read Model)"]
CLIENT -->|"PlaceOrder command"| CMD
CMD --> WDB
CMD -->|"publishes"| EVT
EVT --> PROJ
PROJ --> RDB1
PROJ --> RDB2
CLIENT -->|"GET /balance query"| QH
QH --> RDB1Điều quan trọng: read model không đọc trực tiếp từ write DB. Nó chỉ đọc từ materialized view — đã được xây sẵn tối ưu cho truy vấn đó. Khi cần kiểu truy vấn mới (ví dụ thêm trang analytic theo khu vực), bạn thêm một projection mới mà không chạm write model.
3. So sánh CQRS với CRUD truyền thống
Trước khi đọc bảng so sánh bên dưới, thử điền vào chỗ trống: CRUD truyền thống giải quyết truy vấn đọc phức tạp bằng cách nào? Điều gì xảy ra khi read/write load tăng mạnh trên cùng một bảng? Ghi câu trả lời ngắn rồi đối chiếu.
| Tiêu chí | CRUD truyền thống | CQRS |
|---|---|---|
| Mô hình lưu trữ | Một schema phục vụ cả đọc và ghi | Write model riêng + nhiều read model riêng |
| Đọc phức tạp | JOIN nhiều bảng, index phức tạp | Materialized view xây sẵn cho từng truy vấn |
| Nhất quán | Strong consistency — đọc ngay sau ghi thấy giá trị mới | Eventual consistency — read model trễ sau write model |
| Scale đọc | Replica DB, thêm index, cache | Mỗi read model scale độc lập, chọn store phù hợp |
| Scale ghi | Vertical scale, partition | Write model scale độc lập |
| Tối ưu đọc vs ghi | Index tốt cho đọc làm chậm ghi (và ngược lại) | Mỗi phía tối ưu riêng — không trade-off lẫn nhau |
| Độ phức tạp | Thấp — một source of truth, không cần đồng bộ | Cao — projection, eventual consistency, nhiều data store |
| Debug và trace | Đọc bảng gốc là đủ | Phải trace event → projection → view |
| Phù hợp nhất | Hầu hết ứng dụng | Khi read/write load thật sự khác nhau ở quy mô lớn |
CRUD truyền thống giải quyết truy vấn phức tạp bằng index, DB view (không materialized), và replica. Khi write tăng, bảng bị lock; khi read tăng, cần thêm index — nhưng index làm chậm write. Hai workload đối nghịch nhau trên cùng schema.
CQRS cho phép tối ưu mỗi phía độc lập: write model chọn schema chuẩn hoá cho integrity; read model chọn bất kỳ store nào (PostgreSQL, Elasticsearch, Redis) phù hợp nhất cho truy vấn đó. Nhưng đổi lại là độ phức tạp: phải duy trì projection pipeline, xử lý event mất mát, và — quan trọng nhất — chấp nhận eventual consistency.
4. Eventual consistency — cái giá của việc tách
Eventual consistency (nhất quán cuối cùng): sau khi write model chấp nhận command, read model sẽ phản ánh thay đổi đó — nhưng không phải ngay lập tức. Trong khoảng lag đó, read model chứa dữ liệu cũ hơn write model.
Số liệu thực tế từ hệ thống production:
| Tình huống | Lag Write → Read |
|---|---|
| Tải bình thường | ~50ms |
| Cao điểm (flash sale) — trung bình | ~300ms |
| Cao điểm — tail p99 | vượt 2 giây |
| Khi deploy hoặc restart projection service | ~5 giây |
| Khi projection service gặp sự cố | vài phút |
Read-after-write stale là hệ quả trực tiếp: người dùng vừa thực hiện thao tác ghi, ngay sau đó đọc lại — thấy dữ liệu cũ hoặc nhận 404 (entity chưa propagate vào read model).
Ví dụ kinh điển: POST /articles tạo bài viết thành công (201 Created), nhưng GET /articles/123 ngay sau đó trả 404 — vì projection chưa cập nhật articles_view. Với người dùng, cảm giác là "hệ thống mất bài vừa đăng".
Bạn đang xây hệ thống chuyển tiền: POST /transfer xử lý thành công, rồi client gọi ngay GET /balance. Nếu lag giữa write model và read model là 5 giây — client sẽ thấy gì? Hành vi tiếp theo của họ có thể là gì? Ghi ra câu trả lời.
Incident thực tế trong hệ thống payment: lag 5 giây giữa write model và read model → sau POST /transfer thành công, client gọi GET /balance nhận về số dư cũ (chưa trừ tiền) → client cho rằng lệnh chuyển tiền thất bại → gửi lại POST /transfer lần hai → double-submit → chargebacks và kiện toán.
Đáp án cho Tự đoán: client thấy số dư chưa thay đổi, nghĩ lệnh thất bại, gửi lại → double-submit. Đây là lý do hệ thống payment-critical cần SLO nghiêm ngặt: p99 read model dưới 200ms; vượt ngưỡng đó phải xử lý đặc biệt (đọc từ write model hoặc optimistic UI).
Với dashboard hoặc báo cáo phân tích, lag 1 giây trở lên thường chấp nhận được. Với thao tác tài chính, ngưỡng SLO chặt hơn nhiều và cần thiết kế riêng cho read-your-writes — trường hợp người dùng cần xem kết quả của chính hành động họ vừa thực hiện.
5. Khi nào nên dùng CQRS — và khi nào không?
Martin Fowler — người phổ biến CQRS (thuật ngữ do Greg Young đặt) — viết vào năm 2011:
"Like any pattern, CQRS is useful in some places, but not in others. Many systems do fit a CRUD mental model, and so should be done in that style. CQRS is a significant mental leap for all concerned, so shouldn't be tackled unless the benefit is worth the jump."
Và cảnh báo quan trọng hơn:
"Despite these benefits, you should be very cautious about using CQRS. ... I've certainly seen cases where it's made a significant drag on productivity, adding an unwarranted amount of risk to the project, even in the hands of a capable team."
Dựa vào những gì bạn vừa đọc về CQRS vs CRUD và eventual consistency, thử liệt kê 2 dấu hiệu cho thấy hệ thống đủ lớn để CQRS xứng đáng. Ghi ra rồi đối chiếu với bảng dưới.
| Dấu hiệu | Khuyến nghị |
|---|---|
| Read load lớn hơn write rõ rệt (vd đọc 1.000 lần nhiều hơn ghi) | CQRS có thể hợp lý |
| Nhiều loại truy vấn đọc cần schema/index khác nhau | CQRS có thể hợp lý |
| Nhiều client (dashboard, mobile, analytic) cần view khác nhau từ cùng dữ liệu | CQRS có thể hợp lý |
| Hệ thống nhỏ/vừa, team nhỏ, nghiệp vụ chưa phức tạp | Giữ CRUD — CQRS thêm overhead không tương xứng |
| Read và write load tương đương | Giữ CRUD + replica đọc |
| Cần strong consistency cho mọi thao tác | Không dùng CQRS thuần |
Đáp án: (1) read load lớn hơn write rõ rệt, hoặc (2) nhiều loại truy vấn đọc cần schema/index riêng không thể chia sẻ. Nếu không có dấu hiệu nào trong số đó — CRUD thường là lựa chọn đúng.
CQRS không phải pattern mặc định. Với hầu hết ứng dụng CRUD thông thường, thêm CQRS là thêm complexity (projection, eventual consistency, nhiều data store) mà không có lợi tương xứng. Fowler khuyến cáo chỉ dùng CQRS cho bounded context mà read/write load thật sự khác nhau — không áp dụng toàn hệ thống.
6. CQRS có cần event sourcing không?
Không nhất thiết. Hai pattern độc lập nhau, dù hay xuất hiện cùng nhau:
- CQRS không event sourcing: write model ghi trạng thái cuối vào DB chuẩn hoá (row ghi đè), đồng thời publish event để projection cập nhật read model. Không có lịch sử sự kiện đầy đủ — chỉ có state hiện tại và các materialized view.
- CQRS + event sourcing: write model là event store (lưu chuỗi event bất biến như đã học ở bài trước). Projection replay event để dựng view. Kết hợp này cho phép thêm read model mới bất cứ lúc nào bằng cách replay toàn bộ event lịch sử — nhưng cộng thêm complexity của event sourcing.
Bài 02 (event sourcing) đã đi sâu vào cơ chế lưu event và replay. Bài này tập trung vào phần CQRS: tại sao tách read/write và hệ quả eventual consistency — hai câu hỏi đứng độc lập với event sourcing.
7. Pitfall tổng hợp
❌ Nhầm 1: Đọc read model ngay sau write — tưởng thấy dữ liệu mới
# Sequence khong an toan voi CQRS:
POST /transfer -> 200 OK (write model chap nhan)
GET /balance -> 500000 (read model chua cap nhat -- so du cu)
# Client nhin thay so du cu -> nghi chuyen tien that bai -> gui lai
# -> double-submit
✅ Với thao tác read-your-writes-critical: đọc từ write model trực tiếp, không đọc từ read model. Hoặc UI optimistic update (hiển thị kết quả ngay mà không chờ đọc lại từ server) cộng với version check để không phụ thuộc vào lag.
❌ Nhầm 2: Áp CQRS cho toàn hệ thống
Áp CQRS cho mọi aggregate, mọi endpoint — kể cả những chỗ chỉ có CRUD đơn giản — làm toàn hệ thống phức tạp mà không có lợi. Đây là lỗi phổ biến nhất mà Fowler ghi nhận.
✅ Chỉ dùng CQRS cho bounded context mà read/write load thật sự chênh lệch. Phần còn lại của hệ thống giữ CRUD thông thường.
❌ Nhầm 3: Tưởng CQRS cải thiện consistency — thực ra nó chủ động đánh đổi
CQRS không làm consistency tốt hơn. Nó đánh đổi strong consistency lấy khả năng scale read/write độc lập. Nếu ứng dụng cần strong consistency (giao dịch tài chính chặt, inventory chính xác), phải thiết kế đặc biệt cho read-your-writes hoặc không dùng CQRS thuần.
8. 📚 Deep Dive
Spec / reference chính thức:
- CQRS — Martin Fowler (2011) — bài viết định nghĩa và phổ biến CQRS (thuật ngữ do Greg Young đặt). Đọc toàn bài để hiểu lập luận gốc và cảnh báo cụ thể của Fowler về over-engineering với CQRS.
- Pattern: CQRS — microservices.io (Chris Richardson) — mô tả CQRS trong bối cảnh microservices: view database riêng (thường NoSQL), đồng bộ qua domain events; kèm liên kết với các pattern liên quan (event sourcing, saga, API gateway).
Ghi chú: đọc Fowler 2011 trước để có nhận thức đúng về khi nào không nên dùng, rồi đọc microservices.io cho implementation pattern trong hệ thống phân tán. Fowler viết từ góc nhìn thận trọng; Richardson mô tả cách làm khi đã quyết định dùng.
9. Liên hệ các bài khác
- Bài 02 — Event sourcing: event sourcing lưu chuỗi event bất biến thay vì state — thường kết hợp với CQRS để projection dựng read model bằng replay. Bài 02 giải thích write model; bài này giải thích tại sao cần tách read model.
- Bài 01 — CDC: CDC là một cơ chế cập nhật read model — thay vì viết projection code lắng nghe domain event, có thể dùng CDC đọc changelog của write DB và đẩy vào read model bất đồng bộ.
- Bài 04 — Lambda và Kappa: lambda/kappa là kiến trúc kết hợp batch và stream để xây derived data ở quy mô lớn — read model của CQRS là một dạng derived data đặc biệt; bài 04 mở rộng tư duy này lên toàn hệ thống.
10. Tóm tắt
- CQRS tách write model (nhận command, thay đổi state) ra khỏi read model (phục vụ query từ materialized view).
- Read model là materialized view bất đồng bộ: cập nhật sau khi write model phát domain event; lag thông thường ~50ms, tail vượt 2 giây khi tải cao, vài phút khi có sự cố.
- Eventual consistency là hệ quả tất yếu — không phải sự cố mà là đặc điểm thiết kế có chủ đích. Read-after-write có thể trả dữ liệu cũ hoặc 404.
- Với thao tác read-your-writes-critical (xem lại số dư ngay sau chuyển tiền): đọc từ write model trực tiếp, hoặc dùng optimistic UI update.
- Fowler (2011): CQRS thêm complexity đáng kể — chỉ dùng khi read/write load thật sự khác nhau; không áp toàn hệ thống.
- CQRS và event sourcing độc lập nhau — CQRS không đòi hỏi event sourcing, nhưng kết hợp tốt khi cần replay event để dựng read model mới.
11. Tự kiểm tra
Q1CQRS tách gì khỏi gì? Định nghĩa ngắn gọn command và query trong CQRS, và cho một ví dụ cụ thể cho mỗi loại.▸
CQRS tách write model (xử lý command) ra khỏi read model (phục vụ query). Command là thao tác thay đổi trạng thái, không trả dữ liệu — ví dụ PlaceOrder, TransferFunds. Query là thao tác đọc dữ liệu, không thay đổi gì — ví dụ GET /balance, GET /orders?page=1.
Điểm then chốt: command đi vào write DB (chuẩn hoá cho integrity), query đọc từ materialized view (tối ưu cho truy vấn cụ thể). Không có shared schema giữa hai phía — đây là điểm khác biệt căn bản so với CRUD truyền thống.
Q2Materialized view trong CQRS khác gì so với DB view thông thường (CREATE VIEW trong SQL)?▸
DB view thông thường (CREATE VIEW) là tên gọi của một câu SELECT — khi query view, DB vẫn chạy SELECT đó ở runtime. Nếu SELECT chậm, query view cũng chậm; không lưu kết quả vật lý.
Materialized view trong CQRS là kết quả đã tính toán và lưu vật lý trong một store riêng (có thể là PostgreSQL table, Elasticsearch index, Redis hash). Query chỉ đọc dữ liệu đã xây sẵn — cực nhanh. Đổi lại, cần projection service chạy nền cập nhật view mỗi khi có event mới — đây là nguồn gốc của eventual consistency và là lý do CQRS thêm complexity.
Q3Vì sao đọc read model ngay sau write có thể trả về dữ liệu cũ hoặc 404? Giải thích cơ chế, kèm ví dụ incident double-submit trong hệ thống payment.▸
Write model chấp nhận command và phát domain event (ví dụ MoneyTransferred). Projection service lắng nghe event đó, tính toán, rồi cập nhật read model. Bước này không đồng bộ — có độ trễ từ ~50ms đến vài giây.
Trong khoảng lag đó, read model chứa dữ liệu trước khi command thực thi. Nếu entity vừa được tạo, nó chưa có trong read model → 404. Nếu entity vừa được cập nhật (số dư giảm), read model vẫn hiện số dư cũ.
Incident payment với lag 5 giây: POST /transfer thành công, nhưng GET /balance ngay sau trả về số dư cũ (chưa trừ tiền) → client nghĩ lệnh thất bại → gửi lại lần hai → double-submit → chargebacks. SLO thực tế cho payment-critical: p99 read model dưới 200ms.
Q4So sánh CQRS với CRUD truyền thống: nêu một điểm mạnh và một điểm yếu rõ ràng của mỗi mô hình. Khi nào nên chọn CQRS thay CRUD?▸
- CRUD truyền thống: điểm mạnh — strong consistency, một source of truth, dễ debug; điểm yếu — index tốt cho đọc làm chậm ghi và ngược lại, khó scale read/write độc lập khi load chênh lệch.
- CQRS: điểm mạnh — read/write scale độc lập, mỗi read model tối ưu riêng cho truy vấn của nó; điểm yếu — eventual consistency, phải duy trì projection, nhiều data store, debug khó hơn.
Chọn CQRS khi: (1) read load lớn hơn write rõ rệt, (2) nhiều loại truy vấn đọc cần schema/index khác nhau, (3) hệ thống đủ lớn để lợi ích vượt chi phí complexity. Giữ CRUD khi hệ thống nhỏ/vừa hoặc read/write load tương đương — Fowler (2011) nhấn mạnh đây là lỗi phổ biến nhất khi áp dụng CQRS không đúng chỗ.
Q5Hệ thống dùng CQRS cho trang hồ sơ người dùng. Người dùng vừa đổi ảnh đại diện, reload trang ngay và vẫn thấy ảnh cũ. Đây là lỗi hay hành vi bình thường? Cách xử lý phù hợp nhất là gì?▸
Đây là hành vi bình thường của CQRS với eventual consistency — không phải lỗi trong cơ chế projection. Read model chưa kịp cập nhật ảnh mới trong khoảng lag.
Cách xử lý: (1) Optimistic UI update — client tự hiển thị ảnh mới ngay sau upload thành công mà không chờ đọc lại từ server (phổ biến nhất, UX tốt nhất); (2) đọc từ write model cho trường hợp người dùng xem hồ sơ của chính mình (read-your-writes) — không qua projection nên không bị lag; (3) thêm version hoặc updatedAt vào read model, client poll cho đến khi version mới xuất hiện — phức tạp hơn, dùng khi critical và không thể optimistic.
Q6CQRS và event sourcing có bắt buộc đi cùng nhau không? Mô tả ngắn gọn CQRS không có event sourcing, và event sourcing không có CQRS.▸
Hai pattern hoàn toàn độc lập — không bắt buộc kết hợp.
- CQRS không event sourcing: write model ghi trạng thái cuối vào DB chuẩn hoá (row ghi đè), đồng thời publish event để projection cập nhật read model. Không có lịch sử sự kiện đầy đủ — chỉ có state hiện tại và các materialized view.
- Event sourcing không CQRS: toàn bộ thao tác đọc lẫn ghi dùng chung event store. Query rebuild aggregate từ event khi cần — không tách write model/read model riêng. Phù hợp khi cần audit trail đầy đủ nhưng chưa có vấn đề scale read/write.
Hai pattern hay kết hợp vì event sourcing cung cấp event stream tự nhiên cho projection của CQRS; CQRS giải quyết bài toán query chậm khi replay nhiều event. Nhưng kết hợp cũng cộng gộp complexity của cả hai — chỉ nên làm khi thật sự cần cả hai.
Q7Martin Fowler khuyên rất thận trọng với CQRS vì nó có thể làm giảm năng suất đáng kể. Nêu hai dấu hiệu cụ thể cho thấy hệ thống không cần CQRS, và giải thích vì sao thêm CQRS vào sẽ làm hại.▸
- Dấu hiệu 1: hệ thống CRUD đơn giản — read và write load tương đương, truy vấn đọc không phức tạp, không cần nhiều view khác nhau. Thêm CQRS tức là thêm projection service, nhiều data store, eventual consistency — toàn bộ chi phí đó không mang lợi ích vì bài toán gốc chỉ cần CRUD đơn giản.
- Dấu hiệu 2: team nhỏ, nghiệp vụ chưa ổn định (domain model thay đổi thường xuyên). Mỗi khi thay đổi write model, phải cập nhật cả projection và read model — nhân đôi công việc. Với CRUD, chỉ cần sửa schema một chỗ.
Fowler nhấn mạnh: complexity của CQRS (duy trì projection, xử lý lag, debug multi-store) là chi phí thực sự phải trả hàng ngày. Nếu benefit (scale read/write độc lập, nhiều view tối ưu) không rõ ràng và đo được, chi phí đó làm hại nhiều hơn giúp.
Bài tiếp theo: Lambda và Kappa — kết hợp batch và stream
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên