Rate Limiting bằng Redis trong Spring Boot

1. Tổng quan

Rate limiting giúp bạn chặn client gọi API quá nhiều lần trong một khoảng thời gian, tránh quá tải hệ thống hoặc bị abuse. Bài này trình bày cách dùng Redis làm bộ đếm tập trung, kết hợp Lua script để đảm bảo tính atomic khi nhiều instance ứng dụng cùng truy cập. Đọc xong, bạn sẽ tự viết được một RateLimiter dùng thuật toán fixed window và sliding window, tích hợp vào Spring Boot qua interceptor, và viết test kiểm chứng hành vi.

2. Vì sao chọn Redis cho rate limiting

Khi ứng dụng chạy nhiều instance (horizontal scaling), bộ đếm lưu trong memory của từng instance (ví dụ AtomicInteger) không đồng bộ được. Redis giải quyết vấn đề này vì:

  • Là kho dữ liệu tập trung, mọi instance đọc/ghi cùng một nơi.
  • Hỗ trợ TTL (EXPIRE) để tự dọn key cũ, không cần cron job.
  • Hỗ trợ Lua script chạy atomic trên server, tránh race condition khi tăng counter và kiểm tra ngưỡng.

Lưu ý: Nếu hệ thống chỉ có một instance duy nhất và không cần chia sẻ trạng thái, dùng in-memory limiter (ví dụ Guava RateLimiter) sẽ nhanh hơn và đơn giản hơn. Chỉ nên dùng Redis khi cần rate limit phân tán.

3. Thiết lập dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring.data.redis.host=localhost
spring.data.redis.port=6379

Spring Boot tự cấu hình RedisConnectionFactory và StringRedisTemplate dựa trên các property này.

4. Thuật toán Fixed Window

Fixed window chia thời gian thành các khung cố định (ví dụ mỗi 60 giây), đếm số request trong khung đó. Nếu vượt ngưỡng, từ chối.

4.1. Lua script atomic

Dùng Lua script để kết hợp INCR và EXPIRE thành một lệnh atomic, tránh trường hợp request khác chen vào giữa hai bước.

public class FixedWindowRateLimiter {

    private static final String SCRIPT = """
        local current = redis.call('INCR', KEYS[1])
        if current == 1 then
            redis.call('EXPIRE', KEYS[1], ARGV[1])
        end
        return current
        """;

    private final StringRedisTemplate redisTemplate;
    private final RedisScript<Long> redisScript;

    public FixedWindowRateLimiter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.redisScript = RedisScript.of(SCRIPT, Long.class);
    }

    public boolean isAllowed(String clientId, int limit, int windowSeconds) {
        String key = "rate:fixed:" + clientId;
        Long current = redisTemplate.execute(
                redisScript,
                List.of(key),
                String.valueOf(windowSeconds));
        return current != null && current <= limit;
    }
}

Giải thích:

  • INCR KEYS[1] tăng counter của key, trả về giá trị mới.
  • Nếu current == 1 (lần đầu tạo key), đặt TTL bằng windowSeconds giây.
  • Toàn bộ script chạy atomic trên Redis server, không bị interleave bởi request khác dù chạy đa luồng hay đa instance.
  • isAllowed trả true nếu số lượng request hiện tại chưa vượt limit.

4.2. Dùng trong Spring Boot interceptor

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    private final FixedWindowRateLimiter rateLimiter;

    public RateLimitInterceptor(FixedWindowRateLimiter rateLimiter) {
        this.rateLimiter = rateLimiter;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                              HttpServletResponse response,
                              Object handler) throws IOException {
        String clientId = request.getRemoteAddr();
        boolean allowed = rateLimiter.isAllowed(clientId, 100, 60);
        if (!allowed) {
            response.setStatus(429);
            response.getWriter().write("Too many requests");
            return false;
        }
        return true;
    }
}

Interceptor lấy IP client làm clientId, giới hạn 100 request mỗi 60 giây. Nếu vượt ngưỡng, trả HTTP 429 và chặn request đi tiếp vào controller.

Lưu ý: Fixed window có nhược điểm “burst ở biên”: nếu client gửi 100 request vào cuối giây 59 và 100 request nữa vào đầu giây 60, thực tế có 200 request trong 1 giây dù mỗi window đều hợp lệ. Nếu cần chính xác hơn, dùng sliding window.

5. Thuật toán Sliding Window Log

Sliding window log lưu timestamp của từng request trong một Sorted Set, loại bỏ các timestamp quá cũ mỗi lần kiểm tra. Chính xác hơn fixed window nhưng tốn bộ nhớ hơn vì lưu từng request.

public class SlidingWindowRateLimiter {

    private static final String SCRIPT = """
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local windowMs = tonumber(ARGV[2])
        local limit = tonumber(ARGV[3])

        redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)
        local count = redis.call('ZCARD', key)
        if count < limit then
            redis.call('ZADD', key, now, now)
            redis.call('PEXPIRE', key, windowMs)
            return 1
        end
        return 0
        """;

    private final StringRedisTemplate redisTemplate;
    private final RedisScript<Long> redisScript;

    public SlidingWindowRateLimiter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.redisScript = RedisScript.of(SCRIPT, Long.class);
    }

    public boolean isAllowed(String clientId, int limit, int windowMs) {
        String key = "rate:sliding:" + clientId;
        long now = System.currentTimeMillis();
        Long result = redisTemplate.execute(
                redisScript,
                List.of(key),
                String.valueOf(now),
                String.valueOf(windowMs),
                String.valueOf(limit));
        return result != null && result == 1;
    }
}

Giải thích từng bước trong Lua:

  • ZREMRANGEBYSCORE xoá các entry có score (timestamp) nhỏ hơn now - windowMs, tức loại bỏ request đã ra khỏi cửa sổ thời gian.
  • ZCARD đếm số entry còn lại trong Sorted Set — đây chính là số request trong window hiện tại.
  • Nếu count < limit, thêm entry mới với score là now, đặt TTL cho key bằng windowMs (để tự dọn khi không còn traffic), trả về 1 (cho phép).
  • Nếu đã đầy, trả về 0 (từ chối), không thêm entry mới.

Kết quả: cửa sổ trượt liên tục theo thời gian thực, không có hiện tượng burst ở biên như fixed window.

6. So sánh hai thuật toán

Tiêu chí Fixed Window Sliding Window Log
Độ chính xác Thấp, có burst ở biên Cao
Bộ nhớ O(1) mỗi key O(limit) mỗi key
Độ phức tạp Đơn giản Phức tạp hơn
Phù hợp Giới hạn thô, traffic cao API nhạy cảm, cần chính xác

Nếu không chắc chọn thuật toán nào, bắt đầu với fixed window — đơn giản, hiệu năng tốt, đủ dùng cho hầu hết API nội bộ.

7. Viết test với Testcontainers

Để test không phụ thuộc Redis cài sẵn trên máy, dùng Testcontainers khởi Redis trong Docker.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@Testcontainers
class FixedWindowRateLimiterTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379);

    FixedWindowRateLimiter rateLimiter;

    @BeforeEach
    void setUp() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(
                redis.getHost(), redis.getMappedPort(6379));
        LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
        factory.afterPropertiesSet();

        StringRedisTemplate template = new StringRedisTemplate(factory);
        template.afterPropertiesSet();

        rateLimiter = new FixedWindowRateLimiter(template);
    }

    @Test
    void shouldBlockRequestsExceedingLimit() {
        String clientId = "client-A";
        int limit = 3;

        boolean r1 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r2 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r3 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r4 = rateLimiter.isAllowed(clientId, limit, 60);

        assertThat(r1).isTrue();
        assertThat(r2).isTrue();
        assertThat(r3).isTrue();
        assertThat(r4).isFalse();
    }
}

Test này gửi 4 request liên tiếp với limit = 3. Ba request đầu được chấp nhận (isTrue), request thứ 4 bị từ chối (isFalse) vì đã vượt ngưỡng trong cùng một window. Testcontainers tự khởi và huỷ container Redis, đảm bảo test độc lập với môi trường máy chạy CI.

8. Lỗi thường gặp

  • Không đặt TTL cho key: nếu quên EXPIRE, key tồn tại vĩnh viễn trong Redis, gây rò rỉ bộ nhớ và counter không bao giờ reset.
  • Tăng counter và kiểm tra ngưỡng bằng hai lệnh riêng (GET rồi INCR): tạo race condition khi nhiều request đến cùng lúc, dẫn đến vượt ngưỡng thực tế. Luôn gộp thành một Lua script hoặc dùng INCR nguyên tử.
  • Dùng key không phân biệt client: nếu quên nhúng clientId vào key, tất cả client dùng chung một bộ đếm, gây chặn nhầm người dùng khác.
  • Sliding window log với traffic cực lớn: Sorted Set phình to nếu limit lớn và nhiều client đồng thời, cân nhắc dùng sliding window counter (kết hợp hai fixed window) để tiết kiệm bộ nhớ hơn.

9. Kết luận

Redis kết hợp Lua script là lựa chọn phổ biến để xây rate limiter phân tán: đơn giản, atomic, và dễ scale theo số instance ứng dụng. Fixed window phù hợp cho đa số trường hợp nhờ tính đơn giản, trong khi sliding window log phù hợp khi bạn cần kiểm soát chính xác tại biên thời gian. Hãy luôn test kỹ hành vi tại ngưỡng giới hạn (boundary) trước khi đưa vào production, vì đây là nơi dễ phát sinh lỗi logic nhất.

redisrate-limitingspring-bootlua-scriptjava