CQRS — Command Query Responsibility Segregation — là một trong những mẫu kiến trúc bị hiểu nhầm nhiều nhất. Nhiều người tưởng nó là event sourcing, hoặc phải dùng hai database. Thực ra ý tưởng cốt lõi rất đơn giản: tách đường ghi khỏi đường đọc để mỗi bên tối ưu cho mục đích của nó. Ghi cần đúng đắn và ràng buộc, nên dùng mô hình chuẩn hóa; đọc cần nhanh, nên dùng mô hình gộp sẵn (denormalized). Bài này dựng CQRS chạy được, đo thật lợi ích tốc độ đọc, và nói thẳng cái giá phải trả.

Hai bên: command và query

Trong CQRS, mọi thao tác thay đổi trạng thái là command (lệnh), mọi thao tác đọc là query (truy vấn), và chúng đi qua hai đường code khác nhau. Bên command dùng mô hình chuẩn hóa tối ưu cho tính đúng:

type Command interface{ ten() string }
type TaoDon struct{ ID int; Khach string; Tien int }
type HuyDon struct{ ID int }

func (h *CommandHandler) Xu(c Command) error {
	switch v := c.(type) {
	case TaoDon: h.ws.don[v.ID] = &donGhi{ID: v.ID, Khach: v.Khach, Tien: v.Tien}
	case HuyDon: if d, ok := h.ws.don[v.ID]; ok { d.Huy = true }
	}
	h.onChange() // CHIẾU sang read model sau mỗi lệnh
	return nil
}

Bên query dùng một read model đã gộp sẵn — ở đây là thống kê theo khách, dựng trước để đọc không phải tính lại:

type ThongKeKhach struct{ Khach string; TongTien int; SoDon int } // dựng SẴN

func (q *QueryHandler) TopKhach() []*ThongKeKhach {
	// chỉ đọc + sắp, KHÔNG duyệt từng đơn
}

Ảnh chụp đoạn mã Go nền tối minh hoạ CQRS tách mô hình ghi khỏi mô hình đọc, command side ghi mô hình chuẩn hóa đúng đắn type Command interface ten string type TaoDon struct ID int Khach string Tien int type HuyDon struct ID int func h con trỏ CommandHandler Xu c Command error switch v bằng c type case TaoDon h ws don v ID bằng donGhi case HuyDon h ws don v ID Huy bằng true h onChange chiếu sang read model sau mỗi lệnh, query side đọc mô hình phi chuẩn hóa tối ưu đọc type ThongKeKhach struct Khach string TongTien int SoDon int read model dựng sẵn không tính lại lúc đọc func q con trỏ QueryHandler TopKhach trả slice con trỏ ThongKeKhach chỉ đọc cộng sắp không duyệt từng đơn, projection dựng read model từ write model func chieu ws con trỏ WriteStore rs con trỏ ReadStore for underscore d range ws don nếu d Huy continue tk bằng rs theoKhach d Khach tk TongTien cộng bằng d Tien tk SoDon cộng cộng gộp sẵn ghi trả giá cho việc dựng read model đọc thì gần như miễn phí, vì sao tách ghi cần đúng đắn validate ràng buộc mô hình chuẩn hóa đọc cần nhanh dashboard báo cáo mô hình gộp sẵn hai bên tối ưu độc lập thậm chí dùng CSDL khác nhau read model có thể nhân bản scale đọc riêng khỏi ghi

Hình 1: Bên command (TaoDon, HuyDon) dùng mô hình ghi chuẩn hóa; bên query dùng read model ThongKeKhach gộp sẵn. Sau mỗi lệnh, projection chiếu write model sang read model để đọc không phải tính lại.

Projection: cầu nối hai mô hình

Cái gắn hai bên lại là projection (phép chiếu): sau mỗi lệnh ghi, dựng lại (hoặc cập nhật) read model từ write model:

func chieu(ws *WriteStore, rs *ReadStore) {
	for _, d := range ws.don {
		if d.Huy { continue }
		tk := rs.theoKhach[d.Khach]
		tk.TongTien += d.Tien; tk.SoDon++ // gộp sẵn cho đọc
	}
}

Đo thật (Go 1.23): sau các lệnh TaoDon An 100, TaoDon Bình 250, TaoDon An 80, HuyDon(Bình), truy vấn TopKhach() trả An: tổng 180, 2 đơn — đúng (100+80), và Bình biến mất vì đơn đã hủy (projection loại đơn Huy). Read model luôn phản ánh trạng thái sau mỗi lệnh.

Đo thật: đọc từ read model nhanh hơn ~328 lần

Đây là lý do CQRS tồn tại. So đọc từ read model dựng sẵn với việc tính lại từ write model (100.000 đơn) mỗi lần truy vấn:

Ảnh chụp bảng kết quả đo thật nền tối đọc từ read model nhanh hơn tính lại 328x đổi lại ghi phải chiếu, go run cộng go test bench Go 1.23 arm64 10 core, chạy thật lệnh ghi rồi truy vấn từ read model TaoDon An 100 TaoDon Bình 250 TaoDon An 80 HuyDon Bình Query Top khách theo tổng tiền đọc từ read model An tổng 180 2 đơn Bình bị hủy đơn projection loại khỏi read model, benchmark đọc từ read model vs tính lại từ 100k đơn từ read model dựng sẵn 2.547 ns 952 byte 3 alloc tính lại từ write model 100k đơn 834.971 ns 11.952 byte 110 alloc đọc từ read model nhanh hơn 328x chỉ đọc bảng gộp sẵn thay vì duyệt lại 100k đơn mỗi lần đây là lý do CQRS tồn tại, cái giá ghi phải làm thêm việc chiếu mỗi lệnh ghi chạy projection cập nhật read model nếu chiếu đồng bộ ghi chậm hơn nhưng đọc luôn nhất quán nếu chiếu bất đồng bộ ghi nhanh nhưng read model trễ eventual consistency đọc có thể thấy dữ liệu cũ vài ms, cốt lõi tách Command ghi chuẩn hóa vs Query đọc gộp sẵn projection mỗi lệnh chiếu write model sang read model đọc nhanh 328x vì không tính lại chỉ đọc bảng dựng sẵn cái giá ghi thêm việc cộng nhân đôi dữ liệu cộng có thể trễ khi nào hệ đọc nhiều gấp bội ghi báo cáo phức tạp

Hình 2: Đọc từ read model dựng sẵn 2.547 ns so với tính lại từ 100k đơn 834.971 ns — nhanh hơn ~328 lần (và 3 cấp phát so với 110). Đổi lại, mỗi lệnh ghi phải chạy projection; nếu chiếu bất đồng bộ thì read model có độ trễ (eventual consistency).

  • đọc từ read model: 2.547 ns/op, 952 B, 3 cấp phát.
  • tính lại từ write model: 834.971 ns/op, 11.952 B, 110 cấp phát.

Nhanh hơn ~328 lần. Read model chỉ là một bảng gộp sẵn — đọc nó là đọc kết quả đã tính; còn tính lại phải duyệt cả 100.000 đơn mỗi lần truy vấn. Với một dashboard hay báo cáo gọi hàng nghìn lần mỗi giây, khác biệt này là sống còn. Đây chính là giá trị cốt lõi của CQRS: dời chi phí tính toán từ lúc đọc sang lúc ghi, vì phần lớn hệ đọc nhiều hơn ghi rất nhiều.

Đánh đổi cần cân nhắc

Ghi phải trả giá cho projection, và có thể trễ nhất quán. Mỗi lệnh ghi giờ phải cập nhật read model. Nếu chiếu đồng bộ (ngay trong transaction ghi), ghi chậm hơn nhưng đọc luôn nhất quán tức thì. Nếu chiếu bất đồng bộ (qua hàng đợi, worker riêng), ghi nhanh nhưng read model trễ — đọc có thể thấy dữ liệu cũ vài mili giây (eventual consistency). Đây là đánh đổi cơ bản của CQRS, và chọn sai gây bug khó hiểu ("tôi vừa tạo đơn mà báo cáo chưa thấy").

CQRS thêm phức tạp đáng kể — đừng dùng khi chưa cần. Bạn giờ có hai mô hình, một projection, và (nếu bất đồng bộ) một cơ chế đồng bộ. Với CRUD đơn giản mà đọc/ghi cân bằng và không có truy vấn phức tạp, CQRS chỉ nhân đôi công việc mà không đem lại gì. Nó trả cổ tức khi: đọc nhiều gấp bội ghi, truy vấn đọc phức tạp (gộp nhiều bảng, thống kê), hoặc đọc và ghi cần scale độc lập.

CQRS không bắt buộc đi kèm event sourcing. Một hiểu nhầm phổ biến. CQRS chỉ nói "tách đọc và ghi"; read model có thể dựng từ chính write store bằng projection thường (như bài này), không cần event log. Event sourcing (lưu chuỗi sự kiện thay vì trạng thái) là một mẫu khác thường kết hợp với CQRS nhưng độc lập — đừng gánh cả hai khi chỉ cần một.

Ba ý mang về

  1. CQRS tách đường ghi (command, mô hình chuẩn hóa cho tính đúng) khỏi đường đọc (query, read model gộp sẵn cho tốc độ), nối bằng projection — mỗi lệnh ghi chiếu write model sang read model để đọc không phải tính lại.
  2. Đọc từ read model nhanh hơn tính lại rất nhiều: đo thật 2.547 ns so với 834.971 ns khi tính lại từ 100k đơn (~328 lần), vì read model là kết quả đã gộp sẵn — CQRS dời chi phí tính toán từ lúc đọc sang lúc ghi.
  3. Cái giá là phức tạp và có thể trễ nhất quán: ghi phải chạy projection, chiếu bất đồng bộ cho eventual consistency, và dữ liệu nhân đôi — chỉ đáng khi đọc nhiều gấp bội ghi hoặc truy vấn phức tạp; và CQRS không bắt buộc đi kèm event sourcing.

Phần sau ta xét chính mẫu hay bị nhầm với CQRS, lưu lịch sử thay vì trạng thái: Phần sau mổ xẻ event sourcing cơ bản — lưu chuỗi sự kiện thay cho trạng thái hiện tại, cách dựng lại trạng thái, và đánh đổi so với lưu trạng thái trực tiếp.