CQRS
CQRS — Khi một task làm lúc không có task lại có ích bất ngờ
Mỗi năm, hệ thống của chúng tôi đều gặp ít nhất một incident liên quan đến database, khá đều đặn. Dù đã optimize, monitor, caching đủ kiểu, cuối cùng vũ trụ vẫn gửi đến một incident to bự, như mọi năm.
Bài này kể về một incident như thế, và về một việc team làm hơn một năm trước mà lúc đó tưởng chẳng để làm gì.
Bối cảnh hệ thống
Trước khi vào incident, cần hiểu hình dạng hệ thống:
Chạy multi-platform: service được deploy ở cả data center (DC) lẫn cloud.
Database primary nằm ở data center, cho phép cả đọc và ghi.
Tỉ lệ truy cập rất lệch: đọc chiếm ~90%, ghi chỉ ~10%.
Tỉ lệ 90/10 này sẽ còn quay lại ở phần sau.
── Cloud ──────────── ── Data Center ──
┌──────────────┐ ┌──────────────┐
│ Service A │ │ Primary DB │
│ Service B │ ═══ đọc 90% + ghi 10% ════▶ │ (đọc + ghi) │
│ ... │ │ │
└──────────────┘ └──────────────┘
▲
└─ bandwidth dùng chung với dịch vụ khác → dễ nghẽn ⚠
Incident
Trong quá trình chuyển dịch dần từ data center lên cloud, một vấn đề âm ỉ bắt đầu lộ ra.
Khi ngày càng nhiều service nằm trên cloud nhưng vẫn phải gọi về primary DB dưới DC, lượng traffic qua đường nối DC ↔ cloud tăng đột biến. Tệ hơn, băng thông này dùng chung với nhiều dịch vụ khác. Chỉ cần một service nào đó chiếm bandwidth là tất cả các service khác nghẽn theo, kéo theo độ trễ truy vấn database tăng vọt.
Và thế là incident. Như mọi năm.
Giải pháp tạm thời từ team hạ tầng
Vũ trụ gửi incident, thì team hạ tầng cũng kịp gửi đến một thứ: một database secondary đặt trên cloud, chỉ phục vụ đọc.
Ý tưởng khá hợp lý khi soi vào con số 90/10:
Phần lớn service đã lên cloud, mà phần lớn workload lại là đọc → cho đọc ngay tại chỗ trên cloud, khỏi vòng về DC.
Việc ghi chỉ chiếm 10%, để ở cloud hay DC đều ổn. Cuối cùng quyết định vẫn giữ ghi ở DC cho dễ quản lý — ghi ít, một nguồn ghi duy nhất, đỡ phải lo đồng bộ ngược.
Nói cách khác: ở tầng hạ tầng, đường đọc và đường ghi giờ đã nằm ở hai nơi khác nhau.
Vấn đề mới: một ngày để migrate
Hạ tầng đã sẵn sàng. Nhưng giờ đến phần của dev, và đây mới là chỗ căng:
Chuyển toàn bộ một mớ kha khá service sang dùng database secondary cho việc đọc, trong vòng một ngày, mà không tạo ra một incident mới.
Ngã ở đâu mà lại gấp đôi ở đó thì rất dở. Migrate vội vàng đường đọc của hàng loạt service, đụng vào business logic, là công thức hoàn hảo để biến một incident thành hai.
Điều may mắn xuất hiện: CQRS
May là ở đúng chỗ này, có một việc cũ giúp được.
Hơn một năm trước, trong một giai đoạn khá rảnh, team làm một task tưởng chừng "cho vui": tách phần lớn data model thành read model và write model theo pattern CQRS.
CQRS là gì?
CQRS (Command Query Responsibility Segregation) là một mẫu thiết kế tách biệt hoàn toàn hai loại thao tác trên dữ liệu:
Query — đọc dữ liệu.
Command — ghi / cập nhật dữ liệu.
Thay vì một model dùng chung cho cả hai luồng, mỗi luồng có model, DAO và client riêng của mình. Ví dụ với PostModel:
┌───────────────┐
│ PostModel │
└───────┬───────┘
┌─────────────┴─────────────┐
▼ ▼
┌────────────────┐ ┌─────────────────┐
│ PostReadModel │ │ PostWriteModel │
└───────┬────────┘ └────────┬────────┘
▼ ▼
┌────────────────┐ ┌─────────────────┐
│ PostReadDAO │ │ PostWriteDAO │
└───────┬────────┘ └────────┬────────┘
▼ ▼
┌────────────────┐ ┌─────────────────┐
│ ReaderClient │ │ WriterClient │
└────────────────┘ └─────────────────┘
▷ luồng đọc ▷ luồng ghi
Luồng đọc và luồng ghi đi theo hai nhánh hoàn toàn độc lập, gặp nhau ở mỗi PostModel ban đầu.
Ưu và nhược điểm
Ưu điểm rõ nhất là scale độc lập: có thể tối ưu read path riêng (denormalize bảng đọc cho truy vấn siêu nhanh) mà không đụng gì đến write path, và ngược lại. Code nghiệp vụ cũng gọn hơn vì không lẫn lộn hai luồng, phân quyền trên từng luồng cũng dễ kiểm soát hơn.
Đổi lại, CQRS tăng độ phức tạp tổng thể của dự án, và bắt buộc phải xử lý độ trễ đồng bộ giữa database ghi và database đọc — read model luôn chạy sau write model một nhịp.
Lúc làm task đó, cái giá "phức tạp hơn" là có thật, còn cái lợi thì... mơ hồ. Không ai biết khi nào mới cần đến.
Payoff: migrate chỉ còn là chỉnh config
Và cái ngày cần đến nó là đây.
Vì đường đọc đã được tách sẵn qua ReaderClient từ hơn một năm trước, việc chuyển toàn bộ luồng đọc sang secondary DB không còn là refactor. Không phải đi sửa business logic ở từng service, không phải lần mò xem chỗ nào đọc chỗ nào ghi.
Tất cả gói gọn lại thành:
Chỉnh cấu hình
ReaderClienttrỏ sang secondary DB trên cloud.Deploy lại các service.
Cầu nguyện.
WriterClient giữ nguyên, vẫn trỏ về primary DB dưới DC. Đường ghi không bị động đến một dòng nào.
── Cloud ────────────────────── ── Data Center ──
ReaderClient ──── 90% đọc ────▶ ┌──────────────┐
│ Secondary DB │ ◀── replication ──┐
│ (chỉ đọc) │ │
└──────────────┘ │
┌─────────┴────┐
WriterClient ──── 10% ghi ─────────────────────────────▶│ Primary DB │
│ (ghi) │
└──────────────┘
Phần lớn traffic — đúng cái 90% đọc đó — giờ ở lại hoàn toàn trên cloud, không còn vòng về DC. Đường nối DC ↔ cloud nhẹ gánh. Incident được dập, và quan trọng hơn, không có incident thứ hai.
Bài học
Bài học 1 — Cứ chuẩn bị tinh thần là sẽ có incident. Dù optimize, monitor, review kỹ đến đâu thì một ngày nào đó incident vẫn đến, mỗi incident một vẻ không cái nào giống cái nào. Nhưng nếu không chịu khó improve thì nó sẽ đến thường xuyên hơn nhiều — thay vì mỗi năm một lần.
Bài học 2 — Việc làm lúc rảnh đôi khi lại dùng được. Task tách read/write model hơn một năm trước, lúc làm trông khá vô bổ và tốn thời gian. Nhưng chính nó biến một cuộc migrate đáng sợ thành vài dòng config. Lúc rảnh kiếm việc gì có ích mà làm, nhất là khi nó theo một pattern có sẵn, biết đâu sau này lại cần.
Bài học 3 — Design pattern thường có lý do của nó. Mấy pattern này được nghĩ ra không phải để cho vui. Chỉ là nhiều khi phải đến lúc thật sự cần, mình mới thấy rõ lý do đó.
