•9 phút đọc

Thiết Kế Hệ Thống: Xây Dựng một Bộ Giới Hạn Tốc Độ Phân Tán (Distributed Rate Limiter)

Thiết Kế Hệ Thống: Xây Dựng một Bộ Giới Hạn Tốc Độ Phân Tán (Distributed Rate Limiter)

Giới thiệu

Audio Briefing
0:00 / --:--

Bất kỳ API nào cung cấp ra công chúng đều cần có cơ chế giới hạn tốc độ (rate limiting). Nếu không có nó, hệ thống của bạn sẽ dễ bị tấn công brute-force, DDoS, và các vấn đề "hàng xóm ồn ào" (noisy neighbor) khi một client hoạt động quá tích cực làm suy giảm trải nghiệm của tất cả những người khác.

Thiết kế một bộ giới hạn tốc độ cho một máy chủ đơn lẻ tương đối đơn giản. Nhưng thiết kế một hệ thống như vậy cho kiến trúc microservices phân tán toàn cầu, xử lý hàng triệu yêu cầu mỗi giây, lại là một thách thức kinh điển trong thiết kế hệ thống. Trong bài viết này, chúng ta sẽ phân tích chi tiết về kiến trúc và các thuật toán cần thiết.

1. Lựa chọn Thuật toán Phù hợp

Trái tim của một bộ giới hạn tốc độ là thuật toán mà nó sử dụng để đếm các yêu cầu.

Token Bucket

  • Cách hoạt động: Tưởng tượng một cái xô chứa các token. Token được thêm vào với một tốc độ cố định. Mỗi yêu cầu sẽ tiêu tốn một token. Nếu xô rỗng, yêu cầu sẽ bị từ chối.
  • Ưu điểm: Hiệu quả về bộ nhớ, cho phép các đợt truy cập tăng đột biến trong thời gian ngắn (burst traffic).
  • Nhược điểm: Khó tinh chỉnh giữa kích thước burst và tốc độ duy trì.

Fixed Window Counter (Bộ đếm cửa sổ cố định)

  • Cách hoạt động: Chia thời gian thành các cửa sổ cố định (ví dụ: từ 12:00 đến 12:01). Tăng một bộ đếm cho mỗi yêu cầu. Reset bộ đếm vào đầu cửa sổ tiếp theo.
  • Ưu điểm: Rất dễ triển khai.
  • Nhược điểm: Gây ra tình trạng lưu lượng truy cập tăng vọt ở các cạnh của cửa sổ. Một client có thể gửi toàn bộ hạn ngạch của mình vào lúc 12:00:59 và gửi lại vào lúc 12:01:01, thực chất đã tăng gấp đôi tốc độ cho phép trong một khoảng thời gian ngắn.

Sliding Window Log (Nhật ký cửa sổ trượt)

  • Cách hoạt động: Lưu lại nhật ký timestamp của mỗi yêu cầu từ một người dùng. Từ chối yêu cầu nếu kích thước nhật ký vượt quá giới hạn trong khung thời gian.
  • Ưu điểm: Độ chính xác rất cao.
  • Nhược điểm: Tốn nhiều bộ nhớ và tài nguyên tính toán để duy trì và cắt tỉa nhật ký. Không khả thi ở quy mô lớn.

Sliding Window Counter (Bộ đếm cửa sổ trượt - Lựa chọn tối ưu)

  • Cách hoạt động: Một phương pháp lai. Nó theo dõi bộ đếm của cửa sổ cố định trước đó và cửa sổ hiện tại, rồi tính toán một giá trị trung bình có trọng số dựa trên thời gian hiện tại chồng lấn lên cửa sổ.
  • Ưu điểm: Làm mượt các đợt tăng vọt ở cạnh cửa sổ, rất hiệu quả về bộ nhớ, độ chính xác cao.
Advertisement

2. Kiến trúc cho Hệ thống Phân tán

Trong một hệ thống đa máy chủ, việc duy trì trạng thái trở thành thách thức chính. Nếu một bộ cân bằng tải (load balancer) định tuyến yêu cầu đầu tiên của Người dùng A đến Máy chủ 1 và yêu cầu thứ hai đến Máy chủ 2, làm thế nào chúng có thể chia sẻ bộ đếm giới hạn tốc độ?

Lựa chọn A: Sticky Sessions (Phiên dính)

Định tuyến tất cả lưu lượng của một người dùng cụ thể đến cùng một máy chủ. Máy chủ đó sẽ giữ trạng thái giới hạn tốc độ trong bộ nhớ cục bộ.

  • Kết luận: Ý tưởng tồi. Nó phân phối tải không đồng đều và không có khả năng chịu lỗi tốt khi một máy chủ bị sập.

Lựa chọn B: Kho dữ liệu tập trung (Redis)

Tất cả các máy chủ đọc và ghi vào một cụm Redis tập trung. Redis đủ nhanh (vì là in-memory) để đáp ứng các yêu cầu về độ trễ.

  • Kết luận: Đây là phương pháp tiêu chuẩn. Tuy nhiên, nó tạo ra độ trễ mạng và các tình huống tranh chấp (race conditions).

3. Xử lý Race Conditions trong Redis

Nếu hai yêu cầu từ cùng một người dùng đồng thời đến hai máy chủ API khác nhau, cả hai có thể cùng đọc bộ đếm là 4, cùng tăng lên 5, và cùng cho phép yêu cầu đi qua, qua mặt giới hạn đã đặt ra.

Giải pháp: Lua Scripts

Redis cho phép bạn thực thi các script Lua một cách nguyên tử (atomically). Bạn có thể viết một script để lấy bộ đếm, kiểm tra giới hạn, tăng giá trị, và cập nhật TTL trong một thao tác nguyên tử duy nhất.

lua
-- Script Lua đơn giản cho Token Bucket
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = tonumber(redis.call('get', key) or "0")

if current + 1 > limit then
    return 0 -- Bị từ chối
else
    redis.call('INCRBY', key, 1)
    redis.call('EXPIRE', key, 60)
    return 1 -- Được phép
end

4. Tối ưu Hiệu năng: Cache Cục bộ + Đồng bộ Bất đồng bộ (Async Sync)

Đối với các hệ thống có lưu lượng cực cao, nơi mà ngay cả độ trễ của Redis cũng không thể chấp nhận được, bạn có thể sử dụng một phương pháp lai.

  • Các máy chủ API giữ một bộ giới hạn tốc độ cục bộ, trong bộ nhớ.
  • Chúng đồng bộ các bộ đếm cục bộ của mình lên cụm Redis trung tâm một cách bất đồng bộ sau mỗi vài giây.
  • Đánh đổi: Điều này đánh đổi độ chính xác nghiêm ngặt để lấy hiệu năng. Một client có thể vượt quá giới hạn của họ một chút giữa các cửa sổ đồng bộ, nhưng hệ thống vẫn duy trì tính sẵn sàng cao và tốc độ nhanh.

Kết luận

Xây dựng một bộ giới hạn tốc độ phân tán đòi hỏi phải cân bằng giữa việc thực thi nghiêm ngặt và độ trễ của hệ thống. Đối với hầu hết các API hiện đại, việc kết hợp thuật toán Sliding Window được triển khai thông qua các script Lua nguyên tử trên Redis cung cấp sự cân bằng tốt nhất về độ chính xác, hiệu năng và khả năng mở rộng.

Bạn cũng có thể thích

Các câu hỏi thường gặp

Fixed Window Counter cho phép các đợt truy cập tăng đột biến ở các cạnh ranh giới: một người dùng có thể sử dụng hết hạn ngạch của mình trong giây cuối cùng của cửa sổ A và ngay lập tức gửi một lượng yêu cầu tương tự trong giây đầu tiên của cửa sổ B. Sliding Window Counter tính toán một giá trị trung bình có trọng số giữa cửa sổ trước và cửa sổ hiện tại, làm mượt các đợt tăng đột biến ở ranh giới với chi phí bộ nhớ tối thiểu (hai key số nguyên cho mỗi client).
Một lệnh GET theo sau bởi một lệnh INCR tiêu chuẩn tạo ra một race condition, nơi các máy chủ worker đồng thời đọc cùng một giá trị bộ đếm trước khi tăng, cho phép client vượt quá giới hạn. Việc thực thi một script Lua chạy một cách nguyên tử trên engine đơn luồng của Redis, đảm bảo rằng việc xác minh ngưỡng, tăng giá trị và cập nhật TTL diễn ra như một hoạt động không thể phân chia.
Các kiến trúc có tính sẵn sàng cao triển khai các bộ ngắt mạch (circuit breakers) với các chế độ fail-open (cho phép khi lỗi) hoặc fail-closed (chặn khi lỗi) có thể cấu hình. Các API đọc/truy vấn tiêu chuẩn thường fail-open khi Redis hết thời gian chờ (>5ms) để duy trì thời gian hoạt động cho người dùng, trong khi các endpoint quan trọng về bảo mật (như xác minh mật khẩu đăng nhập, xác minh MFA, và xử lý thẻ tín dụng) sẽ fail-closed để chặn các cuộc tấn công brute-force.
Việc đồng bộ hóa mỗi lượt truy cập API qua các instance Redis đa vùng gây ra độ trễ mạng xuyên vùng không thể chấp nhận được, thường là hơn 100ms. Các kiến trúc hàng đầu phân chia hạn ngạch toàn cầu cho từng vùng (ví dụ: 40% cho US-East, 30% cho EU-Central, 30% cho AP-Southeast) và đồng bộ hóa delta của bộ đếm một cách bất đồng bộ trong các lô nền (background batches) bằng cách sử dụng các message broker.
Trả về mã HTTP 429 Too Many Requests cùng với các header rate limit tiêu chuẩn: RateLimit-Limit (tổng hạn ngạch được phép trong cửa sổ), RateLimit-Remaining (số token còn lại trong cửa sổ hiện tại), RateLimit-Reset (số giây cho đến khi hạn ngạch được reset), và Retry-After (thời gian trì hoãn tối thiểu tính bằng giây trước khi client nên thử lại).
Chia sẻ bài viết:

Đăng ký nhận bản tin

Nhận các bài viết công nghệ và kiến trúc hệ thống mới nhất trực tiếp qua email.

Tiện ích lập trình viên

Công cụ lập trình trực tiếp trên trình duyệt

Làm sạch log AI CLI, tạo biểu thức cron, giải mã JWT và tính quyền chmod offline.

Khám phá công cụ
Advertisement
Dev Kits & DataToolsTagsRSSPrivacy PolicyTerms