Software Architecture

CQRS (Command Query Responsibility Segregation): Tách biệt đọc và ghi để tối ưu hiệu năng

7/19/2026 · 11p đọc


title: "CQRS (Command Query Responsibility Segregation): Tách biệt đọc và ghi để tối ưu hiệu năng"
series: "Software Architecture Masterclass"
part: "Phần 2 — Cấu trúc & Patterns"
order: 18
audience: "Solutions Architect & Technical Lead"
reading_time: "13 phút"
tags: ["CQRS", "event-driven", "eventual consistency", "read model", "write model", "data architecture", "scalability"]

CQRS (Command Query Responsibility Segregation): Tách biệt đọc và ghi để tối ưu hiệu năng

Một buổi tối thứ Sáu, team backend của một sàn thương mại điện tử nhận cuộc gọi khẩn: trang dashboard doanh thu — nơi CEO và các trưởng phòng kinh doanh nhìn số liệu mỗi sáng — load mất 45 giây, thỉnh thoảng timeout. Cùng lúc đó, luồng đặt hàng (checkout) cũng bắt đầu chậm theo, vì cùng một bảng orders đang bị cả hai phía cùng cào: bên ghi (insert đơn hàng mới) và bên đọc (aggregate query cho dashboard) tranh chấp lock, tranh chấp I/O, tranh chấp buffer pool của MySQL.

Đây không phải là một câu chuyện hiếm. Nó là hệ quả tất yếu của một giả định sai: rằng một mô hình dữ liệu duy nhất có thể phục vụ tốt cho cả hai mục đích hoàn toàn khác nhau — ghi giao dịch (transactional write) và đọc phân tích (analytical read). Giả định này đúng ở quy mô nhỏ. Nó sụp đổ khi hệ thống lớn lên, khi truy vấn đọc trở nên phức tạp (join nhiều bảng, aggregate, filter đa chiều), còn ghi thì cần tốc độ và tính toàn vẹn giao dịch.

CQRS — Command Query Responsibility Segregation — là câu trả lời kiến trúc cho vấn đề này. Nhưng như mọi pattern khác trong series này, nó không phải "best practice" áp dụng đại trà. Nó là một sự đánh đổi có giá cụ thể, và bài này sẽ mổ xẻ cái giá đó thay vì chỉ ca ngợi lợi ích.

The Problem

Hãy hình dung mô hình dữ liệu chuẩn hóa (normalized) kinh điển: bảng orders, order_items, customers, products, inventory — mỗi bảng chỉ lưu đúng một sự thật, tránh trùng lặp, đảm bảo tính toàn vẹn tham chiếu (referential integrity). Mô hình này tuyệt vời cho việc ghi: khi một đơn hàng được tạo, transaction chỉ cần insert vào vài bảng, foreign key đảm bảo consistency, rollback đơn giản nếu có lỗi.

Nhưng khi phòng kinh doanh muốn xem "doanh thu theo khu vực, theo danh mục sản phẩm, theo tháng, so sánh với cùng kỳ năm trước, kèm breakdown theo kênh bán" — câu truy vấn đó phải join 5-6 bảng, group by nhiều chiều, tính toán window function. Trên một bảng orders có hàng chục triệu dòng đang liên tục được ghi mới, truy vấn phân tích này:

  1. Chậm — vì phải quét/join khối lượng dữ liệu lớn không được đánh index tối ưu cho truy vấn dạng này (index tối ưu cho ghi và tra cứu theo khóa chính thường khác hẳn index tối ưu cho aggregate).
  2. Cạnh tranh tài nguyên với luồng ghi — cùng một connection pool, cùng buffer pool, cùng lock table (tùy engine và isolation level).
  3. Không thể tối ưu độc lập — muốn thêm một index phục vụ riêng cho dashboard thì lại làm chậm insert vì mỗi ghi giờ phải update thêm index đó.

Vấn đề gốc: command (ghi) và query (đọc) có yêu cầu tối ưu hóa đối nghịch nhau, nhưng lại bị ép sống chung trên một mô hình dữ liệu, một schema, đôi khi một database instance duy nhất. Chuẩn hóa tốt cho ghi thì tệ cho đọc phức tạp; phi chuẩn hóa (denormalize) tốt cho đọc thì lại rước rủi ro bất nhất và chi phí ghi cao hơn vào mô hình ghi.

The Theory

CQRS tách một hệ thống thành hai mô hình riêng biệt theo trách nhiệm:

  • Command side (Write model): nhận các lệnh thay đổi trạng thái (CreateOrder, CancelOrder, UpdateInventory). Tối ưu cho tính toàn vẹn giao dịch, validate business rule, và ghi nhanh. Schema ở đây thường chuẩn hóa, gắn chặt với domain model (xem lại Bài 9 — Domain-Driven Design).
  • Query side (Read model): phục vụ các câu hỏi đọc (GetOrderSummaryByRegion, GetDashboardMetrics). Schema ở đây được thiết kế ngược lại — phi chuẩn hóa, phẳng, đôi khi là dữ liệu đã tính sẵn (pre-aggregated), tối ưu cho tốc độ đọc chứ không quan tâm đến việc trùng lặp dữ liệu.

Điểm mấu chốt không chỉ nằm ở việc tách API (một service write và một service read) mà là tách luôn mô hình dữ liệu và có thể cả data store. Command side có thể dùng một RDBMS (PostgreSQL/MySQL) để đảm bảo ACID; query side có thể dùng Elasticsearch cho tìm kiếm full-text, một data warehouse (ClickHouse, Redshift) cho analytics, hoặc đơn giản là các bảng "read-optimized" nằm ngay trong cùng database nhưng có schema hoàn toàn khác.

Cầu nối giữa hai bên thường là cơ chế đồng bộ bất đồng bộ: mỗi khi command side ghi thành công, nó phát ra một event (OrderCreated, OrderCancelled) — event này được một tiến trình gọi là projector (hay "projection handler") lắng nghe và cập nhật read model tương ứng. Đây chính là điểm giao thoa với Event-Driven Architecture (Bài 13): CQRS thường (không bắt buộc, nhưng phổ biến) được triển khai cùng một message broker như Kafka hoặc RabbitMQ để truyền event từ write side sang read side.

Cần phân biệt rõ hai mức độ áp dụng CQRS:

  • CQRS "nhẹ": chỉ tách API/service layer thành command handler và query handler, nhưng vẫn dùng chung một database. Mức này giảm được sự lẫn lộn về mặt code (một hàm không vừa update vừa query phức tạp), nhưng không giải quyết được vấn đề tranh chấp tài nguyên hạ tầng.
  • CQRS "đầy đủ": tách luôn data store, đồng bộ qua event, chấp nhận eventual consistency. Mức này mới thực sự giải quyết được bài toán ở phần Problem, nhưng đi kèm chi phí vận hành lớn hơn nhiều — đây là trọng tâm phân tích trade-off dưới đây.

CQRS thường đi kèm — nhưng không bắt buộc đi kèm — Event Sourcing (lưu trạng thái dưới dạng chuỗi sự kiện thay vì snapshot hiện tại). Đó là hai pattern độc lập: có thể làm CQRS mà không cần Event Sourcing (chỉ cần publish event sau khi ghi DB như bình thường), và ngược lại. Bài này tập trung vào CQRS thuần, không đi sâu Event Sourcing.

The Trade-off

Đây là phần quan trọng nhất — và cũng là phần thường bị các bài viết marketing về CQRS lờ đi.

Được gì:

  1. Scale đọc và ghi độc lập. Read model có thể nhân bản ra N read replica, cache aggressively, hoặc đẩy sang một document store scale ngang dễ dàng — mà không ảnh hưởng đến throughput ghi. Nếu tải đọc gấp 100 lần tải ghi (rất phổ biến ở SaaS báo cáo/dashboard), bạn scale đúng phần cần scale thay vì scale cả cụm.
  2. Schema tối ưu riêng từng bên. Write model giữ chuẩn hóa để đảm bảo tính toàn vẹn; read model có thể phẳng hóa, denormalize, pre-join, pre-aggregate — biến một câu query "5 giây" thành một lần đọc key-value "5 mili giây".
  3. Công nghệ lưu trữ chuyên biệt cho từng use case. Không còn bị ép "một database phải làm tất cả". Command side dùng PostgreSQL cho ACID; query side dùng Elasticsearch cho search, hoặc một bảng summary trong Redis cho dashboard real-time.
  4. Đơn giản hóa logic ở từng phía. Command handler không còn phải lo vừa validate business rule vừa build câu SELECT phức tạp; query handler không cần biết gì về invariant nghiệp vụ, chỉ trả dữ liệu đã được chuẩn bị sẵn.

Mất gì — và đây là cái giá thật, không phải rủi ro lý thuyết:

  1. Eventual consistency trở thành một phần hiển nhiên của hệ thống, không phải ngoại lệ. Sau khi command side ghi thành công, có một độ trễ (từ mili giây đến vài giây, tùy hạ tầng message broker và tải hệ thống) trước khi read model phản ánh đúng. Điều này có nghĩa: ngay sau khi user tạo đơn hàng, nếu họ bấm F5 vào trang danh sách đơn hàng (đọc từ read model), họ có thể không thấy đơn hàng vừa tạo. Đây là bug UX kinh điển của CQRS mới triển khai, và nó buộc đội ngũ phải thiết kế UI chấp nhận độ trễ này (optimistic update ở client, hoặc đọc trực tiếp write model cho các trường hợp cần đọc-ngay-sau-ghi).
  2. Độ phức tạp vận hành tăng gấp đôi (tối thiểu). Giờ bạn có hai data store phải backup, hai schema phải migrate, hai pipeline phải monitor, và một tầng đồng bộ (projector/event consumer) là điểm hỏng mới — nếu projector chết hoặc lag, read model bắt đầu "nói dối" mà không ai biết cho đến khi có khiếu nại.
  3. Debug khó hơn hẳn. Khi dữ liệu trên dashboard sai, câu hỏi đầu tiên không còn là "query có bug không" mà là "write model đúng không, event có publish không, projector có consume không, có message nào bị mất/duplicate không". Chuỗi nhân quả dài ra, đòi hỏi distributed tracing và observability tốt (liên hệ Bài 28) chỉ để trả lời câu hỏi cơ bản.
  4. Chi phí phát triển ban đầu cao hơn CRUD đơn giản đáng kể. Một tính năng CRUD giờ cần: command handler, event definition, event publisher, projection handler, read schema riêng, và một chiến lược để rebuild read model khi projection logic thay đổi (replay toàn bộ event, hoặc backfill từ write model). Với một tính năng đơn giản như "cập nhật tên khách hàng", đây là quá tải.
  5. Đảm bảo idempotency và thứ tự event không tầm thường. Projector phải xử lý được trường hợp nhận trùng event (retry của broker) mà không double-apply, và trường hợp event đến sai thứ tự (network jitter) mà không làm read model sai lệch vĩnh viễn.

Nói ngắn gọn: CQRS đánh đổi độ phức tạp hệ thống và độ trễ consistency để lấy khả năng scale và tối ưu độc lập. Nếu tải đọc/ghi của bạn không lệch nhau nhiều, hoặc consistency tức thời là yêu cầu cứng (ví dụ số dư tài khoản ngân hàng phải đọc-ghi nhất quán tuyệt đối), CQRS đầy đủ là một khoản đầu tư âm.

The Architecture Diagram

Sơ đồ dưới đây minh họa luồng chuẩn: command đi vào write model, write model phát event, event được projector tiêu thụ để cập nhật read model — và query luôn đọc từ read model, không bao giờ đọc ngược từ write model.

flowchart LR
    subgraph Client
        UI[Client / UI]
    end

    subgraph WriteSide["Command Side (Write Model)"]
        CH[Command Handler]
        WDB[(Write DB<br/>chuẩn hóa, ACID)]
    end

    subgraph Bus["Event Bus"]
        MQ[[Message Broker<br/>Kafka / RabbitMQ]]
    end

    subgraph ReadSide["Query Side (Read Model)"]
        PROJ[Projection Handler]
        RDB[(Read DB<br/>denormalized, tối ưu đọc)]
        QH[Query Handler]
    end

    UI -- "1. Command<br/>(CreateOrder)" --> CH
    CH -- "2. Validate + persist" --> WDB
    CH -- "3. Publish event<br/>(OrderCreated)" --> MQ
    MQ -- "4. Consume event" --> PROJ
    PROJ -- "5. Update projection" --> RDB
    UI -- "6. Query<br/>(GetOrderDashboard)" --> QH
    QH -- "7. Read" --> RDB

    style WDB fill:#2b6cb0,color:#fff
    style RDB fill:#38a169,color:#fff
    style MQ fill:#d69e2e,color:#000

Lưu ý mũi tên số 6-7: query handler không bao giờ đọc từ Write DB. Đây chính là ranh giới kiến trúc phải bảo vệ nghiêm ngặt — vi phạm nó (ví dụ "để tiện, join thẳng sang bảng bên write cho nhanh") sẽ phá vỡ toàn bộ lý do tồn tại của CQRS.

Implementation Guardrails

  1. Write model luôn là nguồn sự thật (source of truth) duy nhất — read model không bao giờ được là nguồn sự thật ẩn. Mọi business rule, mọi validate, mọi invariant nghiệp vụ phải nằm ở command side. Nếu một ngày read model và write model lệch nhau, quy tắc giải quyết xung đột luôn là: write model thắng, read model bị coi là sai và cần rebuild. Tuyệt đối không cho phép bất kỳ luồng nghiệp vụ nào ghi trực tiếp vào read model rồi coi đó là dữ liệu chính thức.
  2. Projection phải idempotent và có khả năng replay từ đầu. Thiết kế projector sao cho việc xử lý cùng một event hai lần (do retry) không tạo ra sai lệch, và có sẵn cơ chế "rebuild read model" bằng cách replay toàn bộ lịch sử event (hoặc backfill từ write DB) khi cần đổi schema đọc hoặc sửa bug logic projection.
  3. UI/API phải công khai xử lý eventual consistency, không được che giấu nó. Sau một command thành công, hoặc trả về đủ dữ liệu để client tự cập nhật optimistic (không cần đọc lại read model ngay), hoặc thiết kế endpoint đọc-ngay-sau-ghi đi thẳng vào write model cho các trường hợp bắt buộc (ví dụ: trang xác nhận đơn hàng ngay sau khi đặt).
  4. Giám sát độ trễ đồng bộ (replication lag) như một chỉ số vận hành bắt buộc, không phải tùy chọn. Phải có alerting khi khoảng cách giữa event được publish và projection hoàn tất vượt ngưỡng chấp nhận được — vì đây là chỉ số duy nhất cho biết "read model đang nói dối bao lâu rồi".

Architect's Verdict

CQRS xứng đáng với độ phức tạp khi hệ thống có sự lệch pha rõ rệt giữa đọc và ghi — SaaS nặng về báo cáo/dashboard, hệ thống có tải đọc gấp hàng chục đến hàng trăm lần tải ghi, hoặc khi mô hình đọc và ghi có hình dạng dữ liệu khác biệt căn bản (ví dụ ghi theo aggregate nhỏ nhưng đọc cần join xuyên nhiều domain). Với một module CRUD thông thường — nơi form nhập liệu và màn hình hiển thị gần như phản chiếu nhau — CQRS chỉ là gánh nặng vô ích, cộng thêm một tầng đồng bộ dễ vỡ mà không ai cần. Bắt đầu bằng CQRS "nhẹ" (tách command/query ở tầng service, chung DB) trước khi nhảy sang CQRS "đầy đủ" tách data store; đa số hệ thống dừng lại ở mức nhẹ là đủ, và chỉ nên đi tiếp khi có bằng chứng đo lường được (không phải phỏng đoán) rằng tải đọc đang bóp nghẹt tải ghi.

🔗 Bài viết liên quan: Event-Driven Architecture (EDA), SAGA Pattern


Bài trước: Clean Architecture · Bài tiếp theo: SAGA Pattern

CQRS (Command Query Responsibility Segregation): Tách biệt đọc và ghi để tối ưu hiệu năng