令牌桶算法的限流组件实现

令牌桶算法的限流组件实现

前言

在高并发场景下,为了保护系统资源不被过度消耗,限流是一种非常重要的技术手段。

限流(Rate Limiting)是指限制系统在单位时间内处理请求的数量,防止系统因为突发流量而崩溃。常见的限流算法包括:

  • 固定窗口计数器:在固定时间窗口内统计请求数量
  • 滑动窗口计数器:更精确的时间窗口统计
  • 漏桶算法(Leaky Bucket):以固定速率处理请求,超出部分丢弃或排队
  • 令牌桶算法(Token Bucket):以固定速率生成令牌,请求需要获取令牌才能执行

本文介绍的组件采用的是令牌桶算法

令牌桶算法原理

令牌桶算法的核心思想:

  1. 令牌生成:系统以恒定速率(如每秒 100 个)向桶中添加令牌
  2. 令牌存储:桶有最大容量,令牌满了就不再添加
  3. 令牌消费:每个请求需要从桶中获取一个令牌才能执行
  4. 请求处理
    • 如果桶中有令牌,取出令牌,请求通过
    • 如果桶中没有令牌,请求被拒绝或等待

单机版限流组件(基于 Guava RateLimiter)

核心注解定义

首先定义一个 @Limit 注解,用于标记需要限流的方法:

java 复制代码
package com.example.annotation;

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface Limit {
    /**
     * 资源的唯一标识 key
     * 不同的接口可以使用不同的 key 实现独立的流量控制
     */
    String key() default "";

    /**
     * 每秒允许的请求数量(QPS)
     * 这是令牌桶的生成速率
     */
    double permitsPerSecond();
    
    /**
     * 获取令牌的超时时间
     * 0 表示立即返回,不等待
     */
    long timeout();

    /**
     * 超时时间的单位
     * 默认为毫秒
     */
    TimeUnit timeunit() default TimeUnit.MILLISECONDS;
    
    /**
     * 限流时返回的提示信息
     */
    String msg() default "系统繁忙,请稍后再试";
}

注解参数说明

  • key:资源标识符,用于区分不同的接口或资源,实现细粒度的流量控制
  • permitsPerSecond:每秒允许通过的请求数,即 QPS(Queries Per Second)
  • timeout:尝试获取令牌的等待时间,0 表示非阻塞模式
  • timeunit:时间单位,可以是毫秒、秒等
  • msg:触发限流时返回给用户的提示信息

AOP 切面实现

使用 Spring AOP 拦截带有 @Limit 注解的方法:

java 复制代码
package com.example.aop;

import com.example.annotation.Limit;
import com.google.common.util.concurrent.RateLimiter;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
@Aspect
@Component
public class LimitAspect {

    /**
     * 使用 ConcurrentHashMap 存储不同资源的 RateLimiter
     * key: 资源标识符
     * value: 对应的令牌桶限流器
     */
    private final Map<String, RateLimiter> limitMap = new ConcurrentHashMap<>();
    
    /**
     * 环绕通知:拦截所有带有 @Limit 注解的方法
     */
    @Around("@annotation(com.example.annotation.Limit)")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        // 1. 获取方法签名和注解信息
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        Method method = signature.getMethod();
        Limit limit = method.getAnnotation(Limit.class);
        
        if (limit != null) {
            String key = limit.key();
            
            // 2. 获取或创建 RateLimiter(线程安全)
            // computeIfAbsent 保证了在并发情况下,同一个 key 只会创建一个 RateLimiter
            RateLimiter rateLimiter = limitMap.computeIfAbsent(key, k -> {
                log.info("新建了令牌桶={},容量={}", k, limit.permitsPerSecond());
                return RateLimiter.create(limit.permitsPerSecond());
            });
            
            // 3. 记录请求信息
            long requestTime = System.currentTimeMillis();
            log.info("请求到达,key={},时间={},当前速率={}", 
                    key, requestTime, rateLimiter.getRate());
            
            // 4. 尝试获取令牌
            boolean acquire = rateLimiter.tryAcquire(limit.timeout(), limit.timeunit());

            // 5. 令牌获取失败,触发限流
            if (!acquire) {
                log.warn("令牌桶={},获取令牌失败,时间={}", key, System.currentTimeMillis());
                throw new RuntimeException(limit.msg());
            }
            
            // 6. 令牌获取成功,记录日志
            log.info("令牌桶={},获取令牌成功,时间={}", key, System.currentTimeMillis());
        }
        
        // 7. 执行目标方法
        return pjp.proceed();
    }
}

实现细节解析

  1. ConcurrentHashMap 的使用

    • 保证多线程环境下的线程安全
    • 每个资源 key 对应一个独立的 RateLimiter 实例
    • 不同接口之间的限流互不影响
  2. computeIfAbsent 的妙用

    • 原子性操作,避免并发创建多个 RateLimiter
    • 只在 key 不存在时才创建新实例
    • 懒加载模式,节省资源
  3. tryAcquire 方法

    • 非阻塞或限时阻塞获取令牌
    • timeout=0 时立即返回,不等待
    • timeout>0 时会等待指定时间,期间如果有令牌生成则获取成功
  4. 日志记录

    • 记录令牌桶的创建
    • 记录每次请求的到达时间和当前速率
    • 记录令牌获取的成功或失败

使用示例

在 Controller 中使用 @Limit 注解:

java 复制代码
package com.example.controller;

import com.example.annotation.Limit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class TestController {

    /**
     * 限流配置:
     * - key: "limit" - 资源标识
     * - permitsPerSecond: 100 - 每秒允许 100 个请求
     * - timeout: 0 - 不等待,立即返回
     * - msg: 自定义限流提示信息
     */
    @Limit(key = "limit", permitsPerSecond = 100, timeout = 0, 
           msg = "系统繁忙,请稍后再试!")
    @GetMapping("/limit/test")
    public String test() {
        log.info("Test");
        return "Hello World";
    }
}

单机版的优缺点

优点

  • ✅ 实现简单,代码量少
  • ✅ 性能优秀,基于内存操作
  • ✅ Guava RateLimiter 久经考验,稳定可靠
  • ✅ 支持平滑限流和突发流量处理

缺点

  • ❌ 只能在单机环境下使用
  • ❌ 无法在分布式系统中共享限流状态
  • ❌ 应用重启后限流状态丢失
  • ❌ 多实例部署时,总 QPS = 单实例 QPS × 实例数

Redis 限流原理

Redis 实现限流主要有两种方案:

  1. 基于 Redis 计数器:使用 INCR + EXPIRE 实现固定窗口计数
  2. 基于 Lua 脚本的令牌桶:使用 Lua 脚本保证原子性

本文采用 Lua 脚本实现令牌桶算法,确保分布式环境下的一致性。

Redis 版注解定义

java 复制代码
package com.example.annotation;

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface RedisLimit {
    /**
     * 资源的唯一标识 key
     * 会自动添加前缀 "rate_limit:"
     */
    String key() default "";

    /**
     * 每秒允许的请求数量(QPS)
     */
    double permitsPerSecond();
    
    /**
     * 令牌桶的最大容量
     * 默认等于 permitsPerSecond,可以设置更大以支持突发流量
     */
    double maxPermits() default 0;
    
    /**
     * 获取令牌的超时时间
     */
    long timeout() default 0;

    /**
     * 超时时间的单位
     */
    TimeUnit timeunit() default TimeUnit.MILLISECONDS;
    
    /**
     * 限流时返回的提示信息
     */
    String msg() default "系统繁忙,请稍后再试";
}

Lua 脚本实现令牌桶

创建 Lua 脚本文件 rate_limiter.lua

lua 复制代码
-- 令牌桶限流 Lua 脚本
-- KEYS[1]: 令牌桶的 key
-- ARGV[1]: 令牌桶容量(最大令牌数)
-- ARGV[2]: 每秒生成的令牌数
-- ARGV[3]: 当前时间戳(毫秒)
-- ARGV[4]: 需要消耗的令牌数(通常为 1)

local key = KEYS[1]
local max_permits = tonumber(ARGV[1])
local permits_per_second = tonumber(ARGV[2])
local current_time = tonumber(ARGV[3])
local required_permits = tonumber(ARGV[4])

-- 获取当前令牌桶状态
local bucket = redis.call('HMGET', key, 'last_time', 'current_permits')
local last_time = tonumber(bucket[1])
local current_permits = tonumber(bucket[2])

-- 初始化令牌桶
if last_time == nil then
    last_time = current_time
    current_permits = max_permits
end

-- 计算时间间隔(秒)
local time_elapsed = math.max(0, (current_time - last_time) / 1000)

-- 计算新增的令牌数
local new_permits = math.min(max_permits, current_permits + time_elapsed * permits_per_second)

-- 尝试获取令牌
local allowed = 0
if new_permits >= required_permits then
    new_permits = new_permits - required_permits
    allowed = 1
end

-- 更新令牌桶状态
redis.call('HMSET', key, 'last_time', current_time, 'current_permits', new_permits)
redis.call('EXPIRE', key, 10)  -- 设置过期时间,防止内存泄漏

return allowed

Lua 脚本优势

  • 原子性:整个脚本在 Redis 中原子执行,不会被其他命令打断
  • 高性能:减少网络往返次数
  • 一致性:保证分布式环境下的数据一致性

Redis 版 AOP 切面实现

java 复制代码
package com.example.aop;

import com.example.annotation.RedisLimit;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.lang.reflect.Method;
import java.util.Collections;

@Slf4j
@Aspect
@Component
public class RedisLimitAspect {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * Lua 脚本对象
     */
    private DefaultRedisScript<Long> rateLimiterScript;

    /**
     * 初始化 Lua 脚本
     */
    @PostConstruct
    public void init() {
        rateLimiterScript = new DefaultRedisScript<>();
        rateLimiterScript.setResultType(Long.class);
        // 从 classpath 加载 Lua 脚本
        rateLimiterScript.setScriptSource(
            new ResourceScriptSource(new ClassPathResource("scripts/rate_limiter.lua"))
        );
    }

    /**
     * 环绕通知:拦截所有带有 @RedisLimit 注解的方法
     */
    @Around("@annotation(com.example.annotation.RedisLimit)")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        // 1. 获取方法签名和注解信息
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        Method method = signature.getMethod();
        RedisLimit limit = method.getAnnotation(RedisLimit.class);
        
        if (limit != null) {
            String key = "rate_limit:" + limit.key();
            double permitsPerSecond = limit.permitsPerSecond();
            double maxPermits = limit.maxPermits() > 0 ? 
                               limit.maxPermits() : permitsPerSecond;
            
            // 2. 记录请求信息
            long requestTime = System.currentTimeMillis();
            log.info("Redis限流 - 请求到达,key={},时间={},速率={}/s", 
                    key, requestTime, permitsPerSecond);
            
            // 3. 执行 Lua 脚本获取令牌
            Long result = redisTemplate.execute(
                rateLimiterScript,
                Collections.singletonList(key),
                String.valueOf(maxPermits),
                String.valueOf(permitsPerSecond),
                String.valueOf(requestTime),
                "1"  // 需要消耗的令牌数
            );
            
            // 4. 判断是否获取到令牌
            if (result == null || result == 0) {
                log.warn("Redis限流 - 获取令牌失败,key={},时间={}", 
                        key, System.currentTimeMillis());
                throw new RuntimeException(limit.msg());
            }
            
            // 5. 令牌获取成功
            log.info("Redis限流 - 获取令牌成功,key={},时间={}", 
                    key, System.currentTimeMillis());
        }
        
        // 6. 执行目标方法
        return pjp.proceed();
    }
}

实现要点

. Lua 脚本加载

  • 使用 @PostConstruct 在 Bean 初始化时加载脚本
  • 脚本文件放在 resources/scripts/ 目录下

两种方案对比

特性 Guava 单机版 Redis 分布式版
适用场景 单机应用 分布式系统
性能 极高(内存操作) 较高(网络 IO)
一致性 单机一致 分布式一致
状态持久化 不支持 支持
实现复杂度 简单 中等
依赖 Guava Redis
扩展性
故障影响 单机故障 Redis 故障影响全局
相关推荐
聊天QQ:276998856 天前
多智能体分布式模型预测控制在编队中的奇幻之旅
guava
武子康7 天前
Java-193 Spymemcached 深入解析:线程模型、Sharding 与序列化实践全拆解
java·开发语言·redis·缓存·系统架构·memcached·guava
武子康8 天前
Java-192 深入拆解 EVCache 内部原理:Memcached 架构、Slab 分配与 LRU 过期机制全解析
数据库·redis·缓存·架构·memcached·guava·evcache
武子康9 天前
Java-190 EVCache入门:Netflix 级分布式缓存架构、性能指标与多区域部署全解析
java·redis·分布式·缓存·架构·guava·guava cache
武子康9 天前
Java-189 Guava Cache 源码剖析:LocalCache、Segment 与 LoadingCache 工作原理全解析
java·redis·后端·spring·缓存·guava·guava cache
Dolphin_Home9 天前
【实用工具类】基于 Guava Cache 实现通用 Token 缓存工具类(附完整源码)
spring·缓存·guava
武子康10 天前
Java-187 Guava Cache 并发参数与 refreshAfterWrite 实战:LoadingCache 动态加载与自定义 LRU 全解析
java·开发语言·spring boot·redis·spring·缓存·guava
武子康10 天前
Java-188 Guava Cache 疑难问题实战:OOM、过期清理、命中率异常和阻塞排查
java·spring boot·redis·后端·spring·缓存·guava