TungDaDev's Blog

rate limiting với bucket4j & redis trong spring boot

Bucket4j rate limiting.jpg
Published on
/8 mins read/

Một hệ thống API không có cơ chế giới hạn lưu lượng (Rate Limiting) cũng giống như một tòa nhà không có cửa bảo vệ — bất kỳ ai cũng có thể ùa vào làm tê liệt toàn bộ hệ thống.

Trong kiến trúc microservice, chúng ta thường nghe về Rate Limiting ở tầng Gateway như Nginx, Cloudflare hay AWS WAF. Nhưng trong thực tế triển khai nghiệp vụ, các giải pháp ở tầng hạ tầng này bộc lộ những hạn chế rõ rệt:

  1. Giới hạn theo IP là con dao hai lưỡi: Nếu hàng ngàn nhân viên trong cùng một công ty hoặc cùng một mạng NAT/Proxy dùng chung một IP công cộng, việc block IP sẽ "giết nhầm" khách hàng hợp lệ.
  2. Không hiểu nghiệp vụ (Business-agnostic): Nginx không thể biết user nào là Gói Miễn Phí (Free Tier: 10 req/phút) và user nào là Khách hàng Doanh Nghiệp (Enterprise: 1.000 req/phút) dựa trên JWT token hoặc API Key.
  3. Không bảo vệ được tầng Service nội bộ: Khi các service giao tiếp với nhau (East-West traffic), một service bị lỗi loop gọi liên tục có thể làm sập dây chuyền toàn bộ cluster.

Đây là lúc bạn cần triển khai Rate Limiting ngay tại tầng Application với Bucket4j kết hợp Redis.


# thuật toán token bucket: linh hồn của bucket4j

Có nhiều thuật toán giới hạn lưu lượng như Leaky Bucket, Fixed Window, Sliding Window Log. Tuy nhiên, Token Bucket là thuật toán được ưa chuộng nhất nhờ khả năng xử lý mượt mà hiện tượng bùng nổ lưu lượng đột biến (traffic bursts):

Nguyên lý vận hành:

  1. Chiếc thùng có sức chứa tối đa C (Capacity).
  2. Định kỳ theo thời gian, một lượng token được bơm vào thùng với tốc độ cố định R (Refill Rate). Nếu thùng đã đầy, token bơm vào sẽ bị tràn ra ngoài bỏ đi.
  3. Mỗi khi có một request đến, nó phải "tiêu thụ" (consume) 1 token từ thùng.
    • Nếu còn token \to Cho phép đi qua.
    • Nếu hết token \to Chặn lại ngay và trả về HTTP 429 Too Many Requests.
  4. Nếu người dùng nghỉ ngơi một lúc, thùng sẽ đầy lại tới mức tối đa, cho phép họ thực hiện một đợt request nhanh liên tiếp (burst) mà không bị nghẽn.

# tại sao bucket4j lại vô địch về hiệu năng trong java?

Bucket4j là thư viện Java chuyên biệt dành cho Token Bucket. Điểm đắt giá nhất của nó không phải là tính năng, mà là hiệu năng cực đỉnh ở cấp độ nano-giây:

  • Thuật toán không khóa (Lock-free Concurrency): Thay vì dùng synchronized hay ReentrantLock gây tắc nghẽn luồng khi có hàng chục ngàn request đồng thời, Bucket4j sử dụng vòng lặp Atomic Compare-And-Swap (CAS) trên bộ nhớ.
  • Thời gian nạp ảo (Lazy Refill Calculation): Bucket4j không hề chạy một background thread nào để liên tục đếm giờ nạp token. Thay vào đó, mỗi khi có request đến, nó lấy currentTime - lastRefillTime, nhân với tốc độ nạp để tính ngay số token được cộng dồn theo công thức toán học. CPU overhead gần như bằng 0!

# bài toán phân tán: khi hệ thống scale lên nhiều pods

Nếu bạn chỉ chạy 1 container Spring Boot duy nhất, lưu Bucket trong RAM cục bộ là đủ. Nhưng trong thực tế Kubernetes:

  • Bạn chạy 5 Pods sau một Load Balancer.
  • Request 1 vào Pod A, Request 2 vào Pod B.
  • Nếu mỗi Pod giữ một Bucket riêng trong RAM, người dùng sẽ được xài hạn mức gấp 5 lần quy định!

Giải pháp là đưa trạng thái Bucket vào Redis thông qua module bucket4j-redis. Bucket4j sử dụng các Lua script được biên dịch sẵn trong Redis để đảm bảo việc kiểm tra và trừ token diễn ra nguyên tử (atomic) 100% qua mạng.


# hướng dẫn triển khai thực chiến trong spring boot 3

1. Khai báo dependencies (pom.xml)

<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>

2. 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.proxy.ProxyManager;
import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
import io.lettuce.core.RedisClient;
import io.lettuce.core.codec.ByteArrayCodec;
import io.lettuce.core.codec.StringCodec;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
import java.time.Duration;
 
@Configuration
public class RateLimitConfig {
 
    @Bean
    public ProxyManager<String> proxyManager(RedisClient redisClient) {
        return LettuceBasedProxyManager.builderFor(redisClient)
                .withExpirationStrategy(
                    io.github.bucket4j.distributed.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();
    }
}

3. 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ự động kiểm tra trước khi vào Controller:

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.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");
        String limitKey = "rate_limit:" + (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-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();
            long waitForRefillSeconds = Math.max(1, waitForRefillNanos / 1_000_000_000);
 
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.addHeader("X-RateLimit-Retry-After-Seconds", String.valueOf(waitForRefillSeconds));
            response.setContentType("application/json");
            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;
        }
    }
}

4. Sử dụng đơn giản 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 thực chiến khi đưa lên production

  1. Thiết lập Expiration Time (TTL) cho Redis Key: Nếu có 10 triệu người dùng vãng lai chỉ gọi 1 request rồi không bao giờ quay lại, Redis sẽ bị tràn bộ nhớ bởi hàng triệu key rác. Hãy luôn cấu hình withExpirationStrategy dựa trên thời gian nạp đầy thùng (basedOnTimeForRefillingBucketUpToMax).
  2. Chiến lược Fail-Open vs Fail-Closed: Khi cụm Redis gặp sự cố hoặc timeout mạng, hệ thống nên làm gì?
    • Fail-Open (Ưu tiên sẵn sàng): Bỏ qua bước rate limit và 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 để bảo vệ database phía sau khỏi nguy cơ sập toàn diện. Trong hầu hết các hệ thống e-commerce, phương án Fail-Open kèm log cảnh báo Sentry là lựa chọn khôn ngoan hơn.
  3. Thân thiện với chuẩn HTTP Quốc tế: Luôn trả về 3 headers chuẩn để client tự động đ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 được gửi tiếp.

# tổng kết

Rate Limiting không đơn thuần là một công cụ chống phá hoại, mà là nền tảng của kiến trúc phần mềm bền vững (Resilient Architecture) và là cơ sở để kinh doanh các gói dịch vụ SaaS đa tầng (Freemium, Pro, Enterprise).

Bằng cách kết hợp thuật toán Token Bucket thanh lịch của Bucket4j với khả năng lưu trữ phân tán siêu tốc của Redis, bạn đã trang bị cho ứng dụng Spring Boot một lớp giáp bảo vệ vững chắc, hoạt động mượt mà ở quy mô hàng triệu người dùng mà không làm suy hao hiệu năng của hệ thố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 😎 👍🏻 🚀 🔥.