Kiến trúc phần mềm: Monolith, Microservices và cái bẫy Distributed Monolith

Kiến trúc phần mềm: Monolith, Microservices và cái bẫy Distributed Monolith

Table of Contents

Nhiều lập trình viên Backend trẻ thường có xu hướng xem các mô hình kiến trúc phần mềm như một tôn giáo hoặc thước đo trình độ. Microservices thường được tôn sùng như đỉnh cao công nghệ, Monolith bị gán nhãn lạc hậu, còn Distributed Monolith - trạng thái tệ hại nhất - lại thường bị nhầm lẫn với vi dịch vụ thực thụ.

Thiết kế hệ thống không phải là nghệ thuật trưng bày sự phức tạp. Đó là một chuỗi các quyết định đánh đổi (trade-offs). Mỗi ranh giới mạng (network boundary) được dựng lên, mỗi database được tách ra đều đi kèm với một hóa đơn thanh toán đắt đỏ về cả chi phí hạ tầng, độ phức tạp vận hành và tốc độ phát triển tính năng (feature velocity).

Bài viết này mổ xẻ bản chất kỹ thuật của ba mô hình kiến trúc dưới lăng kính thực chiến của một Backend Engineer, đồng thời phân tích những ngộ nhận kinh điển cùng các định luật khoa học máy tính chi phối trực tiếp đến sự thành bại của chúng.


1. Monolithic Architecture (Kiến trúc đơn khối)

Monolith không phải là “code rác” (spaghetti code). Đó là mô hình phân phối phần mềm trong đó toàn bộ ứng dụng được đóng gói và chạy dưới dạng một tiến trình duy nhất (single process) trên cùng một không gian địa chỉ bộ nhớ.

Bản chất kỹ thuật

graph TB
  subgraph "Physical Server (Single Process Boundary)"
    subgraph "Monolithic Application"
      Order[Order Module]
      Payment[Payment Module]
      Inventory[Inventory Module]
    end
    DB[("`Single Database
- Shared Schema
- ACID Transaction`")]
  end

  User([User]) -->|HTTP/HTTPS| Order
  Order -->|"`In-Memory Call
- 0ms Latency
- Local Interface`"| Payment
  Order -->|"In-Memory Call"| Inventory
  Order -.->|"ACID Transaction"| DB
  Payment -.->|"ACID Transaction"| DB
  Inventory -.->|"ACID Transaction"| DB

  style DB fill:#1e1e2e,stroke:#a6e3a1,stroke-width:2px
  • In-memory function calls: Các module nghiệp vụ giao tiếp trực tiếp qua việc gọi hàm (method invocation). Giao tiếp này diễn ra trong chu kỳ CPU cực ngắn, có độ trễ bằng 0 (zero network latency) và loại bỏ hoàn toàn các rủi ro mất kết nối mạng.
  • ACID Transactions tại database: Do sử dụng chung một cơ sở dữ liệu duy nhất, việc đảm bảo tính nhất quán dữ liệu (data consistency) cực kỳ đơn giản. Các tác vụ ghi dữ liệu phức tạp trên nhiều bảng (ví dụ: tạo đơn hàng và trừ số dư ví) được bảo vệ bằng cơ chế Transaction cục bộ của hệ quản trị cơ sở dữ liệu (RDBMS). Nếu một bước lỗi, toàn bộ tác vụ được rollback tự động.

Ưu điểm thực chiến

  • Feature Velocity (Tốc độ phát triển): Khi cần thay đổi cấu trúc dữ liệu hoặc thay đổi chữ ký hàm (method signature) ảnh hưởng đến nhiều module, các IDE có thể thực hiện refactor an toàn trên toàn bộ codebase trong vài giây.
  • Vận hành tối giản (Low Ops): Chỉ cần một file thực thi duy nhất (như binary của Go/Rust) hoặc một thư mục source code (Python/Node.js). Quy trình deploy gói gọn trong việc đẩy file lên VPS hoặc chạy một container Docker đơn giản. Không cần API Gateway, Service Mesh hay các hệ thống quản lý cấu hình phức tạp.
  • Tối ưu hóa phần cứng: Tận dụng tối đa bộ nhớ đệm (CPU cache) và không gian địa chỉ dùng chung, tránh lãng phí RAM cho nhiều tiến trình ảo hóa trùng lặp.

Nhược điểm kỹ thuật

  • Phát triển song song (Sự nghẽn cổ chai của con người): Khi đội ngũ vượt quá 30 - 40 lập trình viên làm việc trên cùng một codebase, các xung đột mã nguồn (merge conflicts) và sự chồng chéo trong quy trình kiểm thử (CI/CD pipelines) sẽ xuất hiện liên tục. Đây là lúc Conway’s Law bắt đầu phát huy tác dụng.
  • Scale không đồng đều: Nếu chỉ có module xử lý hình ảnh cần nhiều bộ nhớ (RAM), còn module thanh toán chỉ cần CPU, bạn vẫn phải scale toàn bộ tiến trình Monolith lên các máy chủ lớn hơn, gây lãng phí tài nguyên.
  • Single Point of Failure (Sập dây chuyền): Một lỗi rò rỉ bộ nhớ (memory leak) hoặc lỗi nghiêm trọng không được bắt (unhandled panic) tại một tính năng phụ cũng có thể kéo sập toàn bộ tiến trình ứng dụng, khiến toàn bộ hệ thống ngừng hoạt động.

2. Microservices Architecture (Kiến trúc vi dịch vụ)

Microservices là kiến trúc phân tán phân chia hệ thống thành các dịch vụ nhỏ chạy độc lập trên các tiến trình riêng biệt và giao tiếp với nhau thông qua mạng (network protocols) như gRPC, HTTP REST hoặc Message Queue (Kafka, RabbitMQ).

Bản chất kỹ thuật

graph TB
  User([User]) -->|HTTP| GW[API Gateway]

  subgraph "Network Boundary (gRPC / REST / Event-Driven)"
    subgraph "Order Service (Team A)"
      Order[Order Service]
      OrderDB[("`Order DB
Isolated Schema`")]
    end

    subgraph "Payment Service (Team B)"
      Payment[Payment Service]
      PaymentDB[("`Payment DB
Isolated Schema`")]
    end

    subgraph "Inventory Service (Team C)"
      Inventory[Inventory Service]
      InventoryDB[("`Inventory DB
Isolated Schema`")]
    end

    MessageBus[["`Message Broker
- Kafka / RabbitMQ
- Eventual Consistency`"]]
  end

  GW -->|Route| Order
  GW -->|Route| Payment
  
  Order -->|"`gRPC Call
- Latency > 5ms
- Timeout/Retry`"| Payment
  Order -->|"Publish Event"| MessageBus
  MessageBus -->|"Subscribe"| Inventory

  Order -.-> OrderDB
  Payment -.-> PaymentDB
  Inventory -.-> InventoryDB

  style GW fill:#1e1e2e,stroke:#cba6f7,stroke-width:2px
  style MessageBus fill:#1e1e2e,stroke:#f9e2af,stroke-width:2px
  • Database per Service: Đây là điều kiện tiên quyết để đạt được sự độc lập. Mỗi dịch vụ sở hữu hoàn toàn cơ sở dữ liệu của riêng nó. Không dịch vụ nào được phép đọc/ghi trực tiếp vào cơ sở dữ liệu của dịch vụ khác. Mọi tương tác dữ liệu bắt buộc phải đi qua lớp API do dịch vụ đó cung cấp.
  • Network Boundary & Fallacies of Distributed Computing: Giao tiếp giữa các module không còn là gọi hàm trong bộ nhớ nữa mà là các network hop qua mạng. Bạn bắt buộc phải đối mặt với 8 ngộ nhận của tính toán phân tán (Fallacies of Distributed Computing), đặc biệt là: mạng luôn tin cậy, độ trễ bằng 0 và băng thông vô hạn.

Ưu điểm thực chiến

  • Phù hợp với Conway’s Law: Định luật Conway chỉ ra rằng cấu trúc hệ thống mô phỏng cấu trúc giao tiếp của tổ chức. Microservices cho phép phân chia tổ chức thành các team nhỏ (Two-Pizza Teams). Team A phát triển và tự deploy Order Service mà không cần xin phép hay làm ảnh hưởng tới công việc của Team B làm Payment Service.
  • Cô lập lỗi (Fault Isolation): Một lỗi panic gây sập Order Service sẽ không trực tiếp kéo sập Payment Service. Hệ thống vẫn có thể phục vụ một phần tính năng nếu được thiết kế cơ chế fallback hợp lý.
  • Đa dạng công nghệ: Cho phép lựa chọn ngôn ngữ và framework tối ưu nhất cho từng bài toán cụ thể (ví dụ: dùng Python cho xử lý AI/Data Science, dùng Go cho API Gateway hiệu năng cao).

Nhược điểm kỹ thuật (Cái giá phải trả)

  • Distributed Transactions (Giao dịch phân tán): Rất khó duy trì ACID Transaction. Khi một hành vi nghiệp vụ cần ghi dữ liệu trên nhiều dịch vụ (ví dụ: trừ tiền ví -> giảm tồn kho -> tạo hóa đơn), bạn phải thiết kế cơ chế bù trừ dữ liệu như Saga Pattern (Choreography hoặc Orchestration) kết hợp với Outbox Pattern để đảm bảo tính nhất quán cuối cùng (eventual consistency).
  • Network Overhead: Việc giao tiếp qua mạng đòi hỏi các tác vụ tuần tự hóa (serialization - chuyển struct thành JSON/Protobuf) và giải tuần tự hóa (deserialization) liên tục, cộng thêm độ trễ mạng vật lý. Một chuỗi gọi dịch vụ (call chain) dài sẽ làm tăng đáng kể thời gian phản hồi (response time) tổng thể.
  • Gánh nặng vận hành: Đòi hỏi hạ tầng quản lý container phức tạp (Kubernetes), hệ thống thu thập log tập trung (Loki/Elasticsearch), cơ chế giám sát phân vết (Distributed Tracing - OpenTelemetry, Jaeger) và các cơ chế phục hồi lỗi mạng (Circuit Breaker, Rate Limiting).
  • Gánh nặng nhận thức (Cognitive Load): Lập trình viên không còn chỉ tập trung viết logic nghiệp vụ. Họ phải liên tục xử lý các kịch bản lỗi mạng: “Nếu gọi Payment API bị timeout thì Order Service nên làm gì? Retry bao nhiêu lần? Làm sao để tránh gọi trùng lặp (Idempotency)?”

3. Distributed Monolith (Đơn khối phân tán) - Tấn bi kịch tồi tệ nhất

Distributed Monolith là một phản mẫu kiến trúc (architectural anti-pattern) cực kỳ phổ biến. Đây là trạng thái khi một hệ thống được chia tách thành nhiều dịch vụ vật lý độc lập chạy trên mạng, nhưng logic nghiệp vụ và dữ liệu của chúng lại liên kết quá chặt chẽ (tight coupling).

Bản chất kỹ thuật

graph TB
  User([User]) -->|HTTP| GW[API Gateway]

  subgraph "Tightly Coupled Services"
    Order[Order Service]
    Payment[Payment Service]
    Inventory[Inventory Service]
  end

  DB[("`Shared Database
- Single Point of Failure
- Coupled Schema
- Lock Contention`")]

  GW --> Order
  GW --> Payment

  Order -->|HTTP Call| Payment
  Payment -->|HTTP Call| Inventory

  Order -.->|"Direct Query / JOIN"| DB
  Payment -.->|"Direct Query / JOIN"| DB
  Inventory -.->|"Direct Query / JOIN"| DB

  style DB fill:#f38ba8,stroke:#f38ba8,stroke-width:2px

Một hệ thống rơi vào bẫy Distributed Monolith khi có các dấu hiệu sau:

  1. Dùng chung một Database vật lý (Shared Database): Các dịch vụ khác nhau truy cập và chỉnh sửa trực tiếp vào cùng một schema cơ sở dữ liệu.
  2. Khóa chặt quy trình deploy (Coordinated Deployments): Không thể deploy Order Service nếu không deploy cùng lúc Payment Service vì logic của hai bên phụ thuộc lẫn nhau.
  3. Tách service nhưng vẫn dùng chung thư viện chứa business logic: Mọi thay đổi logic ở thư viện dùng chung đều buộc toàn bộ các dịch vụ phải cập nhật và deploy lại.

Tại sao đây là một thảm họa?

Distributed Monolith gom góp tất cả các nhược điểm của Microservices lẫn Monolith nhưng loại bỏ hoàn toàn ưu điểm của cả hai:

  • Mất quyền sở hữu Schema: Khi Team A thay đổi cấu trúc bảng orders, họ có thể vô tình làm sập Payment Service của Team B do Team B cũng viết câu lệnh SELECT trực tiếp vào bảng đó. Kết quả là không ai dám tối ưu hóa hay thay đổi cấu trúc database vì sợ ảnh hưởng đến các dịch vụ khác.
  • Overhead mạng vô nghĩa: Các dịch vụ giao tiếp với nhau qua HTTP/REST, chịu độ trễ mạng vật lý, nhưng cuối cùng lại cùng ghi dữ liệu vào một ổ đĩa cứng của một database duy nhất. Điều này tạo ra nút thắt cổ chai (bottleneck) tại cơ sở dữ liệu trong khi tăng thêm điểm lỗi tiềm ẩn (point of failure) trên đường truyền mạng.
  • Deploy cồng kềnh: Mất hoàn toàn khả năng deploy độc lập. Mỗi lần cập nhật tính năng, đội ngũ phát triển phải lập kế hoạch bảo trì phức tạp để deploy đồng bộ một nhóm dịch vụ cùng lúc.

4. Phân tích so sánh dưới góc nhìn học thuật

Để đưa ra lựa chọn kiến trúc chính xác, chúng ta cần đánh giá dựa trên các lý thuyết cốt lõi của khoa học máy tính:

Tiêu chíMonolithicMicroservicesDistributed Monolith
Không gian địa chỉDùng chung (In-memory)Phân tách (Over the network)Phân tách (Over the network)
Sở hữu dữ liệuShared DatabaseDatabase per Service (Encapsulated)Shared Database (Coupled)
Tính nhất quán dữ liệuACID TransactionEventual Consistency (Saga / Outbox)ACID (nhưng bị ảnh hưởng bởi lock và mạng)
Độ tin cậy truyền thôngTuyệt đối (Gọi hàm trực tiếp)Rủi ro mạng (Fallacies of Distributed Computing)Rủi ro mạng + Nghẽn cổ chai database
Độ trễ giao dịchCực thấp (Microseconds)Cao (Milliseconds, do serialization + network)Cao (Milliseconds)
Khả năng deploy độc lậpKhông (Deploy toàn bộ khối)Có (Độc lập tuyệt đối)Không (Coordinated Deployments)
Gần với Conway’s LawPhù hợp với team nhỏ (<15 devs)Phù hợp với tổ chức lớn (>30 devs)Phản ánh sự đứt gãy trong giao tiếp tổ chức

Định lý CAP và sự đánh đổi dữ liệu

Theo định lý CAP, trong một hệ thống phân tán, khi xảy ra phân mảnh mạng (Network Partition - P), bạn chỉ có thể chọn một trong hai: Tính nhất quán dữ liệu tức thời (Consistency - C) hoặc Tính sẵn sàng của hệ thống (Availability - A).

  • Trong Monolithic, vì tất cả tiến trình diễn ra cục bộ trên một máy chủ và giao tiếp database qua kết nối trực tiếp, rủi ro phân mảnh mạng giữa các module bằng 0. Hệ thống dễ dàng đạt được cả CA.
  • Trong Microservices, rủi ro phân mảnh mạng (P) là bắt buộc. Nếu Order Service không thể kết nối với Payment Service do sự cố mạng, bạn phải chọn:
    • Từ chối giao dịch (Ưu tiên Consistency - C): Trả về lỗi cho khách hàng, hệ thống bị giảm tính sẵn sàng.
    • Chấp nhận giao dịch và xử lý sau (Ưu tiên Availability - A): Hệ thống ghi nhận đơn hàng ở trạng thái chờ xử lý, sau đó dùng Saga Pattern để bù trừ dữ liệu khi mạng phục hồi.

Rất nhiều đội ngũ chuyển sang Microservices nhưng vẫn kỳ vọng có được tính nhất quán tức thời (Strong Consistency) trên toàn bộ hệ thống. Đây là một sự mâu thuẫn trực tiếp với định lý CAP, dẫn đến các thiết kế chắp vá và cuối cùng rơi vào bẫy Distributed Monolith.


5. Lời khuyên thực chiến: Modular Monolith làm bước đệm

Một sai lầm phổ biến của các startup là cố gắng xây dựng hệ thống dạng Microservices ngay từ ngày đầu tiên khi ranh giới nghiệp vụ (domain boundaries) chưa rõ ràng. Khi sản phẩm liên tục thay đổi mô hình kinh doanh (pivot), việc định hình sai cấu trúc dịch vụ sẽ dẫn đến việc liên tục tái cấu trúc cơ sở dữ liệu phân tán - một công việc vô cùng tốn kém.

Lựa chọn khôn ngoan nhất cho hầu hết dự án là khởi đầu với Modular Monolith (Monolith mô-đun hóa).

graph TB
  subgraph "Modular Monolith (Logical Separation)"
    subgraph "Order Module"
      OrderLogic[Order Logic]
      OrderTables[(Order Tables)]
    end

    subgraph "Payment Module"
      PaymentLogic[Payment Logic]
      PaymentTables[(Payment Tables)]
    end
  end

  OrderLogic -->|"`In-Memory Call
- Interface Only`"| PaymentLogic
  OrderLogic -.->|"No Direct Query/JOIN"| PaymentTables
  PaymentLogic -.->|"No Direct Query/JOIN"| OrderTables

  style OrderTables fill:#1e1e2e,stroke:#a6e3a1,stroke-width:1px
  style PaymentTables fill:#1e1e2e,stroke:#a6e3a1,stroke-width:1px

Nguyên tắc thiết kế Modular Monolith

  1. Phân tách ranh giới logic rõ ràng: Chia cấu trúc codebase theo các domain nghiệp vụ riêng biệt. Trong Go, hãy tổ chức cấu trúc thư mục rõ ràng: /internal/order, /internal/payment, /internal/inventory. Các module này giao tiếp với nhau chỉ thông qua các Interface được định nghĩa rõ ràng, cấm tuyệt đối việc gọi trực tiếp struct nội bộ của module khác.
  2. Cách ly Schema ở mức logic: Ngay cả khi dùng chung một database vật lý, hãy chia tách các bảng dữ liệu theo tiền tố (prefix) hoặc schema riêng (ví dụ: order_order_items, payment_transactions). Cấm tuyệt đối các câu lệnh JOIN chéo bảng giữa hai domain Order và Payment. Nếu Order Module cần thông tin thanh toán, bắt buộc phải gọi qua Interface của Payment Module để lấy dữ liệu.
  3. Sử dụng Event cục bộ: Thay vì gọi trực tiếp module khác, hãy sử dụng các event bus trong bộ nhớ (in-memory event bus) như channel của Go để phát đi các sự kiện nghiệp vụ (ví dụ: OrderCreatedEvent). Các module khác đăng ký lắng nghe event này để xử lý logic tương ứng. Điều này giúp giảm thiểu sự liên kết chặt chẽ (decoupling) giữa các module.

Khi nào thực sự cần chuyển dịch sang Microservices?

Bạn chỉ nên chịu đựng chi phí của Microservices khi và chỉ khi gặp các rào cản vật lý thực sự:

  1. Sự tắc nghẽn quy trình của con người: Khi số lượng lập trình viên tăng lên, việc xếp hàng deploy và giải quyết xung đột mã nguồn bắt đầu tiêu tốn nhiều thời gian hơn là viết code tính năng.
  2. Đặc thù tài nguyên phần cứng khác biệt: Dịch vụ xử lý ảnh cần sử dụng GPU hoặc nhiều bộ nhớ RAM, trong khi dịch vụ ghi nhận log cần thông lượng đĩa cứng cao.
  3. Yêu cầu cô lập lỗi nghiêm trọng: Dịch vụ xử lý thanh toán cần đạt độ ổn định 99.99% và không được phép bị ảnh hưởng bởi lỗi rò rỉ bộ nhớ từ tính năng gợi ý sản phẩm.

Kiến trúc phần mềm sinh ra để giải quyết các bài toán kỹ thuật đi kèm với mục tiêu kinh doanh của doanh nghiệp. Đừng biến hệ thống của bạn thành công cụ để đánh bóng CV hay thử nghiệm các trào lưu công nghệ thiếu thực tế. Hãy bắt đầu đơn giản bằng cách thiết kế ranh giới logic thật tốt, và hệ thống sẽ tự động chỉ đường cho bạn khi nó thực sự cần được chia tách vật lý.

Share :

Related Posts

Khi nào nên dùng cache (và khi nào không)

Khi nào nên dùng cache (và khi nào không)

Có một bug production mà đến giờ mình vẫn nhớ như in: user A hủy đơn hàng, nhưng app vẫn hiển thị “đang giao” trong 10 phút tiếp theo. Support nhận 30 ticket trong 1 buổi sáng. Nguyên nhân: cache TTL 10 phút, nhưng không ai invalidate khi order status thay đổi.

Read More
Agent = Model + Harness: Đừng mang động cơ F1 lắp vào xe bus không phanh

Agent = Model + Harness: Đừng mang động cơ F1 lắp vào xe bus không phanh

Hồi cuối năm ngoái, mình có tự chế một con bot chạy ngầm để tự động đọc menu của các đối tác quán ăn gửi về dưới dạng PDF/Excel, rồi parse qua struct Go để nạp vào DB của hệ thống Menu Service.

Read More