CQRS — Tách đọc khỏi ghi, app scale ngon hơn hẳn

Phong Hy

Mấy anh em dev hay quen một chuyện: cứ lấy một model duy nhất cho cả đọc rồi ghi. Nghe cũng hợp lý, nhưng khi app lớn dần thì cái model "2 trong 1" ấy bắt đầu cản đường. Đó là lúc CQRS (Command Query Responsibility Segregation) bước vô.

CQRS là gì?

Nói nôm na: chia đôi. Command là thứ làm thay đổi dữ liệu (INSERT, UPDATE, xử lý nghiệp vụ — ghi). Query là thứ trả dữ liệu ra (SELECT, đọc). CQRS bắt mình tách riêng model đọc và model ghi, thay vì ép chúng chung một khuôn.

Tại sao lại hay? Vì nhu cầu của 2 chiều rất khác nhau:

  • Ghi: cần ràng buộc nghiệp vụ, transaction, validation chặt chẽ.
  • Đọc: cần nhanh, dễ query, đôi khi phải "precompute" sẵn câu trả lời.

Ép chung một model thì cái nào cũng phải thỏa hiệp, cả 2 đều không tối ưu.

Ví dụ thực chiến với Go

Giả sử app bán hàng. Chức năng ghi: đặt đơn hàng. Chức năng đọc: trang dashboard thống kê doanh số theo ngày.

Command handler — xử lý ghi, kiểm tra dữ liệu kỹ:

type PlaceOrder struct {
    OrderID  string
    Customer string
    Items    []LineItem
}

type OrderWriteModel struct {
    repo OrderRepository
}

func (w *OrderWriteModel) Handle(cmd PlaceOrder) error {
    if len(cmd.Items) == 0 {
        return errors.New("order phải có ít nhất 1 sản phẩm")
    }
    order, err := NewOrder(cmd.Customer, cmd.Items) // validate nghiệp vụ
    if err != nil {
        return err
    }
    return w.repo.Save(order) // transaction trong DB ghi
}

Query side — tách riêng, thoải mái optimize cho đọc:

type SalesDashboard struct {
    db *sql.DB // DB đọc riêng (có thể là read replica)
}

type DailySales struct {
    Date  string
    Total float64
}

func (q *SalesDashboard) DailyTotal(ctx context.Context, from, to time.Time) ([]DailySales, error) {
    rows, err := q.db.QueryContext(ctx, `
        SELECT date, COALESCE(SUM(total), 0)
        FROM sales_materialized   -- bảng đã materialize sẵn cho dashboard
        WHERE date BETWEEN $1 AND $2
        GROUP BY date
        ORDER BY date`, from, to)
    // ... scan kết quả, ko cần transaction, ko cần lock
}

Thấy chưa? Chiều ghi thì giữ nguyên nghiệp vụ, chiều đọc thì mình dùng bảng sales_materialized, đã tính sẵn, query siêu nhẹ. Dashboard không còn "300ms vì group by mấy triệu row" nữa.

Khi nào dùng, khi nào thôi?

Kinh nghiệm thực tế của mình: đừng dùng CQRS cho mọi thứ. Nó thêm độ phức tạp (2 model, sync giữa đọc/ghi, đôi khi phải event). CQRS phát huy khi:

  • Chiều đọc và ghi có nhu cầu hoàn toàn khác nhau (ví dụ dashboard nặng đọc).
  • App nhiều người đọc, ít người ghi.
  • Một feature cần đọc aggregate dữ liệu mà model ghi không phù hợp.

Còn CRUD đơn giản mà dùng CQRS thì chỉ tốn công. Bắt đầu với một model, khi nào chiều đọc đau thì mới tách — gọi là "CQRS khi cần".

Điểm mấu chốt

CQRS không phải cái gì cao siêu, nó chỉ là nhắc mình: đọc với ghi là 2 bài toán khác nhau, đừng gò chung. Phối hợp tốt với read replica, materialized view, đôi khi cả event sourcing (nhưng đừng đụng tới nếu chưa cần).

Hôm nào gặp cái dashboard chạy chậm, anh em thử hỏi một câu: "Model này đang phục vụ đọc, nhưng nó được thiết kế cho ghi à?" — câu trả lời đó thường là điểm bắt đầu của CQRS. Chúc anh em scale ngon! 🚀