0

Triển khai Rate Limiter : Bucket4j + Redisson trong Spring Boot

Trước hết: Đây là bài viết mình tổng hợp bằng AI trong quá trình tìm hiểu về Rate Limiter nhằm mục đích ôn tập nên sẽ có nhiều chỗ sai sót và chưa đúng nghiệp vụ chuẩn. Bài viết chỉ mang tính chất tham khảo, luôn sẵn sàng nhận được sự góp ý của mọi người.

Triển khai Rate Limiter chuyên sâu: Bucket4j + Redisson trong Spring Boot

Rate Limiting (giới hạn tỷ lệ yêu cầu) không còn là một tính năng "nice-to-have" (có thì tốt). Trong các hệ thống microservice hiện đại, nó là một tuyến phòng thủ bắt buộc để đảm bảo:

  • Tính ổn định (Stability): Chống lại các cuộc tấn công DoS, DDoS hoặc các client "ồn ào" (noisy neighbors) làm sập hệ thống.
  • Bảo mật (Security): Ngăn chặn tấn công brute-force vào API đăng nhập hoặc quên mật khẩu.
  • Quản lý chi phí (Cost Control): Kiểm soát số lượng lệnh gọi đến các API của bên thứ ba (Google, AWS...) mà bạn phải trả tiền.

⚠️ Tại sao Rate Limiter "In-Memory" là không đủ?

Trong môi trường microservice, bạn chạy nhiều instance (bản sao) của cùng một dịch vụ sau một bộ cân bằng tải (Load Balancer).

Nếu bạn dùng rate limiter in-memory (lưu trong RAM), mỗi instance sẽ có một bộ đếm riêng. Nếu bạn giới hạn 100 request/phút, 5 instance sẽ cho phép tổng cộng 500 request/phút, phá vỡ hoàn toàn mục tiêu của bạn.

Giải pháp: Chúng ta cần một bộ đếm tập trungtrạng thái chung (shared state).

✨ "Bộ đôi vàng": Bucket4j + Redisson

Đây là sự kết hợp hoàn hảo để giải quyết bài toán trên:

  1. Bucket4j: Một thư viện Java cực kỳ mạnh mẽ, triển khai thuật toán Token Bucket. Nó cung cấp logic (ví dụ: 100 token/phút, nạp lại 10 token/giây).
  2. Redisson (JCache): Một Redis client cao cấp cho Java. Quan trọng nhất, nó cung cấp một implementation (triển khai) hoàn chỉnh của JCache (JSR-107).
  3. Redis: Kho lưu trữ key-value in-memory siêu nhanh, đóng vai trò là nơi lưu trữ trạng thái (số token còn lại) cho tất cả các instance.

Khi kết hợp, Bucket4j sẽ yêu cầu "lưu trạng thái vào JCache", Redisson sẽ "nhận yêu cầu JCache đó và thực thi nó trên Redis" một cách nguyên tử (atomic) bằng LUA script, đảm bảo không có race condition.


Bước 1: Thiết lập nền tảng (Dependencies)

Đây là file pom.xml đầy đủ và chính xác đã được kiểm chứng.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
    <groupId>com.giffing.bucket4j.spring.boot.starter</groupId>
    <artifactId>bucket4j-spring-boot-starter</artifactId>
    <version>0.10.0</version> 
</dependency>

<dependency>
    <groupId>javax.cache</groupId>
    <artifactId>cache-api</artifactId>
    <version>1.1.1</version>
</dependency>

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.27.1</version> 
</dependency>

Bước 2: Cấu hình JCache "Chống lỗi" (Robust Config)

Đây là phần quan trọng nhất và là nơi dễ xảy ra lỗi nhất. Chúng ta cần tạo một Bean javax.cache.CacheManager để Bucket4j sử dụng.

Vấn đề thường gặp: Spring Boot cũng có org.springframework.cache.CacheManager. Nếu bạn import sai hoặc có nhiều CacheManager beans (ví dụ: bạn dùng CaffeineCacheManager cho @Cacheable), hệ thống sẽ bị xung đột.

Đây là file CacheConfig.java hoàn chỉnh, giải quyết tất cả các xung đột:

import lombok.extern.slf4j.Slf4j;
import org.redisson.config.Config;
import org.redisson.jcache.configuration.RedissonConfiguration;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary; // Quan trọng
import org.springframework.cache.CacheManager; // Đây là CacheManager của Spring

// Import CacheManager của JCache (JSR-107)
import javax.cache.Caching;
import javax.cache.spi.CachingProvider;

@Configuration
@EnableCaching
@Slf4j
public class CacheConfig {

    // Tên cache này sẽ được dùng trong application.yml
    public static final String RATE_LIMIT_CACHE = "rate-limit-buckets";

    // --- CẤU HÌNH CHO BUCKET4J (JCACHE) ---

    @Bean
    public Config redissonConfig() {
        Config config = new Config();
        // Cấu hình kết nối Redis của bạn (Single, Cluster, Sentinel...)
        config.useSingleServer()
              .setAddress("redis://127.0.0.1:6379");
        // .setPassword("your-password");
        return config;
    }

    /**
     * Bean này dành RIÊNG cho Bucket4j.
     * Bucket4j starter sẽ tự động tìm Bean có kiểu javax.cache.CacheManager.
     */
    @Bean
    public javax.cache.CacheManager jCacheManagerForBucket4j(Config redissonConfig) {
        
        CachingProvider provider = Caching.getCachingProvider();
        javax.cache.CacheManager cacheManager = provider.getCacheManager();

        // Tạo cấu hình JCache từ cấu hình Redisson
        javax.cache.configuration.Configuration<Object, Object> jcacheConfig =
            RedissonConfiguration.fromConfig(redissonConfig);

        // **Mấu chốt "chống lỗi vặt"**:
        // Chủ động tạo cache trước. Nếu không, filter đầu tiên truy cập
        // có thể gặp lỗi "cache not found" trong môi trường đa luồng.
        if (cacheManager.getCache(RATE_LIMIT_CACHE) == null) {
            cacheManager.createCache(RATE_LIMIT_CACHE, jcacheConfig);
            log.info("✅ JCache '{}' for Bucket4j initialized.", RATE_LIMIT_CACHE);
        }

        return cacheManager;
    }

    /*
    // --- (TÙY CHỌN) NẾU BẠN CŨNG DÙNG SPRING CACHE (@Cacheable) ---
    // Nếu bạn muốn dùng RedisCacheManager cho @Cacheable
    // Hãy định nghĩa nó và đánh dấu @Primary.
    
    @Bean
    @Primary // Báo Spring dùng cái này cho @Cacheable
    public CacheManager springCacheManager(RedisConnectionFactory factory) {
        // ... (Tạo RedisCacheManager của Spring) ...
        log.info("✅ Primary Spring Cache Manager (RedisCacheManager) initialized.");
        return RedisCacheManager.builder(factory).build();
    }
    */
}

Với cấu hình này, Bucket4j sẽ dùng jCacheManagerForBucket4j (JCache), còn Spring (@Cacheable) sẽ dùng springCacheManager (@Primary), không bao giờ xung đột.


Bước 3: Phương pháp 1 (Filter) - Bảo vệ API bằng YAML

Đây là cách triển khai mạnh mẽ, không cần code, dựa trên Spring Expression Language (SpEL). Nó lý tưởng cho các quy tắc tĩnh (static rules).

Sửa file application.yml của bạn:

giffing:
  bucket4j:
    enabled: true
    # Tên cache này PHẢI KHỚP với biến RATE_LIMIT_CACHE trong CacheConfig.java
    cache-name: "rate-limit-buckets"
    
    # Định nghĩa các bộ lọc (filters)
    # Chúng được thực thi theo thứ tự (order)
    filters:
      
      # ------ KỊCH BẢN 1: Whitelist Admin (Ưu tiên cao nhất) ------
      - order: 1
        url: "/api/.*" # Áp dụng cho mọi API
        strategy: FIRST
        # Điều kiện: Đã đăng nhập (qua Spring Security) VÀ có quyền 'ROLE_ADMIN'
        execute-condition: "getPrincipal() != null && getPrincipal().getAuthorities().contains('ROLE_ADMIN')"
        # Key: Dùng tên user (ví dụ: 'admin_user')
        expression: "getPrincipal().getName()"
        rate-limits:
          # Giới hạn cực cao (gần như không giới hạn)
          - bandwidth: { capacity: 10000, time: 1, unit: SECONDS }

      # ------ KỊCH BẢN 2: User đã đăng nhập (Ưu tiên nhì) ------
      - order: 10
        url: "/api/.*"
        strategy: FIRST
        # Điều kiện: Đã đăng nhập (Filter 1 đã bắt admin, nên đây là user thường)
        execute-condition: "getPrincipal() != null"
        expression: "getPrincipal().getName()" # Key: 'user_1', 'user_2'
        rate-limits:
          - bandwidth: { capacity: 200, time: 1, unit: MINUTES }

      # ------ KỊCH BẢN 3: Khách (Theo IP - Ưu tiên thấp nhất) ------
      - order: 20
        url: "/api/.*"
        strategy: FIRST
        # Điều kiện: Sẽ chạy nếu Lớp 1 và 2 không khớp (tức là getPrincipal() == null)
        # Key: Lấy IP. 'X-Forwarded-For' là bắt buộc khi chạy sau Proxy/LB
        expression: "getHeader('X-Forwarded-For') != null ? getHeader('X-Forwarded-For') : getRemoteAddr()"
        rate-limits:
          # Giới hạn thấp để chống spam
          - bandwidth: { capacity: 20, time: 1, unit: MINUTES }
            
      # ------ KỊCH BẢN 4: Chống Burst (Đa băng thông) ------
      - order: 5 # Ưu tiên cao hơn user
        url: "/api/v1/payment/.*" # Chỉ áp dụng cho API thanh toán
        strategy: FIRST
        expression: "getPrincipal() != null ? getPrincipal().getName() : getRemoteAddr()"
        rate-limits:
          # (1) Giới hạn DÀI HẠN: 100 request/giờ
          - bandwidth: { capacity: 100, time: 1, unit: HOURS }
          # (2) Giới hạn NGẮN HẠN (Chống burst): Chỉ 5 request/10 giây
          # Request phải thỏa mãn CẢ HAI điều kiện này
          - bandwidth: { capacity: 5, time: 10, unit: SECONDS }
  • Ưu điểm: Cực nhanh, không cần biên dịch lại code, dễ dàng quản lý các quy tắc tĩnh.
  • Nhược điểm: Không thể xử lý logic động (ví dụ: "User A thuộc gói Free nên được 100 req, User B thuộc gói Paid nên được 5000 req").

Bước 4: Phương pháp 2 (Programmatic) - Logic động

Đây là phương pháp "chuyên sâu" thực sự, cho phép bạn nhúng logic rate limit vào bên trong logic nghiệp vụ của mình.

Kịch bản: Giới hạn theo Gói dịch vụ (Tiered Subscription: Free, Pro, Enterprise).

4.1. Tạo RateLimiterService

Service này sẽ chịu trách nhiệm "chọn" đúng băng thông (Bandwidth) cho user.

import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Refill;
import io.github.bucket4j.distributed.proxy.ProxyManager;
import io.github.bucket4j.grid.jcache.JCacheProxyManager;
import org.springframework.stereotype.Service;

import javax.cache.Cache;
import javax.cache.CacheManager; // Import của JAVAX
import java.time.Duration;
import java.util.function.Supplier;

@Service
public class RateLimiterService {

    // ProxyManager là trái tim của Bucket4j, giúp tương tác với Redis
    private final ProxyManager<String> proxyManager;

    // Định nghĩa các gói (Tiers)
    private static final Bandwidth TIER_FREE = Bandwidth.classic(20, Refill.greedy(20, Duration.ofMinutes(1)));
    private static final Bandwidth TIER_PRO = Bandwidth.classic(5000, Refill.greedy(5000, Duration.ofMinutes(1)));

    public RateLimiterService(CacheManager jCacheManager) { // Tiêm Bean JCache từ CacheConfig
        Cache<String, byte[]> cache = jCacheManager.getCache(CacheConfig.RATE_LIMIT_CACHE);
        this.proxyManager = new JCacheProxyManager<>(cache);
    }

    /**
     * Lấy hoặc tạo một bucket cho một user (dựa trên key)
     */
    public Bucket resolveBucket(String userId) {
        // Logic nghiệp vụ: Kiểm tra gói của user
        UserTier tier = getUserTierFromDatabase(userId); // Giả lập

        Supplier<Bandwidth> bandwidthSupplier;
        if (tier == UserTier.PRO) {
            bandwidthSupplier = () -> TIER_PRO;
        } else {
            bandwidthSupplier = () -> TIER_FREE;
        }

        // proxyManager.getProxy() sẽ:
        // 1. Tìm bucket với key=userId trên Redis.
        // 2. Nếu không có, tạo mới bằng "bandwidthSupplier".
        // 3. Trả về bucket.
        // Mọi thao tác đều là atomic trên Redis.
        return proxyManager.getProxy(userId, bandwidthSupplier);
    }

    // (Hàm giả lập)
    private UserTier getUserTierFromDatabase(String userId) {
        if (userId.startsWith("pro_")) {
            return UserTier.PRO;
        }
        return UserTier.FREE;
    }

    private enum UserTier { FREE, PRO }
}

4.2. Áp dụng trong Controller

Bây giờ, thay vì để filter tự động, bạn sẽ gọi service này trong controller.

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/data")
public class DataController {

    private final RateLimiterService rateLimiterService;

    public DataController(RateLimiterService rateLimiterService) {
        this.rateLimiterService = rateLimiterService;
    }

    @GetMapping
    public ResponseEntity<String> getData(Authentication authentication) {
        if (authentication == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }

        String userId = authentication.getName();
        Bucket bucket = rateLimiterService.resolveBucket(userId);

        // Tiêu thụ 1 token
        if (bucket.tryConsume(1)) {
            // Còn token, xử lý request
            return ResponseEntity.ok("Pro Data for user: " + userId);
        } else {
            // Hết token
            return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                                 .body("Rate limit exceeded for your tier.");
        }
    }
}

Bước 5: Chiến lược chống lỗi (Resilience) - Khi Redis "chết"

Một kịch bản "chuyên sâu" là: Điều gì xảy ra nếu Redis bị lỗi?

  • Mặc định (Fail-Closed): Redisson sẽ ném ra RedisException. Nếu không bắt, request sẽ trả về HTTP 500.

    • Ưu điểm: An toàn tuyệt đối. Backend của bạn được bảo vệ.
    • Nhược điểm: Mất tính sẵn sàng (Availability). Redis lỗi = Dịch vụ lỗi.
  • Chiến lược (Fail-Open): Chúng ta try-catch lỗi Redis và cho phép request đi qua.

    • Ưu điểm: Tính sẵn sàng cao.
    • Nhược điểm: Rủi ro bị tấn công/quá tải khi Redis lỗi.

Chỉ áp dụng Fail-Open nếu tính sẵn sàng quan trọng hơn:

// Trong DataController
@GetMapping("/fail-open")
public ResponseEntity<String> getDataFailOpen(Authentication authentication) {
    String userId = authentication.getName();
    Bucket bucket = rateLimiterService.resolveBucket(userId);

    try {
        if (bucket.tryConsume(1)) {
            return ResponseEntity.ok("Data (Token consumed)");
        } else {
            return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
        }
    } catch (Exception e) {
        // (Quan trọng) Ghi log lỗi kết nối Redis
        log.warn("⚠️ Redis connection error during rate limiting. Failing OPEN for user {}.", userId);
        
        // **FAIL OPEN**: Coi như thành công và cho phép request
        return ResponseEntity.ok("Data (Redis down, request allowed)");
    }
}

Kết luận

Bằng cách kết hợp Bucket4j (logic), Redisson (cầu nối JCache) và Redis (lưu trữ), bạn có một hệ thống Rate Limiter phân tán mạnh mẽ, an toàn và linh hoạt.

  • Sử dụng Phương pháp Filter (YAML) cho các quy tắc bảo vệ tĩnh, chung chung (theo IP, theo Method, chống burst).
  • Sử dụng Phương pháp Programmatic (Service) khi logic rate limit phụ thuộc vào nghiệp vụ kinh doanh (như gói dịch vụ Free/Pro).

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí