Pattern

CQRS là gì?

Trong kiến trúc hệ thống SaaS, việc tối ưu hiệu suất và khả năng mở rộng của hệ thống là một yêu cầu quan trọng. Một trong những cách tiếp cận đang dần được áp dụng phổ biến là CQRS – viết tắt của Command and Query Responsibility Segregation.

Nói đơn giản, CQRS là mô hình tách biệt luồng ghi dữ liệu (command)đọc dữ liệu (query), thay vì xử lý cả hai trong cùng một hệ thống hoặc model. Dưới đây là hình mô tả mà các bạn có thể tìm thấy dễ dàng khắp nơi trên mạng:

  • Command – Ghi dữ liệu, tạo mới, cập nhật, xoá. Không trả về dữ liệu, chỉ xác nhận hành động.
  • Query – Đọc dữ liệu, không thay đổi trạng thái hệ thống. Có thể trả về kết quả phong phú, phức tạp.

Nói cách khác:

  • Mọi Command chỉ thay đổi trạng thái hệ thống
  • Mọi Query chỉ truy vấn trạng thái hệ thống

Tách biệt hai loại này giúp hệ thống dễ mở rộng, dễ bảo trì và linh hoạt hơn trong việc tối ưu hóa từng phần riêng biệt. Đối với một hệ thống thông thường, số lượng query thường sẽ rất nhiều so với update hệ thống. Ví dụ listing, view, report,… được sử dụng thường xuyên, còn add, edit, process,… chỉ khi cần mới làm. Bằng cách tách thành 2 luồng, ta có thể scale luồng query lên nhiều lần và chạy độc lập để không ảnh hưởng đến performance của write store, khi mà lượng query quá nhiều có thể lock các table làm chậm việc update. Ngoài ra còn có thể tránh được deadlock, trong một số tình huống chúng ta có thể vô tình bị. Ví dụ query data và write audit data (đếm số lượt view, số lượt click, last login time,…), vốn dĩ là 2 hành động không liên quan nhưng thực hiện trong 1 request, dev sử dụng ORM hoặc framework hỗ trợ sẵn không để ý, và chúng bị gộp trong 1 transaction thay vì gọi một cách độc lập.

Eventual consistency – Đặc tính quan trọng của CQRS

Eventual consistency là thuật ngữ nói lên rằng dữ liệu giữa các thành phần của hệ thống sẽ trở nên nhất quán trong tương lai gần (sau một khoảng thời gian nhất định), nhưng không yêu cầu phải nhất quán ngay lập tức sau mỗi thay đổi. Điều này khác với strong consistency, nơi mọi thành phần luôn thấy dữ liệu giống nhau ngay sau khi có thay đổi – ví dụ như trong các hệ thống giao dịch ngân hàng hoặc cơ sở dữ liệu quan hệ truyền thống.

Trong mô hình CQRS, dữ liệu được tách ra thành hai tuyến xử lý là command side – ghi dữ liệu vào write store, và query side – đọc dữ liệu từ read store. Quá trình đồng bộ sẽ mất một khoảng thời gian ngắn, dẫn đến việc read store có thể chưa kịp cập nhật, tạo ra một khoảng trống giữa hai mặt xử lý. Điều này dẫn đến trạng thái tạm thời không nhất quán – chính là eventual consistency.

Eventual consistency là một đặc tính phổ biến – và gần như tất yếu – khi áp dụng CQRS, đặc biệt khi read và write được tách thành hai hệ thống xử lý riêng biệt. CAP Theorem là một khái niệm quan trọng trong lý thuyết hệ thống phân tán, giúp cho các nhà phát triển và kiến trúc sư hiểu rõ hơn về sự đánh đổi giữa ba yêu cầu cơ bản: Tính nhất quán (Consistency), Tính sẵn sàng (Availability), và Khả năng chịu lỗi mạng phân tán (Partition Tolerance). CAP Theorem chỉ ra rằng trong một hệ thống phân tán, bạn không thể đạt được cả ba đặc tính này đồng thời. Điều này có nghĩa là khi hệ thống phải đối mặt với sự phân tách mạng (kết nối mạng giữa các thành phần trong hệ thống bị lỗi), bạn sẽ phải đưa ra quyết định giữa việc duy trì sự nhất quán của dữ liệu hoặc đảm bảo khả năng sẵn sàng của dịch vụ. CQRS, khi được triển khai với cơ chế đồng bộ, vốn để tăng khả năng scale read để không ảnh hưởng đến write, nên phương án ưu tiên Availabilitychấp nhận Eventual Consistency như một đánh đổi hợp lý.

Hiểu rõ rằng Eventual Consistency là không thể tránh khỏi, để chúng ta có chiến lược thiết kế UI/UX phù hợp với bản chất và yêu cầu về hiệu năng của hệ thống. Ví dụ:

Khi người dùng trên client thực hiện thao tác thêm một bản ghi mới, và nhận được xác nhận thành công từ phía command (ghi dữ liệu), hệ thống có thể chèn bản ghi đó vào danh sách hiển thị với trạng thái “đang xử lý” (pending), thay vì chờ read model cập nhật. Điều này giúp người dùng không bị “bối rối” khi không thấy dữ liệu mình vừa tạo. Trong khi đó, ở background, client có thể tự động query lại bản ghi mới, và khi dữ liệu từ read store đã được đồng bộ, sẽ tự động thay thế bản ghi pending bằng bản ghi thực tế. Các người dùng khác không nhất thiết phải thấy bản ghi đó ngay lập tức – điều này hoàn toàn phù hợp với mô hình eventual consistency.

Ngược lại, trong một số tình huống nghiệp vụ, eventual consistency là không đủ an toàn, đặc biệt khi nhiều người dùng hoặc tiến trình có thể thao tác đồng thời lên cùng một dữ liệu – ví dụ: phê duyệt đơn hàng, giữ chỗ, hoặc xác nhận giao dịch tài chính.

Để đảm bảo strong consistency trong môi trường phân tán, hệ thống có thể triển khai cơ chế lock/unlock – tức là tạm thời giữ quyền truy cập độc quyền lên tài nguyên. Trước khi nghiệp vụ được thực thi, một command để khóa tài nguyên sẽ được gửi lên và trả về một lock token, giữ nguyên trong suốt quá trình xử lý nghiệp vụ. Những client gửi yêu cầu lock trễ hơn sẽ nhận được lỗi (ví dụ 409 Conflict hoặc 423 Locked) và không được phép tiếp tục hành động.

Vì cơ chế strong consistency thường phức tạp và khó mở rộng, nó chỉ nên được áp dụng chọn lọc, giới hạn trong những tình huống thật sự yêu cầu sự nhất quán tuyệt đối. Trong khả năng cho phép, nên gói gọn toàn bộ nghiệp vụ thành một command đơn lẻ, để tránh cần đến locking kéo dài và giảm thiểu độ phức tạp trong điều phối truy cập giữa các người dùng.

CQRS và Polyglot Persistence

Bản chất CQRS tương tự như database cluster nhưng ở tầng ứng dụng. Database cluster chính là cho phép ứng dụng thực hiện transaction trên primary shard, và đọc dữ liệu từ các replicas. Điều này cho phép duy trì tính nhất quán của transactions song song với việc tăng tốc độ truy xuất qua việc đọc từ các replicas.

Database cluster cho phép bạn chọn giữa eventual consistency và strong consistency. Tuy nhiên database cluster giới hạn bạn chỉ sử dụng một loại database duy nhất, có thể không tối ưu cho các use case khác nhau.

Khi triển khai CQRS ở tầng ứng dụng, bạn không bị bó buộc vào một hệ quản trị cơ sở dữ liệu duy nhất. Trong đó write store có thể là một hệ RDBMS như MSSQL, PostgreSQL, MySQL,… để đảm bảo tính ACID. Còn read store có thể là MongoDB, ElasticSearch, hoặc Redis,… để tối ưu truy vấn. Việc kết hợp sử dụng nhiều hệ quản trị dữ liệu khác nhau gọi là Polyglot Persistence.

Thắc mắc thường gặp

Command không được trả về dữ liệu, vậy làm sao biết kết quả validation?

Theo đúng nguyên tắc thì command không được trả về dữ liệu, nhưng điều đó không nói lên là bạn không được trả về lỗi. Nếu command không thể thực hiện vì lý do gì đó, client cần nhận được thông báo lỗi. Như vậy command có thể xác nhận thành công hay thất bại, và nếu thất bại thì lỗi là gì.

Có nhiều cách để biết kết quả của command. Đơn giản qua REST API thì trả về đối tượng lỗi (exception) là đủ. Phức tạp (bất đồng bộ, background job, message queue, saga,…) thì trả về correlation token rồi client sẽ query status của command thông qua correlation token này.

Ngoài trạng thái của command thì có một ngoại lệ nữa là command có thể trả về ID của đối tượng vừa được tạo ra để phục vụ cho các bước xử lý tiếp theo. Ta không nên trả về dữ liệu chi tiết vì đó là việc của query. Nếu duplicate query logic vào command side sẽ gây khó bảo trì về lâu dài.

Có được phép đọc dữ liệu từ query side trong command side không?

Câu trả lời ngắn gọn là: KHÔNG.

Một nhầm lẫn phổ biến khi nhìn vào mô hình write store/read store, đó là làm thế nào để validate commands, hay nói cách khác, làm thế nào để read data trong khi đang xử lý commands. Đây là một số câu hỏi kiểu vậy:

https://stackoverflow.com/questions/32239353/command-validation-in-ddd-with-cqrs

https://stackoverflow.com/questions/67863289/cqrs-can-the-write-model-consume-a-read-model

Việc sử dụng chữ read và write gây nhầm lẫn ở đây. Hình dưới đây vẽ lại mô hình CQRS đối ứng với database cluster như đã nói ở trên, sửa lại cách gọi Primary Store và Read Store để dễ hình dung:

Hoặc có thể tham khảo ở liên kết này:
https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs

Read để validate command vẫn được thực hiện từ write model (hoặc write data store) – tức là từ nguồn dữ liệu chính xác, đồng nhất, cập nhật nhất. Vì mục tiêu của CQRS là tách biệt truy vấn phục vụ hiển thị ra khỏi logic ghi và xử lý nghiệp vụkhông phải cấm tuyệt đối việc đọc trong luồng ghi.

Trong trường hợp validation xảy ra trên UI để hỗ trợ UX, tùy theo mức độ nhất quán cần thiết mà có thể đọc từ read store hoặc write store. Ưu tiên sử dụng read store để đảm bảo performance, vì vẫn còn một lớp validation trên command side để bảo vệ, và trong trường hợp eventual consistency thường xuyên < 1s thì xác suất không nhất quán khi validation trên UI sẽ rất thấp.

Lạm dụng message broker ngay từ đầu

Nhiều đội ngũ triển khai CQRS là lập tức đưa Kafka, RabbitMQ, hoặc event bus vào – dẫn đến hệ thống phức tạp hóa không cần thiết. Bạn có thể bắt đầu CQRS một cách đơn giản, rồi nâng cấp dần khi cần scale.

Cách tiếp cận này cũng rất hiệu quả trong trường hợp migrate một hệ thống hiện có (monolith, CRUD-based) sang mô hình CQRS:

  • Ban đầu, read model và write model có thể vẫn sử dụng chung một database.
  • Trong khi đó, bạn tách riêng command và query logic ở tầng ứng dụng – giữ nguyên cấu trúc dữ liệu, không làm gián đoạn hệ thống.
  • Khi đã hoàn tất việc tách logic và các phần của hệ thống hoạt động ổn định, bạn mới bắt đầu tách hạ tầng (infra): tạo read store riêng, đồng bộ dữ liệu qua message queue hoặc event stream nếu cần.
  • Việc phân tách này có thể thực hiện dần theo từng use case, từng module.

Tóm lại

  • CQRS (Command and Query Responsibility Segregation) là mô hình tách biệt luồng ghi (Command) và luồng đọc (Query) để tối ưu hiệu năng, khả năng mở rộng và tính tổ chức trong ứng dụng.
  • Tương tự như mô hình database primary-replica, nhưng CQRS được triển khai ở tầng ứng dụng, giúp linh hoạt chọn lựa công nghệ và kiến trúc hệ thống.
  • Command không nên trả về dữ liệu chi tiết, nhưng vẫn có thể phản hồi kết quả (thành công/thất bại), exception và ID của đối tượng mới tạo.
  • Eventual consistency là đặc điểm phổ biến trong CQRS, cần được hiểu rõ để thiết kế UI/UX phù hợp và tránh phụ thuộc vào read model cho logic nghiệp vụ quan trọng.
  • Với các nghiệp vụ yêu cầu strong consistency, có thể áp dụng cơ chế lock/unlock ở command side để kiểm soát quyền truy cập và tránh xung đột.
  • CQRS không bắt buộc phải đi cùng với Kafka, RabbitMQ hay Event Sourcing ngay từ đầu. Bạn có thể triển khai CQRS theo từng bước, bắt đầu đơn giản, dùng chung cơ sở dữ liệu, rồi tách dần command/query trước khi mở rộng hạ tầng.

CQRS không phải là giải pháp cho mọi hệ thống, nhưng nếu được áp dụng đúng cách, nó có thể giúp bạn xây dựng những ứng dụng rõ ràng, hiệu quả và dễ mở rộng theo thời gian.

Leave a Reply