Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Design Patterns và Scalability

Architecture Decision-Making Bài 3/60

Mở đầu — vì sao bài này quan trọng

Ở hai bài trước, bạn đã học nền tảng thiết kế hệ thống và khung phân tích trade-off. Nhưng khi ngồi trước một bài toán thực tế — "hệ thống của chúng ta đang chậm, cần scale lên gấp mười lần trong sáu tháng" — bạn vẫn cần một bộ công cụ tư duy cụ thể để đi từ vấn đề đến giải pháp. Đó chính là design patterns (các mẫu thiết kế) và cách chúng phục vụ mục tiêu scalability (khả năng mở rộng).

Hãy hình dung thế này: một kiến trúc sư giỏi không phát minh lại bánh xe mỗi lần gặp bài toán mới. Họ có trong đầu một "thư viện" các mẫu giải pháp đã được ngành công nghiệp kiểm chứng qua hàng chục năm. Khi gặp tình huống, họ nhận diện được "à, đây là bài toán mà pattern X giải quyết tốt", rồi cân nhắc cái giá phải trả. Người mới thì thường viết lại từ đầu, mắc lại những sai lầm mà cả ngành đã đi qua.

Bài này không dạy bạn cách code từng pattern chi tiết — đó là việc của các bài chuyên sâu phía sau về event-driven, CQRS, caching, hay microservices. Ở đây, bạn sẽ học cách nhìn nhận patterns như một ngôn ngữ chung của kiến trúc, phân biệt các nhóm pattern theo mục đích, và quan trọng nhất: hiểu mối quan hệ cốt lõi giữa việc chọn pattern và khả năng scale. Đây là "bản đồ" giúp bạn định vị mọi kiến thức chuyên sâu sau này.

Khái niệm cốt lõi

Design pattern là gì trong ngữ cảnh kiến trúc

Một design pattern là một giải pháp có thể tái sử dụng cho một vấn đề thường gặp trong một bối cảnh nhất định. Ba từ khóa cần nhớ: giải pháp tái sử dụng, vấn đề thường gặp, và bối cảnh. Từ cuối cùng quan trọng nhất — một pattern chỉ tốt trong đúng bối cảnh của nó. Áp dụng sai chỗ, nó biến thành gánh nặng.

Trong kiến trúc phần mềm, ta phân biệt hai tầng patterns:

  • Design patterns cấp code (như Factory, Strategy, Observer của nhóm Gang of Four): giải quyết vấn đề tổ chức lớp và đối tượng bên trong một ứng dụng.
  • Architectural patterns cấp hệ thống (như Layered, Event-Driven, Microservices, CQRS): giải quyết vấn đề cách các thành phần lớn của hệ thống giao tiếp và phối hợp với nhau.
Trong khóa học về ra quyết định kiến trúc này, ta quan tâm chủ yếu tầng thứ hai. Đó là những quyết định khó đảo ngược và ảnh hưởng đến toàn bộ hệ thống trong nhiều năm.

Ba nhóm architectural pattern cần định vị

Để không bị "lạc" giữa hàng chục pattern, hãy nhóm chúng theo mục đích:

Nhóm 1 — Patterns về cấu trúc và tách lớp (structure). Ví dụ: Layered Architecture (phân tầng), Hexagonal, Onion/Clean. Mục đích là quản lý sự phức tạp bằng cách tách trách nhiệm rõ ràng. Chúng không trực tiếp làm hệ thống scale, nhưng làm cho hệ thống dễ thay đổi — và một hệ thống dễ thay đổi thì mới scale được về mặt tổ chức con người.

Nhóm 2 — Patterns về giao tiếp và phối hợp (communication). Ví dụ: Event-Driven Architecture, Request-Response đồng bộ, Publish-Subscribe, Message Queue. Đây là nhóm ảnh hưởng trực tiếp nhất đến scalability. Cách các thành phần "nói chuyện" với nhau quyết định liệu bạn có thể tách chúng ra và scale độc lập hay không.

Nhóm 3 — Patterns về khả năng chịu tải và phân tán (scale & resilience). Ví dụ: Sharding, Caching, CQRS, Load Balancing, Replication. Đây là những pattern sinh ra để trực tiếp giải quyết bài toán tải lớn.

Ranh giới giữa ba nhóm không cứng nhắc — nhiều pattern nằm ở giao điểm. Nhưng có bản đồ này trong đầu, khi ai đó nói "chúng ta nên dùng event-driven", bạn biết ngay họ đang bàn về nhóm giao tiếp, và câu hỏi tiếp theo phải là "để giải quyết vấn đề phối hợp gì?".

Event-Driven Architecture — pattern giao tiếp then chốt cho scale

Vì đây là pattern xuất hiện trong ghi chú gốc của bài, hãy hiểu nó ở mức khái niệm (chi tiết sâu sẽ ở Bài 20-21).

Trong mô hình truyền thống, khi người dùng thực hiện một hành động, dịch vụ gọi trực tiếp các dịch vụ khác và chờ phản hồi:

User Action → Service chính → gọi Service A (chờ)
                            → gọi Service B (chờ)
                            → gọi Service C (chờ)
                            → trả kết quả

Vấn đề: Service chính bị "trói" vào tốc độ và tình trạng của A, B, C. Nếu C chậm, toàn bộ chuỗi chậm. Nếu C sập, cả yêu cầu thất bại.

Trong event-driven, hành động của người dùng phát ra một event (sự kiện) lên một event bus (kênh trung gian), rồi các dịch vụ quan tâm tự lắng nghe và xử lý:

User Action → phát Event → Event Bus →→ Service A (tự xử lý)
                                     →→ Service B (tự xử lý)
                                     →→ Service C (tự xử lý)

Điểm mấu chốt để scale: Service chính không cần biết A, B, C là ai, có bao nhiêu, đang bận hay không. Nó chỉ "công bố sự thật đã xảy ra" và tiếp tục. Đây gọi là loose coupling (ràng buộc lỏng). Loose coupling chính là điều kiện tiên quyết để scale — vì bạn chỉ có thể scale độc lập những thứ không phụ thuộc chặt vào nhau.

Mối quan hệ cốt lõi: pattern phục vụ scalability như thế nào

Đây là ý tưởng trung tâm của cả bài. Scalability không phải là một tính năng bạn "thêm vào" cuối cùng. Nó là hệ quả của các quyết định về pattern bạn đưa ra từ đầu. Cụ thể:

  • Pattern quyết định coupling (mức độ ràng buộc). Coupling càng lỏng, các phần càng scale độc lập được.
  • Pattern quyết định state (nơi lưu trạng thái). Component stateless (không giữ trạng thái) thì cứ nhân bản thêm là scale được; component stateful thì phức tạp hơn nhiều.
  • Pattern quyết định synchronization (điểm đồng bộ hóa). Mỗi điểm phải chờ đợi lẫn nhau là một điểm nghẽn (bottleneck) tiềm tàng khi tải tăng.
Nói cách khác: khi bạn chọn một pattern, bạn thực chất đang chọn đường mà hệ thống sẽ scale hoặc không scale được sau này.

Tình huống thực tế

Tình huống 1 — Sàn thương mại điện tử ngày sale lớn

Hãy tưởng tượng một sàn TMĐT tầm trung tại Việt Nam, gọi là "ShopViet", với khoảng 200.000 đơn hàng mỗi ngày trong ngày thường. Kiến trúc ban đầu của họ theo mẫu đồng bộ: khi khách bấm "Đặt hàng", hệ thống trong cùng một request sẽ trừ kho, tạo đơn, gọi cổng thanh toán, gửi email xác nhận, gửi thông báo cho nhà bán, và cập nhật điểm tích lũy.

Ngày 11/11, lượng đơn tăng vọt lên gấp 15 lần trong vài giờ cao điểm. Vấn đề lộ ra ngay: dịch vụ gửi email (dùng nhà cung cấp bên thứ ba) bị nghẽn và phản hồi chậm 8 giây mỗi lần. Vì email nằm trong chuỗi đồng bộ của quá trình đặt hàng, mỗi đơn hàng bị treo 8 giây. Người dùng thấy màn hình quay vòng, nhiều người bấm lại, tạo đơn trùng, và hệ thống sập.

Diễn giải: Bài toán không nằm ở việc email chậm — email chậm là chuyện bình thường. Bài toán nằm ở pattern giao tiếp đồng bộ đã trói một tác vụ không quan trọng (gửi email) vào đường đi chính (critical path) của việc đặt hàng.

Đội kỹ thuật chuyển sang mô hình event-driven cho các tác vụ phụ. Khi đặt hàng thành công, hệ thống chỉ làm những việc bắt buộc phải đồng bộ (trừ kho, tạo đơn, xác nhận thanh toán), rồi phát một event OrderPlaced. Các dịch vụ gửi email, thông báo nhà bán, tích điểm tự lắng nghe event này và xử lý sau đó vài giây. Kết quả: thời gian phản hồi của việc đặt hàng giảm từ trung bình 9 giây xuống dưới 400ms, và khi nhà cung cấp email chậm, nó không còn ảnh hưởng đến trải nghiệm mua hàng nữa.

Bài học: Hãy phân biệt tác vụ phải đồng bộ (khách cần biết kết quả ngay) và tác vụ có thể bất đồng bộ (có thể xử lý sau). Đưa tác vụ bất đồng bộ ra khỏi critical path bằng event-driven là một trong những đòn bẩy scale rẻ và hiệu quả nhất.

Tình huống 2 — Ứng dụng gọi xe và bài toán stateful

Một startup gọi xe khu vực Đông Nam Á — gọi là "GoRide" — có tính năng theo dõi vị trí tài xế theo thời gian thực. Ban đầu, họ lưu vị trí và trạng thái phiên kết nối của mỗi tài xế ngay trong bộ nhớ của server xử lý kết nối đó. Mọi thứ chạy mượt khi chỉ có một server.

Khi mở rộng ra ba thành phố lớn, họ cần chạy nhiều server để chịu tải. Nhưng vấn đề xuất hiện: nếu tài xế A đang kết nối với Server 1 (nơi lưu trạng thái của A trong RAM), mà request tiếp theo của A lại bị load balancer đẩy sang Server 2, thì Server 2 không biết gì về A cả. Họ buộc phải dùng "sticky session" (ghim mỗi tài xế vào đúng một server), nhưng điều này làm việc scale trở nên cứng nhắc: không thể phân bổ tải đều, và khi một server sập, toàn bộ tài xế trên đó mất trạng thái.

Diễn giải: Đây là hệ quả kinh điển của việc chọn pattern stateful mà không lường trước. Trạng thái nằm trong từng server làm cho các server không còn "thay thế cho nhau được" — mà scalability theo chiều ngang (thêm server) chỉ dễ dàng khi các server là stateless và có thể thay thế lẫn nhau.

Giải pháp: tách trạng thái ra khỏi server ứng dụng, đưa vào một tầng lưu trữ chia sẻ (một distributed cache tốc độ cao). Server ứng dụng trở thành stateless — bất kỳ server nào cũng phục vụ được bất kỳ tài xế nào, vì trạng thái nằm ở nơi dùng chung. Load balancer giờ đây phân bổ tự do, và thêm server chỉ là thao tác nhân bản.

Bài học: "Stateless ở tầng ứng dụng, stateful được cô lập ở tầng lưu trữ" là một nguyên tắc kiến trúc mạnh. Khi bạn thiết kế một component, hãy luôn tự hỏi: "Component này giữ trạng thái gì trong bộ nhớ? Nếu tôi nhân bản nó ra mười bản, chúng có mâu thuẫn nhau không?"

Tình huống 3 — Blog nhỏ áp dụng microservices quá sớm

Một nhóm ba lập trình viên khởi nghiệp làm nền tảng học trực tuyến. Đọc nhiều bài viết về "công ty lớn dùng microservices để scale", họ quyết định ngay từ đầu chia hệ thống thành 12 microservice riêng biệt: service người dùng, service khóa học, service thanh toán, service thông báo, service bình luận... mỗi cái một database, một pipeline triển khai riêng.

Sau bốn tháng, họ vẫn chỉ có khoảng 500 người dùng, nhưng đội ngũ kiệt sức. Mỗi tính năng nhỏ như "hiển thị tên giảng viên trong trang khóa học" cần gọi qua ba service, xử lý lỗi mạng giữa chúng, và đồng bộ dữ liệu qua nhiều database. Việc debug một lỗi đơn giản mất cả ngày vì phải lần theo request qua nhiều dịch vụ.

Diễn giải: Microservices là pattern giải quyết bài toán scale về mặt tổ chức (nhiều đội làm việc độc lập) và scale từng phần độc lập khi tải rất lớn và không đồng đều. Với một nhóm ba người và 500 người dùng, họ không có vấn đề đó — họ đang trả cái giá phức tạp của distributed system để giải quyết một bài toán không tồn tại.

Bài học: Pattern phức tạp không phải là pattern "cao cấp hơn". Áp dụng pattern scale khi chưa có bài toán scale là một dạng over-engineering tốn kém. Câu hỏi đúng luôn là: "Vấn đề thực tế mà pattern này giải quyết có đang tồn tại trong hệ thống của tôi không?" Một monolith rõ ràng, tách module tốt sẽ phục vụ họ tốt hơn nhiều ở giai đoạn này (các đánh đổi cụ thể sẽ được bàn kỹ ở các bài 16-18).

Hướng dẫn từng bước

Khi đứng trước quyết định chọn pattern cho một bài toán scalability, hãy đi theo quy trình sau:

Bước 1 — Định lượng bài toán trước khi chọn giải pháp. Đừng bắt đầu bằng "nên dùng pattern nào". Bắt đầu bằng con số: hiện tại tải là bao nhiêu (request/giây, dữ liệu/ngày)? Dự kiến trong 6-12 tháng tăng bao nhiêu lần? Tải phân bổ đều hay có đỉnh nhọn? Không có con số, mọi lựa chọn pattern chỉ là cảm tính.

Bước 2 — Xác định điểm nghẽn thật (bottleneck). Scale sai chỗ là lãng phí. Tìm ra thành phần nào sẽ "vỡ" trước khi tải tăng: database đọc? database ghi? một service gọi bên thứ ba? băng thông? Pattern bạn chọn phải nhắm đúng điểm nghẽn này.

Bước 3 — Phân loại tác vụ theo tính đồng bộ. Lập danh sách các tác vụ trong luồng chính và đánh dấu: cái nào phải đồng bộ (người dùng cần kết quả ngay), cái nào có thể bất đồng bộ. Mỗi tác vụ có thể bất đồng bộ là một ứng viên để đưa ra khỏi critical path bằng pattern giao tiếp phù hợp.

Bước 4 — Xác định trạng thái (state). Với mỗi component, hỏi: nó giữ trạng thái gì? Có thể tách trạng thái ra tầng lưu trữ chia sẻ để component trở thành stateless không? Stateless là chìa khóa để scale ngang dễ dàng.

Bước 5 — Chọn pattern tối thiểu đủ dùng. Trong các pattern giải quyết được bài toán, chọn cái đơn giản nhất. Độ phức tạp là chi phí thật, trả hàng ngày bằng thời gian vận hành và debug. Chỉ tăng độ phức tạp khi con số ở Bước 1 thực sự đòi hỏi.

Bước 6 — Ghi lại quyết định và đánh đổi. Mỗi lựa chọn pattern là một quyết định kiến trúc. Ghi lại: bài toán, phương án đã cân nhắc, phương án chọn, và cái giá phải trả. Đây chính là tinh thần ADR mà bạn sẽ học chi tiết ở Bài 4-5.

Lỗi thường gặp & mẹo

Lỗi 1 — Chọn pattern theo "trend" thay vì theo bài toán. Thấy công ty lớn dùng gì thì bắt chước. Nhưng công ty lớn giải quyết bài toán ở quy mô của họ, không phải của bạn. Mẹo: luôn hỏi "vấn đề cụ thể pattern này giải quyết là gì, và tôi có vấn đề đó chưa?".

Lỗi 2 — Nhầm lẫn giữa scale và tối ưu. Nhiều người vội vàng phân tán hệ thống trong khi một câu query database chưa được đánh index đang là thủ phạm thật. Mẹo: đo lường và tối ưu điểm nghẽn hiện tại trước; đôi khi một thay đổi nhỏ ở tầng dữ liệu giúp bạn hoãn được cả một dự án tái kiến trúc.

Lỗi 3 — Bỏ quên tính nhất quán khi chuyển sang bất đồng bộ. Event-driven mang lại scale, nhưng cũng mang theo "eventual consistency" — dữ liệu ở các nơi không đồng bộ tức thời. Ví dụ email xác nhận đến sau vài giây là chấp nhận được, nhưng số dư tài khoản hiển thị sai thì không. Mẹo: chỉ đưa sang bất đồng bộ những tác vụ mà độ trễ nhất quán là chấp nhận được về mặt nghiệp vụ.

Lỗi 4 — Tạo state ẩn trong component tưởng là stateless. Cache cục bộ trong RAM, biến đếm toàn cục, file tạm trên đĩa server — tất cả đều là trạng thái ẩn phá vỡ khả năng scale ngang. Mẹo: kiểm tra kỹ, "component này có ghi bất cứ thứ gì vào bộ nhớ hay đĩa cục bộ mà cần tồn tại giữa các request không?".

Lỗi 5 — Coi pattern là bất biến. Kiến trúc đúng cho 500 người dùng khác với 5 triệu người dùng. Mẹo: chọn pattern đúng cho quy mô hiện tại cộng một bậc, và thiết kế sao cho việc chuyển pattern sau này không phải viết lại toàn bộ.

Bài tập thực hành

Bài 1 — Phân loại critical path. Lấy một luồng nghiệp vụ bạn quen thuộc (ví dụ: đăng ký tài khoản, thanh toán, đăng bài). Liệt kê tất cả các tác vụ hệ thống thực hiện trong luồng đó. Với mỗi tác vụ, đánh dấu "phải đồng bộ" hay "có thể bất đồng bộ" và giải thích ngắn gọn lý do nghiệp vụ. Sau đó vẽ lại luồng sau khi đã đưa các tác vụ bất đồng bộ ra event bus.

Bài 2 — Săn trạng thái ẩn. Chọn một dịch vụ trong hệ thống bạn đang làm (hoặc một hệ thống giả định). Liệt kê mọi nơi nó có thể đang giữ trạng thái: biến trong bộ nhớ, cache cục bộ, file, session. Với mỗi trạng thái, đề xuất cách tách ra tầng chia sẻ để component trở thành stateless.

Bài 3 — Nhận diện over-engineering. Cho tình huống: một ứng dụng nội bộ dùng cho khoảng 300 nhân viên công ty, tải ổn định, ít khi có đỉnh. Một đồng nghiệp đề xuất chia thành 8 microservice với message broker riêng. Viết một đoạn phản biện 150-200 từ, nêu rõ pattern nào phù hợp hơn cho bối cảnh này và vì sao, dựa trên khung tư duy trong bài.

Bài 4 — Định lượng trước khi chọn. Chọn một hệ thống bạn hình dung sẽ phải scale gấp 10 lần trong năm tới. Viết ra các con số ở Bước 1 (tải hiện tại, dự kiến, hình dạng tải), xác định điểm nghẽn có khả năng vỡ trước nhất, và chỉ ra pattern nào bạn sẽ cân nhắc để nhắm vào đúng điểm nghẽn đó.

Tóm tắt

  • Design pattern là giải pháp tái sử dụng cho vấn đề thường gặp trong một bối cảnh nhất định — và bối cảnh là yếu tố quyết định pattern có tốt hay không.
  • Trong kiến trúc, hãy định vị patterns theo ba nhóm mục đích: cấu trúc/tách lớp, giao tiếp/phối hợp, và chịu tải/phân tán. Bản đồ này giúp bạn không lạc giữa hàng chục pattern.
  • Scalability không phải tính năng thêm vào cuối — nó là hệ quả của các quyết định pattern từ đầu, thông qua ba đòn bẩy: coupling (ràng buộc), state (trạng thái), và synchronization (điểm đồng bộ).
  • Event-Driven Architecture minh họa nguyên lý cốt lõi: loose coupling cho phép các thành phần scale độc lập; đưa tác vụ bất đồng bộ ra khỏi critical path là đòn bẩy scale rẻ và mạnh.
  • Stateless ở tầng ứng dụng, state cô lập ở tầng lưu trữ là nguyên tắc then chốt cho việc scale ngang.
  • Pattern phức tạp không phải pattern "cao cấp hơn". Luôn chọn pattern tối thiểu đủ dùng cho quy mô hiện tại, vì độ phức tạp là chi phí trả hàng ngày.
  • Quy trình ra quyết định: định lượng bài toán → tìm điểm nghẽn thật → phân loại đồng bộ/bất đồng bộ → xác định trạng thái → chọn pattern đơn giản nhất đủ dùng → ghi lại quyết định và đánh đổi.
Ở các bài tiếp theo, bạn sẽ đi sâu vào từng pattern cụ thể — nhưng hãy luôn mang theo bản đồ tư duy của bài này: mọi pattern đều là một câu trả lời cho một câu hỏi, và nhiệm vụ của kiến trúc sư là đảm bảo mình đang hỏi đúng câu hỏi trước khi tìm câu trả lời.