Spring AOP + Guava RateLimiter 用注解实现优雅限流

最近在项目中遇到了一个真实场景:这是一个基于 MQ 触发的定时跑批任务。平日里风平浪静,可是一旦大促或者数据量激增,MQ 里的积压消息就会瞬间推送给消费者。

虽然消费者服务虽然处理得过来,但底层的核心业务数据库却扛不住了------大量并发查询瞬间打满 CPU,CPU 使用率飙升至 100%,直接影响了线上实时业务的稳定性。

考虑到该服务是单节点部署,引入 Redis 做分布式限流显得"杀鸡用牛刀",也增加了额外的运维成本。最终,我决定使用 Spring AOP + Guava RateLimiter + 自定义注解 ,实现一个 无侵入、可配置、轻量级单机限流组件


一、 为什么选择 AOP + 注解?

在介绍代码之前,先明确设计初衷。

以前我刚接触开发时,也喜欢在 Service 或 Controller 层直接硬编码限流逻辑,例如:

复制代码
// ❌ 反例:硬编码,逻辑混杂且难以复用
if (!rateLimiter.tryAcquire()) {
    throw new RuntimeException("系统繁忙");
}
doBusiness();

这种写法的弊端很明显:

  1. 逻辑混杂:清晰的业务代码中夹杂着非业务的限流判断。
  2. 复用性差:如果有十个接口需要限流,就需要重复编写十次。
  3. 维护困难:一旦需要调整限流策略(例如升级为分布式限流),涉及的修改点将非常多。

AOP(面向切面编程) 的核心就是 "解耦""复用"

我将限流逻辑封装为一个独立的"切面",配合自定义注解作为"开关"。只需在目标方法上添加一个注解,限流策略随即生效。后续的维护与升级,也仅需聚焦于切面逻辑本身,无需触碰任何业务代码。


二、 Guava RateLimiter 核心原理

我这次选用的核心库是 Google Guava 的 RateLimiter。它是基于 令牌桶算法(Token Bucket) 实现的。

1. 简单回顾令牌桶

它的机制不像"漏桶"那样死板(恒定速率流出),而是更加人性化:

  • 生产令牌:系统以固定速率向桶中放入令牌。
  • 消费令牌:请求过来时,必须先拿到令牌才能执行。
  • 关键特性支持突发流量。如果一段时间没有请求,桶里的令牌会积攒起来(直到达到桶上限)。当一波突发流量到来时,可以直接消耗积攒的令牌立刻执行,而不需要排队等待。

2. 两种核心模式

Guava 贴心地提供了两种实现:

  1. SmoothBursty(平滑突发)默认模式。适合大多数场景,允许短时间的流量突发。
  2. SmoothWarmingUp(平滑预热)预热模式。启动初期令牌发放速率较慢,随着时间推移逐步提升到目标 QPS。这对于需要"热身"的资源(如数据库连接池、缓存填充)非常友好,防止冷启动时瞬间被打挂。

3. 单机版警告 ⚠️

注意Guava RateLimiter单机限流 工具!令牌是存在当前 JVM 内存里的。

  • 如果你的服务只部署一台机器,它完美胜任。
  • 如果你部署了 10 台机器,每台设置 QPS=5,那么整个集群的总 QPS 上限是 50。

4. 常用 API 详解

熟练掌握 API 是实战的基础,以下是 RateLimiter 的核心方法:

核心创建方法

方法签名 说明
create(double permitsPerSecond) 创建 SmoothBursty 限流器,指定每秒生成的令牌数(默认:permitsPerSecond = QPS = 桶容量)。
create(double permitsPerSecond, long warmupPeriod, TimeUnit unit) 创建 SmoothWarmingUp 限流器,指定 QPS + 预热时间。

核心获取方法

方法签名 说明
double acquire() 阻塞式 获取 1 个令牌。若无令牌,线程会一直等待,直到获取成功。
double acquire(int permits) 阻塞式获取指定数量的令牌(可一次获取多个)。
boolean tryAcquire() 非阻塞式 获取 1 个令牌。立即返回:成功 true,失败 false(不等待)。
boolean tryAcquire(long timeout, TimeUnit unit) 限时等待 获取 1 个令牌。在超时时间内拿到返回 true,否则返回 false。这是最推荐的用法,既避免了线程死等,又提供了一定的缓冲。

三、 代码实战:打造企业级限流组件

接下来,我来实现一个功能完备的 @RateLimit 组件,支持QPS配置、阻塞/非阻塞模式、超时控制以及预热模式

1. 引入依赖

复制代码
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>32.1.3-jre</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2. 定义注解 @RateLimit

这个注解承载了限流的所有配置元数据。

复制代码
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {

    /**
     * 限流阈值 (QPS),默认每秒 5 个
     */
    double qps() default 5.0;

    /**
     * 获取令牌的策略
     * true: 阻塞模式(直到拿到令牌或超时)
     * false: 非阻塞模式(拿不到立即失败)
     */
    boolean block() default true;

    /**
     * 阻塞等待的超时时间(仅当 block=true 时生效)
     * 默认 0,表示无限等待
     */
    long timeout() default 0;

    /**
     * 超时时间单位
     */
    TimeUnit timeUnit() default TimeUnit.MILLISECONDS;

    /**
     * 预热时间
     * 默认 0 (SmoothBursty);设置 >0 则开启预热模式 (SmoothWarmingUp)
     */
    long warmupPeriod() default 0;

    /**
     * 预热时间单位
     */
    TimeUnit warmupUnit() default TimeUnit.SECONDS;

    /**
     * 限流提示信息
     */
    String message() default "系统繁忙,请稍后再试";
}

3. 定义全局异常 RateLimitException

复制代码
public class RateLimitException extends RuntimeException {
    public RateLimitException(String message) {
        super(message);
    }
}

4. 实现切面 RateLimitAop

这是限流组件的"大脑"。需要重点关注实例缓存、线程安全以及不同策略的执行逻辑。

复制代码
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.annotation.Pointcut;
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 RateLimitAop {
    
    // 使用 ConcurrentHashMap 缓存 RateLimiter 实例,确保线程安全
    // Key: 方法签名 (类名.方法名(参数类型)), Value: 限流器实例
    private final Map<String, RateLimiter> rateLimiterCache = new ConcurrentHashMap<>();

    @Pointcut("@annotation(com.example.annotation.RateLimit)")
    public void rateLimitPointcut() {}

    @Around("rateLimitPointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        RateLimit annotation = method.getAnnotation(RateLimit.class);

        // 1. 构建方法唯一 Key,防止方法重载冲突
        String methodKey = buildMethodKey(method);
        
        // 2. 线程安全地创建或获取限流器
        RateLimiter rateLimiter = rateLimiterCache.computeIfAbsent(methodKey, key -> createRateLimiter(annotation));

        // 3. 执行获取令牌逻辑
        boolean acquireSuccess;
        if (annotation.block()) {
            // --- 阻塞模式 ---
            if (annotation.timeout() <= 0) {
                // 无限等待,直到成功
                rateLimiter.acquire();
                acquireSuccess = true;
            } else {
                // 限时等待
                acquireSuccess = rateLimiter.tryAcquire(annotation.timeout(), annotation.timeUnit());
            }
        } else {
            // --- 非阻塞模式 ---
            // 立即尝试,失败即返回
            acquireSuccess = rateLimiter.tryAcquire();
        }

        // 4. 限流拦截
        if (!acquireSuccess) {
            log.warn("【限流报警】方法 {} 请求频率过高,已拒绝。", methodKey);
            throw new RateLimitException(annotation.message());
        }

        // 5. 放行
        return joinPoint.proceed();
    }

    /**
     * 生成方法签名:Package.Class.Method(ParamType1,ParamType2)
     */
    private String buildMethodKey(Method method) {
        StringBuilder keyBuilder = new StringBuilder();
        keyBuilder.append(method.getDeclaringClass().getName())
                .append(".").append(method.getName()).append("(");
        Class<?>[] parameterTypes = method.getParameterTypes();
        for (int i = 0; i < parameterTypes.length; i++) {
            keyBuilder.append(parameterTypes[i].getSimpleName());
            if (i < parameterTypes.length - 1) {
                keyBuilder.append(",");
            }
        }
        keyBuilder.append(")");
        return keyBuilder.toString();
    }

    /**
     * 工厂方法:根据配置创建具体的 RateLimiter
     */
    private RateLimiter createRateLimiter(RateLimit annotation) {
        if (annotation.warmupPeriod() > 0) {
            log.info("创建预热限流器: QPS={}, Warmup={}s", annotation.qps(), annotation.warmupPeriod());
            return RateLimiter.create(annotation.qps(), annotation.warmupPeriod(), annotation.warmupUnit());
        } else {
            log.info("创建标准限流器: QPS={}", annotation.qps());
            return RateLimiter.create(annotation.qps());
        }
    }
}

5. 业务接入示例

复制代码
@Service
public class DataSyncService {

    // 场景1:核心数据同步,允许排队等待500ms,保证尽可能执行
    @RateLimit(qps = 10.0, block = true, timeout = 500)
    public void syncImportantData(List<Data> dataList) {
        // ... 业务逻辑 ...
    }

    // 场景2:非核心接口,流量大时直接丢弃,保护系统
    @RateLimit(qps = 50.0, block = false, message = "当前访问人数过多")
    public void refreshCache() {
        // ... 刷新逻辑 ...
    }
}

四、 进阶:聊聊动态代理那个"大家都知道"的坑

在使用 AOP 时,有一个经典面试题级别的现象:类内方法自调用导致 AOP 失效。作为开发者,我们不止要知其然,更知其所以然。

场景重现

复制代码
@Service
public class TradeService {
    public void process() {
        // ... 前置处理 ...
        pay(); // ❌ 重点在这里:直接调用内部方法
    }

    @RateLimit(qps = 5.0) 
    public void pay() { ... }
}

为什么会失效?

Spring AOP 的底层使用的是 动态代理

  • 容器启动时,Spring 为 TradeService 生成了一个代理对象(Proxy)。
  • 外部调用 process() 时,先走的是代理。
  • 但在 process() 内部执行 pay() 时,使用的是 this.pay()这里的 this 指向的是目标对象本身,而非代理对象
  • 既然没经过代理,切面逻辑自然就像空气一样被穿透了。

避坑建议

针对此类问题,我推荐以下处理方式:

推荐:拆分大法(Best Practice)

pay() 方法拆分到另一个独立的 Bean(例如 PayService)中。通过注入的方式调用,天然符合"通过代理调用"的规则,代码结构也更清晰。

推荐:AopContext

直接从 Spring 上下文中捞取当前代理对象。(老功能修改)

  1. SpringBoot启动类上开启配置:@EnableAspectJAutoProxy(exposeProxy = true)
  2. 具体代码中修改:((TradeService) AopContext.currentProxy()).pay();

不推荐:@Autowired 注入自身

虽然能解决问题,但容易引发循环依赖异常,增加系统启动风险。


五、 进阶思考:从单机到分布式

前面我强调了 Guava RateLimiter单机限流。那么,如果系统做大了,部署了 50 个节点,需要对某个下游 API 做全局每秒 1000 次的限流,该怎么办?

这时候,AOP + 注解 设计模式的威力就体现出来了。

完全不需要 修改任何业务代码,也不用删掉 @RateLimit 注解。

你只需要做一个动作:修改 RateLimitAop 切面的实现

把切面里获取令牌的逻辑,从 Guava RateLimiter 换成 Redis + Lua 脚本,或者直接接入 RedissonRRateLimiter

复制代码
// 伪代码示例:无缝切换分布式限流
private RRateLimiter getRedisLimiter(String key) {
    RRateLimiter limiter = redissonClient.getRateLimiter(key);
    // ... 初始化 Redis 限流器 ...
    return limiter;
}

// 在 around 方法里,将 RateLimiter.tryAcquire() 替换为 Redisson 的实现
RRateLimiter limiter = getRedisLimiter(methodKey);
if (!limiter.tryAcquire(annotation.qps(), annotation.timeout(), annotation.timeUnit())) {
    throw new RateLimitException("分布式限流生效中...");
}

看,这就是架构设计的艺术。业务方无感知,底层能力平滑升级。

相关推荐
BUG指挥官12 小时前
Sa-Token和Spring Security对比
java·后端·spring
geminigoth13 小时前
Spring AI Alibaba 入门开发一(备份)
java·人工智能·spring
xbgRS15 小时前
spring整合mybaits
java·spring·mybatis
ruleslol15 小时前
静态依赖注入 VS 动态依赖查询
spring
墨雨晨曦8818 小时前
Spring AI总结
java·人工智能·spring
用户31268748772020 小时前
Spring Boot 异常处理到底怎么玩的?从 DispatcherServlet 到全局兜底的全链路拆解
spring
不才不才不不才20 小时前
Spring 源码系列(17): HandlerMapping 与 HandlerAdapter 两大体系
java·后端·spring
(轻舟已过万重山)21 小时前
第40章 Spring AI 实战:企业级 AI 应用架构
人工智能·spring·架构
yanaiding1 天前
C# WPF 独立开发看图工具 PixSight 经验介绍(二)—— 水印模块开发实录
图像处理·c#·wpf·个人开发
堕落年代2 天前
Ollama CPU 推理大提示词优化实测报告(细致化数据版)
java·后端·spring