Rate Limit là gì?

Giải thích về Rate Limit Rate Limit (Giới hạn tốc độ) là một kỹ thuật nhằm hạn chế số lượng yêu cầu (request) mà một máy khách (client) có thể thực hiện trong một khoảng thời gian nhất định. Kỹ thuật này được sử dụng trong các máy chủ API, ứng dụng web, hệ thống bộ nhớ đệm... với các mục đích như: bảo vệ tài nguyên máy chủ, cung cấp dịch vụ công bằng và phòng chống các cuộc tấn công ác ý (DDoS).

Sự cần thiết của Rate Limit

  • Ngăn ngừa quá tải hệ thống: Phòng tránh tình trạng sập máy chủ do lượng yêu cầu tăng đột biến.
  • Duy trì chất lượng dịch vụ: Đảm bảo chất lượng phản hồi nhất quán cho tất cả người dùng thông qua việc phân bổ tài nguyên công bằng.
  • Tiết kiệm chi phí: Trong môi trường điện toán đám mây, việc giới hạn là cần thiết vì chi phí thường được tính dựa trên lưu lượng cuộc gọi (API calls).

Phương án xử lý phía Client

  • Khi nhận được phản hồi 429 Too Many Requests từ máy chủ, cần áp dụng cơ chế trễ tái thử (backoff).
  • Điều chỉnh thời điểm gửi lại yêu cầu dựa trên thông tin trong Header (Retry-After).
  • Tận dụng bộ nhớ đệm cục bộ (local cache) ở phía client để tối thiểu hóa các yêu cầu gửi đến máy chủ.

Các loại Rate Limit

1. Fixed Window

  • Đặc điểm: Giới hạn số lượng yêu cầu trong một đơn vị thời gian cố định (ví dụ: 1 phút).
  • Nhược điểm: Có thể xảy ra hiện tượng bùng nổ lưu lượng (traffic burst) tại thời điểm ranh giới giữa hai cửa sổ.
1.png
val WINDOW_SIZE_MS = 60000L
val windowKey = "rate:user::"
val count = redisTemplate.opsForValue().increment(windowKey)
redisTemplate.expire(windowKey, Duration.ofMillis(WINDOW_SIZE_MS))
if (count != null && count > limit) {
    throw RateLimitExceededException("Too many requests")
}

2. Sliding Window Log

  • Đặc điểm: Lưu trữ dấu thời gian (timestamp) vào Redis Sorted Set (ZSET) để kiểm tra số lượng yêu cầu trong phạm vi cửa sổ thời gian gần nhất.
  • Nhược điểm: Thiếu tính nguyên tử (atomicity) do phải kết hợp nhiều lệnh ZSET cùng lúc (trừ khi sử dụng Lua Script).
2.png
val WINDOW_SIZE_MS = 60000L
val now = System.currentTimeMillis()
val key = "rate:user:$userId"
val windowStart = now - WINDOW_SIZE_MS
redisTemplate.opsForZSet().add(key, now.toString(), now.toDouble())
redisTemplate.opsForZSet().removeRangeByScore(key, 0.0, windowStart.toDouble())
val count = redisTemplate.opsForZSet().size(key) ?: 0
if (count > limit) throw RateLimitExceededException()

3. Token Bucket

  • Đặc điểm: Tạo ra các thẻ bài (token) theo một chu kỳ nhất định và tiêu thụ chúng mỗi khi có yêu cầu (request) gửi đến.
  • Nhược điểm: Do logic cập nhật (nạp thẻ) và tiêu thụ thẻ tách biệt nhau nên có khả năng xảy ra tình trạng tranh chấp dữ liệu (race condition).
3.png
val now = System.currentTimeMillis()
val bucketKey = "bucket:user:$userId"
val tokens = redisTemplate.opsForHash<String, String>().get(bucketKey, "tokens")?.toIntOrNull() ?: 10
if (tokens <= 0) throw RateLimitExceededException()
redisTemplate.opsForHash<String, String>().put(bucketKey, "tokens", (tokens - 1).toString())

4. Leaky Bucket

  • Đặc điểm: Xử lý yêu cầu với tốc độ không đổi (sử dụng cơ chế hàng đợi rò rỉ).
  • Nhược điểm: Phải tính toán đồng thời trạng thái hiện tại và lượng rò rỉ, dẫn đến nguy cơ xung đột khi có nhiều yêu cầu gửi đến cùng lúc.
4.png
val key = "leaky:user:$userId"
val now = System.currentTimeMillis()
val last = redisTemplate.opsForValue().get(key)?.toLongOrNull() ?: now
val leaked = ((now - last) / 1000).toInt()
val current = maxOf(0, redisTemplate.opsForValue().increment(key, -leaked.toLong()) ?: 0)
if (current >= capacity) throw RateLimitExceededException()
redisTemplate.opsForValue().increment(key)

Cải tiến: Xử lý nguyên tử (Atomic) với Lua Script

Để giải quyết các nhược điểm về tính nguyên tử và tranh chấp dữ liệu, chúng ta sử dụng Lua Script chạy trực tiếp trên Redis. Dưới đây là cách triển khai thuật toán Sliding Window Log một cách an toàn:

1. Lua Script (Xử lý trên Redis)

Đoạn mã này đảm bảo việc xóa dữ liệu cũ và đếm dữ liệu mới diễn ra nguyên tử:
redis.call("ZADD", KEYS[1], ARGV[1], ARGV[1])
redis.call("ZREMRANGEBYSCORE", KEYS[1], 0, ARGV[2])
local count = redis.call("ZCARD", KEYS[1])
if tonumber(count) > tonumber(ARGV[3]) then
  return 0
else
  return 1
end

Kotlin code for lua-script

val now = System.currentTimeMillis()
val windowStart = now - WINDOW_SIZE_MS
val script = DefaultRedisScript<Int>()
script.scriptText = loadLuaScriptFromClasspath("scripts/sliding_window.lua")
script.resultType = Int::class.java

val allowed = redisTemplate.execute(
    script,
    listOf("rate:user:$userId"),
    now.toString(),
    windowStart.toString(),
    limit.toString()
)

if (allowed == 0) throw RateLimitExceededException()

Thuật toán Token Bucket với Lua Script

Lua-Script
local bucket = redis.call("HMGET", KEYS[1], "tokens", "last_refill")
local tokens = tonumber(bucket[1]) or tonumber(ARGV[3])
local last_refill = tonumber(bucket[2]) or tonumber(ARGV[1])
local now = tonumber(ARGV[1])

local refill = math.floor((now - last_refill) / 1000 * tonumber(ARGV[2]))
tokens = math.min(tokens + refill, tonumber(ARGV[3]))
last_refill = now

if tokens < tonumber(ARGV[4]) then
  return 0
else
  tokens = tokens - tonumber(ARGV[4])
  redis.call("HMSET", KEYS[1], "tokens", tokens, "last_refill", last_refill)
  redis.call("EXPIRE", KEYS[1], 60)
  return 1
end
Kotlin code for lua-script
val now = System.currentTimeMillis()
val script = DefaultRedisScript<Int>()
script.scriptText = loadLuaScriptFromClasspath("scripts/token_bucket.lua")
script.resultType = Int::class.java

val allowed = redisTemplate.execute(
    script,
    listOf("bucket:user:$userId"),
    now.toString(),
    "10",      // refill rate
    "100",     // capacity
    "1"        // requested tokens
)

if (allowed == 0) throw RateLimitExceededException()

Leaky Bucket - Lua Script

Lua-Script
local state = redis.call("HMGET", KEYS[1], "count", "last_leak")
local count = tonumber(state[1]) or 0
local last_leak = tonumber(state[2]) or tonumber(ARGV[1])
local now = tonumber(ARGV[1])

local leaked = math.floor((now - last_leak) / tonumber(ARGV[2]))
count = math.max(0, count - leaked)
last_leak = last_leak + leaked * tonumber(ARGV[2])

if count + 1 > tonumber(ARGV[3]) then
  return 0
else
  count = count + 1
  redis.call("HMSET", KEYS[1], "count", count, "last_leak", last_leak)
  redis.call("EXPIRE", KEYS[1], 60)
  return 1
end
Kotlin code for lua-script
val now = System.currentTimeMillis()
val script = DefaultRedisScript<Int>()
script.scriptText = loadLuaScriptFromClasspath("scripts/leaky_bucket.lua")
script.resultType = Int::class.java

val allowed = redisTemplate.execute(
    script,
    listOf("leaky:user:$userId"),
    now.toString(),
    "1000",  // leak rate (ms per request)
    "10"      // capacity
)

if (allowed == 0) throw RateLimitExceededException()

Helper codes

Tải Lua script bằng ClassLoader
fun loadLuaScriptFromClasspath(path: String): String {
    val classLoader = Thread.currentThread().contextClassLoader
    val inputStream = classLoader.getResourceAsStream(path)
        ?: throw IllegalArgumentException("Script not found: $path")
    return inputStream.bufferedReader().use { it.readText() }
}
Việc tải sẵn script lên Redis giúp tối ưu hiệu năng bằng cách tránh việc gửi toàn bộ nội dung script trong mỗi lần yêu cầu.
Đăng ký (Register) Sử dụng lệnh này để lưu script vào bộ nhớ đệm của Redis và nhận lại mã băm (SHA1):
# Thực hiện đăng ký nội dung file lua lên Redis
SCRIPT LOAD "$(cat scripts/token_bucket.lua)"
Sử dụng giá trị SHA được trả về (Execute using SHA) Thay vì dùng lệnh EVAL kèm nội dung script, hãy dùng EVALSHA kết hợp với mã SHA1 đã nhận được để thực thi:
# EVALSHA "<MÃ_SHA1>" <số_lượng_key> <key> <tham_số_1> <tham_số_2> ...
EVALSHA "<MÃ_SHA1>" 1 bucket:user:123 <now> 10 100 1

Áp dụng bằng WebFilter

Cơ chế hoạt động (How it works)

  • WebFilter: Đánh chặn mọi yêu cầu (request) gửi đến để kiểm tra giới hạn tốc độ (rate-limit).
  • Lua Script: Thực hiện logic rate-limit một cách nguyên tử (atomic) trên Redis.
  • RedisTemplate: Thực thi script và kiểm tra kết quả trả về.
  • Đối tượng áp dụng: Có thể áp dụng linh hoạt cho toàn bộ yêu cầu hoặc dựa trên URL/Header/Người dùng cụ thể.

Kết quả đạt được (How it results)

  • Cấp 10 mã thông báo (token) mỗi giây và tối đa 100 token dựa trên X-USER-ID của từng người dùng.
  • Trả về phản hồi 429 Too Many Requests khi vượt quá giới hạn.
  • Xử lý kiểm soát đồng thời (concurrency) chính xác và hiệu năng cao nhờ sự kết hợp giữa Lua + Redis.
Ví dụ (Example)
@Component
class RateLimitWebFilter(
    private val redisTemplate: StringRedisTemplate
) : WebFilter {

    private val scriptSha: String

    init { 
        // Cơ chế fail-fast: đăng ký script ngay khi ứng dụng khởi tạo
        val scriptText = loadLuaScriptFromClasspath("scripts/token_bucket.lua")
        val sha = redisTemplate.execute(RedisCallback { connection ->
            connection.serverCommands().scriptLoad(scriptText.toByteArray())
        })?.toHexString()

        this.scriptSha = sha ?: throw IllegalStateException("Không thể đăng ký Lua script với Redis")
    }

    override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
        val request = exchange.request
        // Lấy userId từ header, nếu không có thì mặc định là "anonymous"
        val userId = request.headers.getFirst("X-USER-ID") ?: "anonymous"
        val now = System.currentTimeMillis()

        // Thực thi script bằng mã SHA để tối ưu hiệu năng
        val passed = redisTemplate.execute(RedisCallback { connection ->
            connection.evalSha(
                scriptSha,
                ReturnType.INTEGER,
                1,
                "bucket:user:$userId".toByteArray(),
                now.toString().toByteArray(),
                "10".toByteArray(),   // tốc độ nạp (refill rate - tokens/giây)
                "100".toByteArray(),  // dung lượng thùng (capacity)
                "1".toByteArray()     // số token cần tiêu thụ
            )
        }) as? Long

        return if (passed == 1L) {
            // Nếu hợp lệ, cho phép yêu cầu đi tiếp
            chain.filter(exchange)
        } else {
            // Nếu vượt quá giới hạn, trả về lỗi 429
            exchange.response.statusCode = HttpStatus.TOO_MANY_REQUESTS
            exchange.response.setComplete()
        }
    }

    private fun loadLuaScriptFromClasspath(path: String): String {
        val stream = Thread.currentThread().contextClassLoader.getResourceAsStream(path)
            ?: throw IllegalArgumentException("Không tìm thấy Lua script: $path")
        return stream.bufferedReader().use { it.readText() }
    }

    private fun ByteArray.toHexString(): String =
        joinToString("") { "%02x".format(it) }
}

Lưu ý (Note)

Ví dụ trên áp dụng rate-limit cho tất cả các yêu cầu gửi đến. Tùy thuộc vào tình huống, bạn có thể phân tách đối tượng áp dụng bằng cách sử dụng Header, Method hoặc Path của request để xử lý.

Kết luận (마무리)

Rate Limit là một chiến lược thiết yếu để vừa đảm bảo tính ổn định của hệ thống, vừa đáp ứng tính công bằng cho người dùng. Ngay cả khi bắt đầu với những cách triển khai đơn giản, việc giải quyết vấn đề kiểm soát chính xác và xung đột đồng thời sẽ trở nên cực kỳ quan trọng trong môi trường dịch vụ thực tế.
Trong bài viết này, chúng ta đã cùng tìm hiểu nguyên lý và cách triển khai của 4 thuật toán tiêu biểu (Fixed, Sliding, Token, Leaky), cũng như phương thức xử lý nguyên tử dựa trên Lua Script thông qua Redis. Nếu bạn lựa chọn được chiến lược phù hợp và áp dụng ở cấp độ WebFilter hoặc API Gateway khi cần thiết, dịch vụ của bạn chắc chắn sẽ trở nên mạnh mẽ và linh hoạt hơn trong việc mở rộng. 🙂
Nguồn bài viết ryukato.github.io