+9

🧩🧠 Bulkhead & Rate Limit: vì sao lỗi không được phép lan rộng? - Microservice Architecture P8

Bulkhead & Rate Limit: vì sao lỗi không được phép lan rộng?

Hãy tưởng tượng bạn đang vận hành một hệ thống thương mại điện tử lúc 9 giờ sáng một ngày làm việc bình thường. Đột nhiên, on-call alert nổ ra liên tục. Dashboard Grafana chuyển đỏ từ trên xuống dưới. Người dùng không thể đăng nhập, không thể thanh toán, toàn bộ luồng kinh doanh cốt lõi tê liệt hoàn toàn.

Bạn lần theo log, trace từng service một. Và rồi phát hiện ra thủ phạm không ai ngờ tới: Product Recommendation Service — cái service nhỏ bé, ngoại vi, chỉ có nhiệm vụ trả về danh sách sản phẩm gợi ý ở cuối trang — đang phản hồi cực kỳ chậm. Mỗi request đến nó mất 15-20 giây để timeout. Và vì API Gateway cấu hình timeout quá lớn, toàn bộ thread pool của gateway bị các request "chờ recommendation" chiếm hết sạch. Kết quả: request đăng nhập, request thanh toán, request xem đơn hàng — tất cả đều xếp hàng chờ và lần lượt bị timeout theo.

Một service ngoại vi đã kéo sập toàn bộ hệ thống. Đây không phải bug. Đây là lỗi thiết kế kiến trúc.


Niềm tin phổ biến: "Timeout lớn = an toàn hơn"

Khi một service phụ thuộc bắt đầu phản hồi chậm, phản ứng đầu tiên của nhiều kỹ sư là tăng timeout. Lý lẽ nghe rất hợp lý:

"Nếu tôi timeout quá nhanh thì sẽ trả về lỗi oan cho user. Để đủ thời gian cho service kia xử lý đi đã."

Và khi ai đó đề xuất auto-scaling, câu trả lời kinh điển cũng xuất hiện:

"Hệ thống đã có HPA rồi, scale tự động khi tải tăng, lo gì?"

Cả hai suy nghĩ này đều chứa một phần sự thật, nhưng đều bỏ qua một điều cốt lõi: Khả năng scale và timeout chỉ giải quyết vấn đề về lượng tải và thời gian chờ, không giải quyết được vấn đề cô lập lỗi.


Tại sao timeout lớn và auto-scaling đều không đủ?

Hãy nhìn vào cơ chế hoạt động thực tế của một thread pool trong API Gateway hoặc bất kỳ service nào xử lý request HTTP.

Request đến API Gateway
        │
        ▼
 ┌─────────────────────────────────────┐
 │          Thread Pool (50 threads)   │
 │  ┌──────┐ ┌──────┐ ┌──────┐ ...    │
 │  │ T-01 │ │ T-02 │ │ T-03 │        │
 │  │ [Rec]│ │ [Rec]│ │ [Rec]│        │  ← tất cả đang chờ
 │  └──────┘ └──────┘ └──────┘        │    Recommendation
 └─────────────────────────────────────┘       Service
        │                                       (chậm)
        ▼
 ┌───────────────────────────────┐
 │  Request Login/Payment đến   │
 │  → Không còn thread nào free │
 │  → Timeout / 503             │
 └───────────────────────────────┘

Khi Recommendation Service chậm và timeout được set ở mức 15 giây, mỗi thread xử lý request sẽ bị treo trong 15 giây trước khi giải phóng. Nếu trong 15 giây đó có 50 request recommendation đến, toàn bộ 50 thread bị chiếm. Những request quan trọng hơn — đăng nhập, thanh toán — không còn thread nào để xử lý.

Auto-scaling thêm pod cũng không giúp gì ở đây. Scaling mất 30-60 giây để hoạt động. Trong khoảng thời gian đó, hệ thống đã sập. Và ngay cả khi pod mới khởi động xong, nếu Recommendation Service vẫn chậm, thread pool của pod mới cũng sẽ bị lấp đầy trong vài giây tiếp theo.

Vấn đề gốc rễ không phải là "ít tài nguyên". Vấn đề là tài nguyên không được phân chia và bảo vệ theo từng luồng nghiệp vụ.


Góc nhìn kỹ sư: Hệ thống kiên cố không phải là hệ thống không bao giờ lỗi

Đây là sự thay đổi tư duy quan trọng nhất trong thiết kế hệ thống phân tán.

Trong môi trường microservices, lỗi là tất yếu. Network timeout, dependency chậm, database bị spike tải, third-party API phản hồi lỗi — đây là thực tế, không phải trường hợp ngoại lệ. Câu hỏi không phải là "Làm sao để không bao giờ có lỗi?", mà là "Khi lỗi xảy ra, làm sao để nó không lan rộng?"

Có ba pattern thiết kế cốt lõi để xây dựng khả năng tự vệ này:

  1. Bulkhead — cô lập tài nguyên theo từng dependency
  2. Rate Limiting — bảo vệ service khỏi bị áp đảo bởi lượng request
  3. Circuit Breaker — tự động ngắt kết nối với dependency đang lỗi

Chúng không phải ba giải pháp thay thế nhau, mà là ba lớp bảo vệ bổ sung cho nhau.


Bulkhead: Vách ngăn khoang tàu

Tên gọi này đến từ thiết kế của tàu thủy. Khi tàu bị thủng một khoang, vách ngăn (bulkhead) giữa các khoang ngăn nước tràn sang khoang kia. Tàu vẫn nổi được dù một khoang đã chết.

Trong phần mềm, Bulkhead Pattern thực hiện điều tương tự: phân chia thread pool, connection pool hoặc resource pool theo từng dependency.

Thay vì tất cả các loại request chia sẻ chung một thread pool, ta tạo các pool riêng biệt:

┌────────────────────────────────────────────────────────┐
│                    API Gateway                         │
│                                                        │
│  ┌────────────────┐  ┌──────────────┐  ┌───────────┐  │
│  │ Pool: Auth     │  │ Pool: Pay    │  │ Pool: Rec │  │
│  │ (20 threads)   │  │ (20 threads) │  │ (5 threads│  │
│  └───────┬────────┘  └──────┬───────┘  └─────┬─────┘  │
└──────────┼──────────────────┼────────────────┼─────────┘
           │                  │                │
           ▼                  ▼                ▼
     Auth Service       Payment Svc    Recommendation Svc
                                       (đang chậm ❌)

Khi Recommendation Service chậm, chỉ 5 thread của pool Recommendation bị lấp đầy. Pool Auth và Payment hoàn toàn không bị ảnh hưởng. Request đăng nhập và thanh toán tiếp tục xử lý bình thường.

Nguyên tắc cốt lõi: kích thước của pool tỉ lệ với mức độ quan trọng của dependency đó với business. Service recommendation là ngoại vi, ít quan trọng hơn — giới hạn pool nhỏ lại. Service xác thực và thanh toán là cốt lõi — cấp pool lớn hơn.

Bulkhead còn áp dụng ở nhiều tầng khác:

  • Connection pool của database client: mỗi service chỉ dùng số connection nhất định, không ôm hết connection slot của database
  • Semaphore: giới hạn số concurrent execution của một nhóm logic nghiệp vụ tốn kém
  • Dedicated worker thread: một nhóm thread riêng chỉ xử lý background job, không đụng vào thread xử lý request người dùng

Rate Limiting: Bảo vệ từ phía trên

Bulkhead bảo vệ service khỏi bị kéo sập từ phía downstream (dependency bị chậm). Rate Limiting bảo vệ service khỏi bị áp đảo từ phía upstream (quá nhiều request đến).

Có hai loại tình huống khiến Rate Limiting trở nên bắt buộc:

1. Traffic spike hợp lệ: Flash Sale bắt đầu, hàng trăm nghìn người đồng thời gọi API. Database không thể xử lý số lượng write đó. Không có Rate Limiting, hệ thống sẽ bị overload và sập theo dây chuyền.

2. Abuse / Bad actor: Bot gọi API liên tục để scrape dữ liệu, hoặc thậm chí là một lỗi vô tình trong code client gây ra infinite retry loop, tự tấn công chính mình.

Cơ chế Rate Limiting phổ biến nhất là Token BucketSliding Window:

Token Bucket:
  Mỗi giây, bucket được nạp N token.
  Mỗi request tiêu thụ 1 token.
  Nếu bucket hết token → request bị reject (HTTP 429).

  [████████░░] bucket 80/100 → request pass
  [░░░░░░░░░░] bucket 0/100  → request reject

Sliding Window:
  Đếm số request trong cửa sổ thời gian trượt X giây.
  Nếu vượt ngưỡng → throttle.

   t=0s      t=1s      t=2s      t=3s
    │         │         │         │
  [100rq]  [100rq]  [150rq]   [80rq]
              ←── window 2s ──→
              [100+150]=250 > limit(180) → throttle

Rate Limiting có thể được đặt ở nhiều lớp:

  • API Gateway: giới hạn theo IP, theo user token, theo API key — ngăn bad actor trước khi vào hệ thống
  • Service level: mỗi service tự bảo vệ mình trước khi vào business logic
  • Database level: giới hạn số connection, số query per second từ một client cụ thể

Một điểm quan trọng mà nhiều đội bỏ qua: Rate Limiting và auto-scaling không loại trừ nhau. Auto-scaling cần thời gian khởi động — 30 đến 60 giây, thậm chí lâu hơn. Trong khoảng thời gian đó, Rate Limiting là tuyến phòng thủ duy nhất ngăn hệ thống sụp đổ trước khi pod mới kịp sẵn sàng nhận traffic.


Circuit Breaker: Cầu dao ngắt mạch tự động

Khi Recommendation Service đang lỗi, tại sao ta vẫn tiếp tục gọi sang đó mỗi giây? Mỗi request chờ đủ timeout rồi mới biết là lỗi, trong khi thread đã bị giữ suốt thời gian đó. Đây là lãng phí tài nguyên có hệ thống.

Circuit Breaker giải quyết chính xác điều này: tự động phát hiện dependency đang lỗi và ngừng gọi sang đó tạm thời, để tài nguyên không bị lãng phí vào những request chắc chắn sẽ thất bại.

Cơ chế hoạt động có ba trạng thái:

         [Healthy traffic]
             │
             │ Error rate vượt threshold
             ▼
        ┌─────────┐
        │  OPEN   │ ← Reject ngay, không gọi downstream
        └────┬────┘   Fallback: trả cache cũ / empty
             │
             │ Sau cooldown period (ví dụ: 30s)
             ▼
       ┌──────────┐
       │ HALF-OPEN│ ← Cho phép một số request thử nghiệm
       └────┬─────┘
            │
    ┌───────┴────────┐
    │                │
  Thành công      Thất bại
    │                │
    ▼                ▼
 [CLOSED]          [OPEN]
 (bình thường)    (cách ly tiếp)

Closed — hoạt động bình thường. Request đi qua, error rate được theo dõi liên tục. Circuit breaker ở chế độ này như một người quan sát im lặng.

Open — circuit breaker "bật". Toàn bộ request bị reject ngay lập tức mà không cần gọi sang dependency. Không có timeout, không lãng phí thread. Service gọi đến phải có fallback logic: trả về danh sách sản phẩm gợi ý rỗng, trả về dữ liệu cache cũ, hoặc trả về một set sản phẩm được cấu hình sẵn.

Half-Open — sau một khoảng thời gian nguội (cooldown), circuit breaker thử cho một số lượng nhỏ request đi qua để kiểm tra. Nếu thành công, chuyển về Closed. Nếu vẫn lỗi, quay lại Open. Đây là cơ chế tự phục hồi quan trọng — circuit breaker không cần người vận hành can thiệp thủ công.

Điều cần lưu ý: Circuit Breaker không sửa lỗi của dependency. Nó chỉ cô lập tác động của lỗi đó. Dịch vụ phụ thuộc vẫn cần được sửa. Nhưng trong khi chờ đội kia xử lý xong, hệ thống của bạn vẫn tiếp tục phục vụ người dùng ở mức degraded — tốt hơn nhiều so với sập hoàn toàn.


Ba lớp bảo vệ phối hợp với nhau

Nhìn toàn cảnh, ba pattern này không thay thế nhau mà hoạt động ở ba tầng bảo vệ khác nhau:

Inbound traffic (từ client / internet)
      │
      ▼
┌──────────────────────────────────────┐
│           RATE LIMITING              │  ← Lớp 1: Kiểm soát đầu vào
│   (API Gateway / Service layer)      │    Chặn spam, bot, traffic bất thường
└──────────────────────────────────────┘
      │
      ▼
┌──────────────────────────────────────┐
│             BULKHEAD                 │  ← Lớp 2: Cô lập tài nguyên
│  (Separate thread/connection pools)  │    Ngăn một dependency kéo sập cái khác
└──────────────────────────────────────┘
      │
      ▼
┌──────────────────────────────────────┐
│          CIRCUIT BREAKER             │  ← Lớp 3: Ngắt kết nối lỗi
│   (Per-dependency trip mechanism)    │    Không lãng phí thread vào dep đang chết
└──────────────────────────────────────┘
      │
      ▼
  Downstream Services

Rate Limiting kiểm soát đầu vào. Bulkhead cô lập tài nguyên theo luồng nghiệp vụ. Circuit Breaker phát hiện và cách ly lỗi runtime. Ba lớp này bổ sung cho nhau theo chiều dọc, từ tầng network xuống tầng execution.


Trade-offs thực tế

Không pattern nào là miễn phí. Trước khi quyết định áp dụng, cần nhìn rõ cái giá phải trả:

Bulkhead:

  • Tăng tổng lượng tài nguyên cần cấp phát — mỗi pool đều giữ một phần memory và thread, ngay cả khi không có request
  • Cần tune kích thước pool cẩn thận. Pool quá nhỏ thì reject request oan, pool quá lớn thì lãng phí
  • Thêm độ phức tạp trong cấu hình và monitoring — phải giám sát utilization của từng pool riêng

Rate Limiting:

  • Phải xác định ngưỡng hợp lý cho từng endpoint. Flash Sale cần ngưỡng khác với API thông thường
  • Distributed Rate Limiting trên nhiều pod, nhiều region đòi hỏi shared state (Redis), thêm một hop mạng mỗi request
  • Cần thiết kế cơ chế retry và exponential backoff phía client để tránh thundering herd khi limit được reset

Circuit Breaker:

  • Phải định nghĩa chính xác thế nào là "thất bại". HTTP 404 là response hợp lệ, không phải lỗi hạ tầng. HTTP 503 mới là circuit-trip candidate
  • Cooldown period cần cân nhắc kỹ. Quá ngắn thì circuit chưa kịp phục hồi đã bị trip lại. Quá dài thì dependency đã recover nhưng hệ thống vẫn reject
  • Fallback logic phải được thiết kế trước khi deploy. Nếu không có fallback tốt, trải nghiệm user vẫn tệ dù hệ thống không sập

Kịch bản đổ vỡ: Cascading Failure

Khi cả ba pattern trên đều vắng mặt, điều kinh khủng nhất trong distributed systems xảy ra: Cascading Failure — lỗi dây chuyền kéo sập toàn bộ cluster chỉ trong vài phút.

Hãy tưởng tượng một topology phụ thuộc đơn giản:

User → API Gateway → Order Service → Inventory Service → Product Service
                   ↘
                  Payment Service

Product Service bắt đầu chậm do database bị spike. Inventory Service gọi Product Service — thread pool của Inventory bắt đầu lấp đầy. Order Service gọi Inventory — thread pool của Order bắt đầu lấp đầy. API Gateway gọi Order và Payment — thread pool Gateway lấp đầy. Toàn bộ hệ thống sập trong vài phút chỉ vì một service lá bị chậm.

Đây không phải kịch bản lý thuyết. Đây là pattern xảy ra ở nhiều hệ thống thực tế khi đội ngũ nghĩ rằng "cứ scale ngang ra là được". Scale ngang giải quyết vấn đề capacity, không giải quyết được cascading failure.

Điều làm cascading failure nguy hiểm hơn bất kỳ loại lỗi nào khác: nó thường xảy ra vào đúng lúc hệ thống chịu tải cao nhất — vì traffic cao mới làm lộ ra dependency yếu. Và lúc đó, mọi phản ứng thủ công đều chậm hơn tốc độ lan rộng của lỗi.


Khi nào ba pattern này chưa đủ?

Cần thẳng thắn về giới hạn: Bulkhead, Rate Limiting, Circuit Breaker giải quyết vấn đề runtime protection — bảo vệ hệ thống trong lúc chạy. Chúng không thay thế được:

  • Proper timeout design: Mỗi outbound call cần có timeout hợp lý ở cả connect timeout và read timeout. Không phải "đặt lớn cho an toàn" vì timeout lớn chính là thứ giữ thread lại lâu khi dependency chậm.
  • Retry với exponential backoff: Retry ngay lập tức sau lỗi chỉ làm tình hình tệ hơn khi dependency đang quá tải. Retry cần backoff để giảm áp lực.
  • Observability: Nếu bạn không đo được error rate, latency percentile (p99), thread pool utilization theo từng dependency — bạn không có cơ sở để cấu hình và tune các tham số trên một cách chính xác.

Một Circuit Breaker cấu hình sai threshold, hoặc một Bulkhead có pool size không được monitor, còn nguy hiểm hơn không có gì — vì nó tạo ra cảm giác an toàn giả trong khi hệ thống vẫn dễ vỡ.


Nhìn lại câu chuyện đầu bài

Quay lại hệ thống thương mại điện tử lúc 9 giờ sáng. Nếu ba pattern này được thiết kế đúng từ đầu:

  • Bulkhead: Recommendation Service chỉ được cấp 5 thread trong pool của Gateway. Khi nó chậm, 5 thread đó bị lấp đầy, nhưng 45 thread còn lại vẫn phục vụ đăng nhập và thanh toán bình thường.
  • Circuit Breaker: Sau khi error rate của Recommendation vượt ngưỡng 50%, circuit mở. Gateway ngừng gọi sang đó, trả về danh sách gợi ý rỗng hoặc dữ liệu cache cũ. User thấy trang thiếu gợi ý sản phẩm — không đẹp, nhưng vẫn mua hàng được.
  • Rate Limiting: Nếu nguyên nhân gốc rễ là bot spam request lên Recommendation API, Rate Limiting đã chặn từ tầng Gateway trước khi Recommendation bị overload và ảnh hưởng đến hệ thống.

Kết quả: một partial service degradation thay vì một full system outage. Doanh thu không mất hoàn toàn. Người dùng vẫn checkout được. Đội kỹ sư có thời gian xử lý bình tĩnh thay vì ở chế độ panic.


Hệ thống kiên cố không được xây dựng bằng cách hy vọng không có gì xảy ra. Nó được xây dựng bằng cách thiết kế cho thất bại — dự đoán lỗi sẽ xảy ra và quyết định trước rằng lỗi đó sẽ dừng lại ở đâu, không được phép lan rộng hơn.

Khi các vách ngăn bảo vệ hệ thống đã được dựng lên, một câu hỏi mới xuất hiện: Làm sao bạn nhìn thấy những gì đang xảy ra bên trong hàng chục service đang chạy song song? Làm sao bạn biết circuit breaker đã trip bao nhiêu lần hôm nay? Thread pool nào đang gần đầy? Latency p99 của dependency nào đang tăng bất thường? Tập tiếp theo sẽ giới thiệu ba trụ cột của Observability — công cụ không thể thiếu để vận hành một hệ thống phân tán một cách thực sự tự tin.


Góc nhìn thêm: Failure isolation là cách hệ thống nói rằng "một lỗi không được quyền có phạm vi vô hạn"

Bulkhead và rate limiting nghe như các kỹ thuật bảo vệ nhỏ lẻ. Nhưng về bản chất, chúng là tuyên bố kiến trúc rất mạnh: mỗi thành phần chỉ được phép gây hại trong một bán kính nhất định.

Nếu recommendation chết, checkout không được chết theo. Nếu một tenant spam, toàn bộ API không được nghẹt theo. Nếu một consumer bị lag, cả cụm xử lý nền không được kẹt theo.

Đó là tư duy nền phía sau failure isolation. Team nào không thiết kế theo cách này thường đang để các tài nguyên cốt lõi bị chia sẻ quá rộng, và khi sự cố đến thì blast radius lớn hơn rất nhiều so với giá trị của thành phần gây lỗi.

Nhìn thêm: Chỉ số nào cho thấy hệ thống đang thiếu isolation?

Một số tín hiệu rất thực tế:

  • p99 của endpoint quan trọng tăng theo endpoint ngoại vi
  • CPU, thread pool hoặc DB connection bị chiếm bởi workload không critical
  • cùng một traffic spike làm đỏ đồng loạt nhiều dashboard vốn không nên liên quan
  • mỗi sự cố nhỏ đều leo thang thành incident toàn hệ thống

Khi thấy các dấu hiệu này, câu hỏi nên hỏi không chỉ là "thành phần nào chậm", mà là "vì sao thành phần đó được quyền làm người khác đau theo".

🧭 Học theo lộ trình

Nếu bạn muốn tiếp tục hành trình nâng cấp tư duy thiết kế hệ thống và làm chủ các kỹ thuật resiliency nâng cao, hãy tiếp tục với:

🚀 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 trade-off. Thiết kế tốt hơn.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí