🏗️🧠 CQRS: tách đọc và ghi để hệ thống lớn không tự nghẽn - System Design P28
CQRS: Tách “Đọc” và “Ghi” Để Xử Lý Dữ Liệu Cực Lớn - Khi Mô Hình Dữ Liệu Duy Nhất Trở Thành "Xiềng Xích"
1. Mở đầu: Câu chuyện từ "chiến trường" Production
Đó là đêm 11.11, khoảnh khắc mà mọi kỹ sư thương mại điện tử đều nín thở. Tôi ngồi trước màn hình giám sát, nhìn biểu đồ Latency của dịch vụ Order bắt đầu dựng đứng như vách núi. Hệ thống đang xử lý một đợt Flash Sale quy mô lớn.
Ở phía "Ghi" (Write Path), hàng chục nghìn transaction đang đổ vào để tạo đơn hàng, trừ tồn kho, và áp mã giảm giá. Đây là những thao tác đòi hỏi tính chính xác tuyệt đối (Strong Consistency). Database quan hệ (RDBMS) của chúng tôi lúc đó được thiết kế cực kỳ "sạch" theo chuẩn 3NF (Third Normal Form) để đảm bảo không dư thừa dữ liệu và giữ vững tính toàn vẹn.
Thế nhưng, thảm họa lại đến từ phía "Đọc" (Read Path). Cùng lúc đó, hàng triệu người dùng liên tục nhấn F5 trang "Đơn hàng của tôi" và "Bảng tin ưu đãi" để theo dõi trạng thái. Để hiển thị được cái Dashboard "tổng hợp" đó, hệ thống phải thực hiện những cú JOIN "thần thánh" qua 7-8 bảng dữ liệu: từ Orders, Order_Items, Payments, Shipping, cho đến User_Loyalty và Promotion_History.
Kết quả là gì? Những câu truy vấn đọc phức tạp đã chiếm quyền ưu tiên khóa (locking) và ngốn sạch tài nguyên IOPS của Database. Các transaction ghi đơn hàng – nguồn sống của doanh nghiệp – bắt đầu bị timeout hàng loạt. Doanh thu rơi rụng theo từng giây.
Đứng ở góc độ một Senior Engineer, tôi nhận ra một sự thật cay đắng: Mô hình dữ liệu mà chúng tôi dày công tối ưu để "lưu trữ đúng" chính là xiềng xích ngăn cản hệ thống "hiển thị nhanh". Chúng ta đã quá tham lam khi cố ép một Schema duy nhất phải gánh vác hai nhiệm vụ có bản chất xung đột hoàn toàn. Câu hỏi không còn là "làm sao để tối ưu câu query này?", mà phải là: "Tại sao chúng ta vẫn cố chấp giữ một mô hình dữ liệu duy nhất khi workload đã phân cực?"
2. Niềm tin phổ biến và Sự sụp đổ của "Mô hình duy nhất"
Trong giáo trình đại học và những dự án nhỏ, chúng ta luôn được dạy rằng: "Một Database, một Schema duy nhất cho cả Đọc và Ghi là kiến trúc chuẩn mực và sạch sẽ nhất". Niềm tin này dựa trên sự tiện lợi: Bạn ghi vào bảng A, bạn đọc ra từ bảng A, dữ liệu luôn khớp nhau 100% tại mọi thời điểm.
Nhưng thực tế Production là một "con thú" hoàn toàn khác. Khi traffic tăng từ vài nghìn lên vài triệu user, sự "sạch sẽ" của Normalization trở thành gánh nặng.
"Sự đơn giản của mô hình dữ liệu duy nhất là một món quà ở quy mô nhỏ, nhưng lại là một xiềng xích khi hệ thống tiến hóa. Khi bạn cố gắng tối ưu hóa một Schema cho việc Ghi (để đảm bảo Integrity), bạn đang vô tình phá hủy hiệu suất Đọc. Và ngược lại."
Ở quy mô lớn, nhu cầu của doanh nghiệp không chỉ dừng lại ở việc lưu trữ. Kinh doanh đòi hỏi Dashboard phải hiển thị real-time, tìm kiếm phải trả kết quả trong vài chục mili giây, và báo cáo phải tổng hợp được dữ liệu từ nhiều nguồn. Nếu bạn cố giữ mô hình duy nhất, bạn sẽ rơi vào cái bẫy "thỏa hiệp tai hại":
- Nếu thêm quá nhiều Index để tăng tốc độ Đọc, tốc độ Ghi sẽ tụt dốc vì Database phải cập nhật Index tree liên tục.
- Nếu giữ Schema tinh gọn để Ghi nhanh, tốc độ Đọc sẽ thảm hại do phải tính toán và JOIN dữ liệu ngay lúc runtime.
Đó là lúc "Mô hình duy nhất" sụp đổ, nhường chỗ cho một tư duy kiến trúc mới.
3. Phân tích nguyên nhân gốc rễ: Sự phân cực của Workload
Tại sao Đọc và Ghi lại không thể "chung sống hòa bình"? Hãy nhìn vào bảng so sánh bản chất kỹ thuật của hai loại workload này dưới lăng kính của một System Architect:
| Đặc tính | Write Workload (Command) | Read Workload (Query) |
|---|---|---|
| Mục tiêu tối thượng | Tính toàn vẹn (Integrity), ACID, Validation | Tốc độ (Latency), Sự linh hoạt, UX |
| Cấu trúc dữ liệu | Chuẩn hóa (Normalized) để tránh anomaly | Phi chuẩn (Denormalized) để truy cập O(1) |
| Kiểu truy cập | Thường là Insert/Update theo ID đơn lẻ | Truy vấn tập hợp, lọc, tìm kiếm, JOIN |
| Tần suất traffic | Thường thấp hơn (ví dụ: 1-5% traffic) | Chiếm đa số tuyệt đối (95-99% traffic) |
| Nhu cầu mở rộng | Khó scale ngang (thường phải dùng Sharding) | Cực kỳ dễ scale ngang bằng Read Replicas/Cache |
| Hệ quả kinh doanh | Ghi sai/mất dữ liệu = Mất tiền, lỗi pháp lý | Đọc chậm = Người dùng rời bỏ, giảm conversion |
Sự xung đột này là vĩnh cửu. Việc cố gắng dung hòa chúng trong cùng một model giống như việc cố thiết kế một chiếc xe vừa phải chở được 50 tấn hàng (Write-heavy), vừa phải chạy nhanh như xe đua F1 (Read-heavy). Cuối cùng, bạn sẽ nhận được một chiếc xe không làm tốt bất cứ việc gì.
Sai lầm lớn nhất của các team không phải là chọn công nghệ sai, mà là cố tình phớt lờ sự phân cực này. Họ cố đắp thêm RAM, thêm CPU cho con Database trung tâm, nhưng đó chỉ là giải pháp "mua thêm thời gian" thay vì giải quyết gốc rễ vấn đề.
4. Tư duy kỹ sư: CQRS như một Chiến lược, không phải một Tool
CQRS (Command Query Responsibility Segregation) thường bị các Tutorial mô tả một cách máy móc như là việc "tách Database". Nhưng với một Senior Technical Architect, CQRS trước hết là một sự thay đổi về mô hình tư duy (Mental Shift).
Thay vì tư duy theo kiểu "Dữ liệu của tôi nằm trong bảng này", chúng ta chuyển sang tư duy theo "Trách nhiệm và Hành vi":
- Command (Luồng Ghi): Đại diện cho ý định của người dùng (Intent). Một Command như
PlaceOrderkhông đơn thuần là một câu lệnhINSERT. Nó là một chuỗi các nghiệp vụ: kiểm tra số dư, xác thực voucher, giữ chỗ tồn kho. Command tập trung vào việc thực thi logic và đảm bảo hệ thống chuyển từ trạng thái đúng này sang trạng thái đúng khác. Nó không cần trả về dữ liệu phức tạp, chỉ cần xác nhận: "Tôi đã tiếp nhận và xử lý thành công". - Query (Luồng Đọc): Đại diện cho nhu cầu thông tin. Một Query không quan tâm đến các ràng buộc nghiệp vụ phức tạp lúc ghi. Nó chỉ cần dữ liệu được "dọn sẵn ra đĩa" (Ready-to-serve).
Khẳng định quan trọng nhất: CQRS là sự chấp nhận có chủ đích về việc nhân bản dữ liệu (Data Duplication) để đổi lấy khả năng mở rộng (Scalability).
Trong kiến trúc này, chúng ta không còn sợ sự dư thừa. Chúng ta sẵn sàng lưu thông tin khách hàng ở 3 nơi khác nhau: một bản chuẩn hóa trong SQL để thanh toán, một bản JSON trong Document DB để hiển thị Profile, và một bản flattened trong Elasticsearch để tìm kiếm. Mỗi "phiên bản" dữ liệu đều được tối ưu hóa 100% cho mục đích sử dụng của nó.
5. Kiến trúc giải pháp: Từ Command đến Read Model
Làm thế nào để dữ liệu từ phía Ghi "chảy" sang phía Đọc một cách trơn tru? Đây là nơi chúng ta nói về quy trình Projection (Phóng chiếu).
Dòng chảy dữ liệu trong một hệ thống CQRS thực thụ sẽ diễn ra như sau:
- Command Execution: User gửi yêu cầu thay đổi trạng thái. Write Model xử lý transaction và đảm bảo tính nhất quán.
- Event Dispatching: Sau khi Ghi thành công, một Event (ví dụ:
OrderCreated) được phát ra. - Projection Pipeline: Các bộ xử lý (Projectors) nghe Event này và cập nhật vào các Read Model chuyên dụng.
Ở đây, một Senior Engineer phải đối mặt với những bài toán về Độ tin cậy của đường ống (Pipeline Guarantees) mà các bài hướng dẫn thường bỏ qua:
- Idempotency (Tính lũy đẳng): Nếu một Event bị gửi hai lần do lỗi mạng, Projector của bạn có làm dữ liệu bị sai lệch không? (Ví dụ:
TotalAmountbị cộng gấp đôi). Thiết kế Projector phải đảm bảo dù xử lý một Event bao nhiêu lần thì kết quả cuối cùng vẫn như nhau. - At-least-once Delivery: Chúng ta thường chọn cơ chế này để đảm bảo không mất dữ liệu, nhưng nó đòi hỏi hệ thống phía Read Model phải cực kỳ "tỉnh táo" để xử lý trùng lặp.
- Ordering: Điều gì xảy ra nếu Event
OrderCancelledđến trước EventOrderCreated? Hệ thống của bạn cần một cơ chế đánh dấu phiên bản (Versioning) hoặc Timestamp để giữ cho Read Model không bị rơi vào trạng thái phi lý.
Sức mạnh thực sự của CQRS nằm ở chỗ: Read Model có thể là bất cứ thứ gì.
- Bạn cần Dashboard cho sếp? Hãy đẩy dữ liệu vào một bảng Aggregated trong PostgreSQL.
- Bạn cần Newsfeed như Facebook? Hãy đẩy vào một Graph Database hoặc Redis.
- Bạn cần tìm kiếm sản phẩm? Hãy đẩy vào Elasticsearch.
Mỗi Read Model lúc này giống như một "View" đã được tính toán sẵn (Pre-computed view). Khi người dùng cần xem, chúng ta chỉ việc trả về kết quả O(1) mà không cần JOIN, không cần tính toán logic nghiệp vụ đắt đỏ.
6. Sự đánh đổi và "Cái giá" của sự tự do
Đừng để vẻ hào nhoáng của Performance đánh lừa. CQRS là một trong những kiến trúc "đắt đỏ" nhất về mặt vận hành. Với tư cách là người chịu trách nhiệm về hệ thống, bạn phải hiểu rõ cái giá phải trả:
Eventual Consistency - Khi "Sự thật" không đến ngay lập tức
Đây là thách thức lớn nhất. Trong CQRS (đặc biệt là bản bất đồng bộ), dữ liệu ở Read Model sẽ luôn trễ hơn Write Model một khoảng thời gian (Latency). User vừa bấm "Cập nhật tên", nhưng khi quay lại Profile vẫn thấy tên cũ.
Đây không phải là bug, đây là một đặc tính của hệ thống phân tán. Bạn phải giải quyết nó bằng hai cách:
- UX Trick: Sử dụng Optimistic UI (cập nhật giao diện ngay lập tức ở phía Client) để "đánh lừa" cảm giác của người dùng trong khi chờ đợi dữ liệu thực sự được sync.
- Technical Pattern: Sử dụng cơ chế "Read-your-own-writes" (buộc đọc từ Write Model trong một khoảng thời gian ngắn sau khi Update) – nhưng hãy cẩn thận, nó có thể làm hỏng mục tiêu tách biệt của CQRS.
Operational Complexity - Gánh nặng vận hành
Thay vì quản lý 1 Database, giờ đây bạn quản lý: 1 Write DB, 1 Message Broker (Kafka/RabbitMQ), N Read DBs, và N cụm Projectors. Nếu một Projector bị treo, dữ liệu hiển thị cho người dùng sẽ bị "stale" (cũ). Việc giám sát độ trễ (Lag monitoring) giữa các model trở thành nhiệm vụ sống còn.
Thử tưởng tượng kịch bản Read Model của bạn bị hỏng hoàn toàn dữ liệu. Bạn phải "Re-sync" lại từ đầu từ 10 tỷ Event trong Write Model. Bạn đã có Playbook cho việc này chưa? Mất bao lâu để hệ thống phục hồi? Đó là những câu hỏi phân cấp Senior.
Rủi ro Over-engineering
CQRS là một "khẩu súng đại bác". Đừng dùng nó để bắn một con chim sẻ. Nếu hệ thống của bạn vẫn đang chạy ổn định với vài câu SQL Indexing, đừng dại dột đưa CQRS vào chỉ vì nó "ngầu". Chi phí bảo trì sự phức tạp này thường cao hơn nhiều so với lợi ích mang lại cho những hệ thống nhỏ.
7. Các trường hợp thất bại (Failure Cases) - Bài học từ "Xương máu"
Tôi đã từng chứng kiến nhiều đội ngũ khốn đốn vì áp dụng CQRS một cách mù quáng:
- **Case 1: Read Model "Tham Lam" (The God Read Model).**Một team cố gắng tạo ra một Read Model "vạn năng" chứa tất cả thông tin để phục vụ cho mọi trang web, app và báo cáo. Kết quả là Read Model này trở nên khổng lồ, chậm chạp và khó thay đổi y hệt cái Database ban đầu mà họ muốn thoát ra. CQRS chỉ hiệu quả khi mỗi Read Model được thiết kế "Tailor-made" – chỉ chứa đúng và đủ dữ liệu cho một Use Case cụ thể.
- **Case 2: Ám ảnh Debugging trong mê cung.**Một khách hàng báo lỗi: "Tôi đã đổi địa chỉ nhưng đơn hàng vẫn giao về chỗ cũ". Trong hệ thống truyền thống, bạn chỉ cần kiểm tra 1 bảng. Trong CQRS, hành trình tìm lỗi là một cơn ác mộng: Kiểm tra Write DB (đúng) -> Kiểm tra Outbox Table (đúng) -> Kiểm tra Kafka (đúng) -> Kiểm tra Projector logic (lỗi ở đây!). Nếu không có một hệ thống Observability (Tracing, Logging) cực tốt, team của bạn sẽ kiệt sức trước khi tìm ra nguyên nhân.
- **Case 3: Bỏ qua Business Context.**Áp dụng CQRS cho hệ thống tài chính nơi người dùng yêu cầu tính nhất quán tức thời (Strong Consistency) cho số dư ví. Khi User nạp tiền và thấy số dư chưa tăng ngay, họ sẽ gọi điện mắng chửi bộ phận hỗ trợ, gây ra rủi ro về niềm tin và vận hành khủng khiếp.
8. Khi nào nên dùng và Khi nào nên tránh? (Decision Framework)
Kiến trúc tốt không phải là kiến trúc hiện đại nhất, mà là kiến trúc phù hợp nhất với bối cảnh kinh doanh.
Bạn THỰC SỰ cần CQRS khi:
- Workload Divergence: Khi tỉ lệ Đọc/Ghi chênh lệch khủng khiếp (ví dụ: 1000/1) và các câu query đọc đang làm tê liệt khả năng ghi.
- Complex Search/Filtering: Khi nhu cầu tìm kiếm vượt xa khả năng của SQL (cần Elasticsearch).
- Scalability Independence: Bạn muốn scale cụm Đọc lên 100 node nhưng chỉ muốn giữ cụm Ghi ở 2 node để tiết kiệm chi phí bản quyền DB.
- Team Autonomy: Khi các team khác nhau cần những góc nhìn dữ liệu khác nhau từ cùng một nguồn sự thật.
Hãy TUYỆT ĐỐI tránh CQRS khi:
- Hệ thống chủ yếu là CRUD đơn giản (tạo sao, hiện vậy).
- Đội ngũ kỹ thuật còn mỏng và chưa có kinh nghiệm về Message Queue/Distributed Systems.
- Business yêu cầu Strong Consistency trên toàn bộ các mặt trận hiển thị.
Nếu bạn thấy hệ thống bắt đầu chậm nhưng chưa đến mức phải dùng "vũ khí hạng nặng" như CQRS, hãy cân nhắc Read/Write Splitting (tách bản Read-replica). Đây là một giải pháp trung gian, ít đau đớn hơn mà chúng ta sẽ mổ xẻ ở tập sau.
9. Kết luận và Những bài học then chốt
CQRS không đơn thuần là một kỹ thuật tách DB, nó là một triết lý về việc quản lý sự phức tạp thông qua việc tách biệt trách nhiệm. Kết thúc bài viết này, tôi muốn bạn ghi nhớ 3 điểm cốt lõi:
- Mô hình dữ liệu duy nhất là một sự thỏa hiệp: Đừng cố gắng tìm kiếm một Schema hoàn hảo cho cả thế giới. Hãy chấp nhận sự phân cực của workload để thiết kế những model chuyên biệt.
- Dư thừa dữ liệu là sức mạnh, không phải lỗi: Trong kỷ nguyên Cloud và Storage giá rẻ, việc nhân bản dữ liệu để đổi lấy Performance và Scalability là một cuộc trao đổi cực kỳ hời, nếu bạn biết cách kiểm soát nó.
- Kiến trúc là nghệ thuật quản lý lời hứa: Một Senior Engineer giỏi là người biết khi nào hứa "dữ liệu sẽ khớp ngay lập tức" và khi nào hứa "dữ liệu sẽ khớp sau vài giây" để hệ thống đạt được hiệu suất tối ưu nhất.
Kiến trúc bền vững không được xây dựng từ những công nghệ đắt tiền nhất, mà từ sự thấu hiểu sâu sắc về dòng chảy của dữ liệu và sự dũng cảm khi đối mặt với những đánh đổi.
10. Mở đầu cho hành trình tiếp theo
CQRS là một đỉnh cao của thiết kế hệ thống, nhưng cái giá về độ phức tạp vận hành đôi khi là quá lớn cho nhiều doanh nghiệp. Vậy nếu bạn đang ở giữa ranh giới: Hệ thống đã bắt đầu chậm, nhưng bạn chưa sẵn sàng cho một cuộc đại phẫu theo kiểu CQRS, liệu có con đường nào nhẹ nhàng hơn không?
Câu trả lời sẽ có trong Episode 29: Read/Write Splitting - Tăng Tốc Database Khi Chưa Cần Đến CQRS. Chúng ta sẽ cùng xem cách tận dụng sức mạnh của các bản Replica để giảm tải cho hệ thống mà không làm tăng vọt độ phức tạp của Codebase.
🚀 Tiếp tục hành trình cùng TechCraft
Bài viết này là một phần trong hành trình khám phá Backend Engineering, System Design và Production Systems tại TechCraft.
Nếu bạn muốn học theo một lộ trình rõ ràng hơn, TechCraft đang xây dựng Dev Insider như nơi tập trung các series chuyên sâu hơn về:
- Backend Internals
- Database Internals
- Transaction & Consistency
- Distributed Systems
- Production System Design
- AI-Proof Engineer
🚀 Dev Insider
https://www.patreon.com/techcraft_official/posts/vi-sao-dev-ra-161163881?collection=2220113
📘 Facebook
https://www.facebook.com/techcraft.official
🎥 YouTube
https://www.youtube.com/@techcraft.official
🎵 TikTok
https://www.tiktok.com/@techcraft.official
Hiểu hệ thống. Không chỉ framework.
All rights reserved