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ằngwindowSecondsgiâ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.
isAllowedtrảtruenếu số lượng request hiện tại chưa vượtlimit.
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:
ZREMRANGEBYSCORExoá các entry có score (timestamp) nhỏ hơnnow - 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ằngwindowMs(để 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 (
GETrồiINCR): 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ùngINCRnguyên tử. - Dùng key không phân biệt client: nếu quên nhúng
clientIdvà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
limitlớ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.