Rate Limiting với Bucket4j trong Spring Boot
Tung Bui Thanh (TungDaDev)10 mins read

API mà không có rate limiting thì giống toà nhà không có bảo vệ: ai muốn ùa vào cũng được, và chỉ cần một đám là đủ làm tê liệt cả hệ thống.
Trong kiến trúc microservice, người ta hay nói tới rate limiting ở tầng gateway như Nginx, Cloudflare hay AWS WAF. Nhưng khi làm nghiệp vụ thật, mấy giải pháp ở tầng hạ tầng này có vài hạn chế khá rõ:
- Giới hạn theo IP là dao hai lưỡi: hàng ngàn nhân viên cùng công ty, hoặc cùng một mạng NAT/proxy, dùng chung một IP public. Block IP là "giết nhầm" cả đám khách hàng hợp lệ.
- Không hiểu nghiệp vụ (business-agnostic): Nginx không biết user nào thuộc gói miễn phí (Free Tier: 10 req/phút), user nào là khách doanh nghiệp (Enterprise: 1.000 req/phút) dựa trên JWT token hay API key.
- Không bảo vệ được service nội bộ: khi các service gọi nhau (east-west traffic), một service bị lỗi loop gọi liên tục có thể kéo sập dây chuyền cả cluster.
Lúc đó là lúc cần làm rate limiting ngay ở tầng application bằng Bucket4j kết hợp Redis.
# thuật toán token bucket: trái tim của bucket4j
Có khá nhiều thuật toán giới hạn lưu lượng như Leaky Bucket, Fixed Window, Sliding Window Log. Nhưng Token Bucket được chuộng nhất vì xử lý mượt các đợt traffic tăng vọt đột ngột (traffic burst):
# cách nó chạy
- Cái thùng chứa được tối đa C token (capacity).
- Theo thời gian, token được bơm vào thùng với tốc độ cố định R (refill rate). Thùng đầy rồi thì token bơm thêm tràn ra ngoài, bỏ đi.
- Mỗi request tới phải "tiêu" (consume) 1 token trong thùng:
- Còn token → cho qua.
- Hết token → chặn ngay, trả về HTTP
429 Too Many Requests.
- Người dùng nghỉ một lúc thì thùng đầy lại, họ được bắn một loạt request liên tiếp (burst) mà không bị nghẽn.
# vì sao bucket4j chạy nhanh vậy trong java?
Bucket4j là thư viện Java chuyên cho Token Bucket. Thứ đáng tiền nhất của nó không nằm ở tính năng mà ở hiệu năng tính bằng nano giây:
- Không dùng lock (lock-free concurrency): thay vì
synchronizedhayReentrantLock, thứ làm nghẽn thread khi có hàng chục ngàn request cùng lúc, Bucket4j dùng vòng lặp atomic compare-and-swap (CAS) trên bộ nhớ. - Tính refill kiểu lười (lazy refill calculation): Bucket4j không chạy background thread nào để đếm giờ nạp token. Mỗi khi có request, nó lấy
currentTime - lastRefillTimenhân với tốc độ nạp là ra ngay số token được cộng thêm. Chi phí CPU cho mỗi lần kiểm tra rất nhỏ.
Hai điểm trên nói về bucket nằm trong RAM của một JVM. Khi bucket nằm trên Redis như phần dưới, chi phí chính là một round-trip mạng tới Redis cho mỗi request, thường cỡ dưới một mili-giây trong cùng datacenter, lớn hơn nhiều so với phần tính toán.
# bài toán phân tán: khi scale lên nhiều pod
Nếu chỉ chạy một container Spring Boot, lưu bucket trong RAM là đủ. Nhưng trên Kubernetes thì:
- Bạn chạy 5 pod sau một load balancer.
- Request 1 vào pod A, request 2 vào pod B.
- Mỗi pod giữ một bucket riêng trong RAM thì người dùng được xài gấp 5 lần hạn mức!
Cách xử lý là đưa trạng thái bucket vào Redis qua module bucket4j-redis. Với LettuceBasedProxyManager, Bucket4j làm theo kiểu compare-and-swap: đọc trạng thái bucket, tính ở phía client, rồi ghi lại bằng một Lua script chỉ thành công nếu trạng thái trên Redis chưa bị ai đổi. Script chạy atomic trong Redis, nên hai pod không thể cùng trừ một token; pod nào thua thì đọc lại và thử lần nữa. Nhờ vậy không cần lock phân tán, nhưng một key bị rất nhiều pod tranh nhau cùng lúc sẽ tốn thêm vài vòng retry.
# làm trong spring boot 3
# khai báo dependencies (pom.xml)
Ví dụ dưới dùng Bucket4j 8.10.1. Từ 8.11, artifact được đổi tên theo phiên bản JDK (vd com.bucket4j:bucket4j_jdk17-lettuce, bản mới nhất hiện là 8.14.0), package và API chính gần như giữ nguyên; nâng cấp thì đổi lại artifactId cho đúng.
<dependencies>
<!-- Bucket4j Core & Redis Backend -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-redis</artifactId>
<version>8.10.1</version>
</dependency>
<!-- Lettuce Driver cho Redis -->
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies># cấu hình bucket proxy với redis
package com.tungdadev.config;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.BucketConfiguration;
import io.github.bucket4j.distributed.ExpirationAfterWriteStrategy;
import io.github.bucket4j.distributed.proxy.ClientSideConfig;
import io.github.bucket4j.distributed.proxy.ProxyManager;
import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.codec.ByteArrayCodec;
import io.lettuce.core.codec.RedisCodec;
import io.lettuce.core.codec.StringCodec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
@Configuration
public class RateLimitConfig {
// Spring Boot không expose sẵn bean RedisClient của Lettuce, nên tự tạo
@Bean(destroyMethod = "shutdown")
public RedisClient redisClient(@Value("${spring.data.redis.url:redis://localhost:6379}") String redisUrl) {
return RedisClient.create(redisUrl);
}
@Bean(destroyMethod = "close")
public StatefulRedisConnection<String, byte[]> rateLimitRedisConnection(RedisClient redisClient) {
// Key là String, value là byte[] (trạng thái bucket đã serialize)
return redisClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));
}
@Bean
public ProxyManager<String> proxyManager(StatefulRedisConnection<String, byte[]> connection) {
// builderFor(RedisClient) chỉ cho ProxyManager<byte[]>; muốn key String phải truyền connection có codec String
return LettuceBasedProxyManager.builderFor(connection)
.withClientSideConfig(ClientSideConfig.getDefault()
.withExpirationAfterWriteStrategy(
ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10))))
.build();
}
// Định nghĩa hạn mức: 10 request / phút, nạp đều đặn
public static BucketConfiguration standardTierConfig() {
return BucketConfiguration.builder()
.addLimit(Bandwidth.builder()
.capacity(10)
.refillGreedy(10, Duration.ofMinutes(1))
.build())
.build();
}
}# tạo custom annotation & interceptor
Tạo annotation @RateLimited:
package com.tungdadev.annotation;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimited {
String key() default "client_ip"; // Hoặc "api_key", "user_id"
int capacity() default 10;
int refillMinutes() default 1;
}Viết HandlerInterceptor để tự kiểm tra trước khi request vào tới controller. Thuộc tính key() của annotation ở đây chưa được dùng, ví dụ chỉ lấy API key hoặc IP; bạn có thể mở rộng để chọn nguồn định danh theo nó.
package com.tungdadev.interceptor;
import com.tungdadev.annotation.RateLimited;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.ConsumptionProbe;
import io.github.bucket4j.distributed.proxy.ProxyManager;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.time.Duration;
@Component
@RequiredArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {
private final ProxyManager<String> proxyManager;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
RateLimited rateLimited = handlerMethod.getMethodAnnotation(RateLimited.class);
if (rateLimited == null) {
return true;
}
// Lấy định danh: ví dụ API Key trong header hoặc Bearer token
String apiKey = request.getHeader("X-API-KEY");
// Gắn thêm tên method: mỗi endpoint một bucket riêng. Nếu dùng chung key, cấu hình (capacity)
// chỉ được áp lúc bucket tạo lần đầu, endpoint khác sẽ dùng nhầm hạn mức của endpoint trước.
String endpoint = handlerMethod.getBeanType().getSimpleName() + "." + handlerMethod.getMethod().getName();
String limitKey = "rate_limit:" + endpoint + ":" + (apiKey != null ? apiKey : request.getRemoteAddr());
// Lấy hoặc khởi tạo Bucket phân tán trong Redis
Bucket bucket = proxyManager.builder().build(limitKey, () ->
io.github.bucket4j.BucketConfiguration.builder()
.addLimit(io.github.bucket4j.Bandwidth.builder()
.capacity(rateLimited.capacity())
.refillGreedy(rateLimited.capacity(), Duration.ofMinutes(rateLimited.refillMinutes()))
.build())
.build()
);
// Thử tiêu thụ 1 token
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
if (probe.isConsumed()) {
// Cho phép đi tiếp, kèm theo Header thông báo hạn mức còn lại
response.addHeader("X-RateLimit-Limit", String.valueOf(rateLimited.capacity()));
response.addHeader("X-RateLimit-Remaining", String.valueOf(probe.getRemainingTokens()));
return true;
} else {
// Hết lượt! Trả về HTTP 429 và thời gian cần chờ
long waitForRefillNanos = probe.getNanosToWaitForRefill();
// Làm tròn lên, làm tròn xuống thì client quay lại sớm quá và lại bị 429
long waitForRefillSeconds = Math.max(1, (waitForRefillNanos + 999_999_999) / 1_000_000_000);
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.addHeader(HttpHeaders.RETRY_AFTER, String.valueOf(waitForRefillSeconds));
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8"); // message có tiếng Việt
response.getWriter().write(String.format(
"{\"error\": \"Too Many Requests\", \"message\": \"Vượt quá giới hạn gọi API. Vui lòng thử lại sau %d giây.\"}",
waitForRefillSeconds
));
return false;
}
}
}Interceptor chỉ chạy khi được đăng ký với Spring MVC:
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final RateLimitInterceptor rateLimitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor).addPathPatterns("/api/**");
}
}Thêm một lưu ý về request.getRemoteAddr(): chạy sau load balancer hay ingress thì nó trả về IP của load balancer, mọi client sẽ chung một bucket. Cần bật server.forward-headers-strategy=native (hoặc framework) để Spring đọc X-Forwarded-For, và chỉ tin header này khi nó do proxy của mình đặt, không thì client tự giả IP được.
# dùng trong controller
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@PostMapping
@RateLimited(capacity = 5, refillMinutes = 1) // Tối đa 5 lượt đặt hàng mỗi phút
public ResponseEntity<String> placeOrder(@RequestBody OrderRequest request) {
return ResponseEntity.ok("Đặt hàng thành công!");
}
}# kinh nghiệm khi đưa lên production
Đặt TTL cho key Redis: nếu có 10 triệu người dùng vãng lai gọi 1 request rồi không bao giờ quay lại, Redis sẽ đầy ứ hàng triệu key rác. Luôn cấu hình
withExpirationStrategytheo thời gian nạp đầy thùng (basedOnTimeForRefillingBucketUpToMax).Fail-open hay fail-closed: Redis gặp sự cố hoặc timeout mạng thì hệ thống làm gì? Interceptor ở trên chưa xử lý chuyện này: lỗi Redis sẽ ném exception và request trả về 500, tức là fail-closed ngoài ý muốn. Cần bọc lời gọi
tryConsumeAndReturnRemainingtrong try/catch và đặt timeout ngắn cho kết nối Redis để chọn hành vi một cách chủ động.- Fail-open (ưu tiên sẵn sàng): bỏ qua rate limit, cho request đi tiếp để không làm gián đoạn kinh doanh.
- Fail-closed (ưu tiên an toàn): chặn request lại để database phía sau khỏi sập. Với đa số hệ thống e-commerce, fail-open kèm log cảnh báo lên Sentry là lựa chọn khôn hơn.
Trả header cho client dễ chịu: luôn trả về 3 header để client tự điều chỉnh tốc độ:
X-RateLimit-Limit: tổng hạn mức.X-RateLimit-Remaining: số token còn lại trong thùng.Retry-After: số giây phải đợi trước khi gửi tiếp.
Trong ba header này chỉ
Retry-Afterlà chuẩn HTTP chính thức (RFC 9110). Hai headerX-RateLimit-*là quy ước phổ biến (GitHub, nhiều API lớn dùng), còn IETF đang soạn chuẩnRateLimit/RateLimit-Policynhưng tới nay vẫn ở dạng draft.
TIP
Thiết kế key cho rate limiter: kết hợp Client IP với User ID hoặc API Key theo mẫu rate:user:{userId}:{endpoint}. Đừng rate limit chỉ dựa trên IP, vì rất dễ dính cả những người chung văn phòng hoặc chung mạng công cộng (NAT).
# tổng kết
Rate limiting không chỉ để chống phá hoại. Nó là một phần của kiến trúc chịu lỗi tốt (resilient architecture) và là nền để bán các gói SaaS nhiều tầng (Freemium, Pro, Enterprise).
Ghép thuật toán Token Bucket của Bucket4j với Redis làm nơi lưu trạng thái chung, ứng dụng Spring Boot có thêm một lớp bảo vệ hiểu được nghiệp vụ, đổi lại mỗi request tốn thêm một round-trip tới Redis. Bước tiếp theo nên làm là quyết định rõ fail-open hay fail-closed và gắn metric số request bị 429 theo từng gói để biết hạn mức đặt có hợp lý không.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.