NHT

Redis Lua Script & SETNX: Giải Pháp Rate Limiting & Quota Alerting Hiệu Năng Cao Cho API

Tìm hiểu cách Gogoduk xây dựng hệ thống Rate Limiting và Quota Alerting cho API bằng Redis và Go. Hướng dẫn sử dụng Lua Script đảm bảo tính nguyên tử, chống rò rỉ RAM và dùng SETNX chống trùng lặp thông báo.

Nguyen Hoang TuanNguyen Hoang Tuan8 thg 6, 20269 phút đọc

Để bảo vệ hệ thống API trước các cuộc tấn công từ chối dịch vụ (DDoS) và quản lý chi phí vận hành (đặc biệt khi API tích hợp các dịch vụ tính phí của bên thứ ba như Google Maps API), việc xây dựng một hệ thống kiểm soát tần suất truy cập là vô cùng quan trọng.

Trong bài viết này, chúng ta sẽ khám phá cách Gogoduk—một nền tảng bản đồ Việt Nam cho lập trình viên—thiết kế và tối ưu hóa hệ thống kiểm soát lưu lượng API (Rate Limiting) và cảnh báo vượt hạn mức (Quota Alerting) thời gian thực bằng GoRedis. Chúng ta sẽ đi sâu vào cách giải quyết vấn đề tranh chấp dữ liệu (race conditions), chống rò rỉ bộ nhớ bằng Redis Lua Script và kỹ thuật loại bỏ thông báo trùng lặp bằng SETNX.


Tại sao cần kiểm soát API: Rate Limit vs Daily Quota

Một hệ thống quản lý API hoàn chỉnh cần kiểm soát lưu lượng ở hai cấp độ khác nhau:

  1. Rate Limiting (Giới hạn tần suất): Giới hạn số lượng request trong thời gian ngắn (ví dụ: tối đa 60 request/phút) để ngăn chặn bot spam và chống quá tải hệ thống.
  2. Daily Quota (Hạn ngạch ngày): Giới hạn tổng số lượng request trong một ngày (ví dụ: 5,000 request/ngày) dựa trên gói dịch vụ đã mua của từng người dùng.

Tại Gogoduk, khi người dùng gọi API, hệ thống cần kiểm tra cả hai giới hạn này chỉ trong vài mili giây trước khi cho phép xử lý tiếp. Vì thế, Redis—với lợi thế cơ sở dữ liệu in-memory tốc độ cực cao—là lựa chọn tối ưu nhất để lưu trữ số lượng request.


Hiểm họa rò rỉ bộ nhớ khi sử dụng lệnh Redis tuần tự

Cách đơn giản nhất để triển khai bộ đếm giới hạn (ví dụ: 60 request/phút) trong code backend là gọi các lệnh Redis tuần tự:

// LƯU Ý: Đây là cách làm lỗi, dễ gây rò rỉ bộ nhớ!
count, err := rdb.Incr(ctx, minKey).Result()
if count == 1 {
    rdb.Expire(ctx, minKey, 60 * time.Second)
}

Tại sao đoạn code trên lại nguy hiểm?

Đoạn code trên không đảm bảo tính nguyên tử (atomicity). Quá trình gồm hai bước gửi lệnh mạng riêng biệt từ ứng dụng đến Redis.

Điều gì xảy ra nếu ngay sau lệnh Incr thành công, server ứng dụng đột ngột mất điện, bị crash hoặc kết nối mạng bị gián đoạn trước khi lệnh Expire được gửi đi? Key minKey lúc này sẽ tồn tại vĩnh viễn trong RAM của Redis mà không có thời gian hết hạn (TTL).

Khi hệ thống có hàng triệu địa chỉ IP hoặc người dùng truy cập mỗi ngày, số lượng key "mồ côi" không có TTL này tích tụ lại sẽ gây ra hiện tượng rò rỉ bộ nhớ (memory leak) nghiêm trọng, dẫn đến việc Redis server cạn kiệt RAM và bị crash hệ thống.


Giải pháp: Nguyên tử hóa (Atomicity) bằng Redis Lua Script

Để giải quyết triệt để rủi ro trên, chúng ta cần gộp hai thao tác INCREXPIRE thành một giao dịch nguyên tử duy nhất. Redis hỗ trợ thực thi các đoạn mã Lua Script trực tiếp trên server một cách đơn luồng, đảm bảo không có lệnh nào khác có thể chen vào giữa.

Dưới đây là Lua Script được sử dụng trong middleware giới hạn tần suất của Gogoduk:

local current = redis.call("INCR", KEYS[1])
if current == 1 then
  redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return current

Cơ chế hoạt động:

  1. Ứng dụng gửi đoạn mã Lua cùng tham số minKey (KEYS[1]) và 60 (ARGV[1] - số giây hết hạn) lên Redis.
  2. Redis chạy lệnh INCR. Nếu số đếm trả về là 1 (nghĩa là key vừa được tạo mới cho chu kỳ phút đó), nó sẽ thực thi ngay lệnh EXPIRE.
  3. Toàn bộ quá trình diễn ra cô lập trên Redis server. Kể cả khi ứng dụng bị sập ngay sau đó, key vẫn chắc chắn đã được đặt TTL và sẽ tự động bị xóa sau 60 giây.
  4. Tối ưu mạng: Thay vì mất 2 vòng kết nối mạng (Round Trip Time - RTT) để chạy 2 lệnh riêng biệt, giờ đây ta chỉ tốn đúng 1 RTT.

Trong mã nguồn Go, chúng ta khai báo và gọi script này một cách dễ dàng:

var incrScript = redis.NewScript(`
local current = redis.call("INCR", KEYS[1])
if current == 1 then
  redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return current
`)

// Thực thi script trong Middleware
minCount, err := incrScript.Run(ctx, rdb, []string{minKey}, 60).Int64()

Thách thức của Quota Alerting: Tránh trùng lặp thông báo

Bên cạnh việc chặn request khi vượt hạn mức, hệ thống Gogoduk cần gửi cảnh báo (email hoặc Slack webhook) cho người dùng khi họ sử dụng hết 80% hoặc 100% hạn ngạch ngày để họ kịp thời nâng cấp hoặc nạp thêm tiền.

Bài toán Concurrency Spikes (Bùng nổ truy cập đồng thời)

Giả sử người dùng có giới hạn 10,000 request/ngày. Tại thời điểm request thứ 10,000 được gửi đến, khách hàng cũng đang gửi đồng thời 100 request khác trong cùng một giây.

Nếu không có cơ chế kiểm soát, cả 100 request này sẽ đồng loạt kiểm tra và phát hiện ra số lượng request vượt mốc 10,000, từ đó kích hoạt 100 email cảnh báo gửi đi cùng một lúc. Điều này không chỉ gây lãng phí tài nguyên hệ thống mà còn tạo ra trải nghiệm cực kỳ tồi tệ cho người dùng (hòm thư bị spam).


Sử dụng SETNX và Asynchronous Workers để chống trùng lặp

Để đảm bảo cảnh báo chỉ được gửi đúng 1 lần duy nhất cho mỗi mốc phần trăm (80%, 100%) của từng user trong ngày, Gogoduk áp dụng cơ chế khóa phân tán siêu nhẹ thông qua lệnh SETNX (Set if Not Exists) của Redis.

Ý tưởng là tạo ra một "lá cờ" (flag key) đại diện cho trạng thái cảnh báo của ngày hôm đó. Key này được thiết kế theo cấu trúc: quota_alert:<userID>:<percent>:<date_string>.

Dưới đây là logic xử lý chi tiết trong mã nguồn Go của Gogoduk:

func maybeFireQuotaAlert(ctx context.Context, rdb *redis.Client, userID string, percent int, used, dailyLimit int64, now time.Time) {
    dayStamp := now.UTC().Format("2006-01-02")
    flagKey := fmt.Sprintf("quota_alert:%s:%d:%s", userID, percent, dayStamp)
    
    // Đặt cờ với TTL 30 giờ để đảm bảo an toàn múi giờ
    ok, err := rdb.SetNX(ctx, flagKey, "1", 30 * time.Hour).Result()
    if err != nil || !ok {
        // Nếu SETNX trả về false, nghĩa là cờ đã được dựng (cảnh báo đã được kích hoạt hôm nay)
        // Chúng ta âm thầm bỏ qua để tránh gửi trùng lặp
        return
    }

    // Nếu SETNX trả về true, tiến hành gửi cảnh báo bất đồng bộ bằng Goroutine nền
    go postQuotaAlert(userID, percent, used, dailyLimit)
}

Tại sao mô hình này hiệu quả?

  1. Tính nguyên tử của SETNX: Chỉ có duy nhất 1 request trong số 100 request đồng thời thực hiện SETNX thành công (nhận giá trị true từ Redis). Tất cả các request đến sau đều nhận giá trị false và kết thúc ngay lập tức.
  2. Xử lý bất đồng bộ (Asynchronous): Việc gửi thông báo qua email hoặc API bên ngoài thường mất từ 1 đến 3 giây do độ trễ mạng. Bằng cách chạy tiến trình gửi trong một Goroutine nền độc lập (go postQuotaAlert), luồng xử lý API chính của người dùng không bị tắc nghẽn và phản hồi kết quả về client ngay lập tức.
  3. Tự động dọn dẹp: Key flag được thiết lập TTL là 30 giờ. Sau khi hết ngày, key tự động biến mất và sẵn sàng cho chu kỳ cảnh báo của ngày hôm sau, tránh hoàn toàn việc phình to bộ nhớ của Redis.

Tổng kết bài học thiết kế

Khi thiết kế và tối ưu hóa hệ thống giới hạn lưu lượng bằng Redis, hãy luôn ghi nhớ 3 nguyên tắc cốt lõi:

  1. Luôn hướng tới tính nguyên tử (Atomicity): Sử dụng Lua Script để gộp các nhóm lệnh đọc-ghi có liên quan (như kiểm tra số đếm và thiết lập thời gian hết hạn) thành một thao tác duy nhất, loại bỏ nguy cơ rò rỉ RAM do mất kết nối mạng.
  2. Sử dụng SETNX để kiểm soát tác vụ idempotent: SETNX là một công cụ tuyệt vời và siêu nhẹ để làm khóa phân tán, ngăn ngừa việc thực thi lặp đi lặp lại các tác vụ nặng (như gửi mail, ghi log hay gọi webhook) dưới áp lực concurrency cao.
  3. Không chặn luồng chính (Non-blocking): Bất kỳ tác vụ phụ nào (như gửi cảnh báo) đều phải được đẩy xuống chạy bất đồng bộ dưới nền để giữ cho thời gian phản hồi (latency) của API chính luôn ở mức tối ưu.

Nếu bạn đang tìm cách cải thiện trải nghiệm người dùng trên các giao diện tìm kiếm, đừng quên tham khảo thêm bài viết Tối ưu hóa Autocomplete Địa Chỉ Việt Nam cho UX Thanh Toán để xây dựng các luồng tương tác mượt mà nhất.

Hệ thống của bạn đang gặp vấn đề hiệu năng hay mở rộng tải?

Tôi chuyên xây dựng hạ tầng bản đồ độ trễ thấp, streaming pipeline thời gian thực (Kafka, ClickHouse) và các hệ thống backend tối ưu. Hãy cùng hợp tác để nâng cấp sản phẩm của bạn.

Hợp tác ngay

Tác giả

Nguyen Hoang Tuan

Nguyen Hoang Tuan

Full-stack developer focused on practical backend architecture, web performance, and production delivery.

Bài viết liên quan