缪小春 發表於 2025-12-29 10:01:00

Spring AOP + Guava RateLimiter:我是如何用注解实现优雅限流的?

<blockquote>
<p><strong>写在前面</strong></p>
<p>提起 AOP(面向切面编程),大家的第一反应往往是:“哦,那个用来打印日志、管理事务、或者做权限校验的。”</p>
<p>其实,AOP 的能力远不止于此。在面对高并发场景下的接口自我保护时,它同样能发挥奇效。</p>
</blockquote>
<p>最近在项目中遇到了一个真实场景:这是一个<strong>基于 MQ 触发的定时跑批任务</strong>。平日里风平浪静,可是一旦大促或者数据量激增,MQ 里的积压消息就会瞬间推送给消费者。</p>
<p>虽然消费者服务虽然处理得过来,但底层的<strong>核心业务数据库却扛不住了</strong>——大量并发查询瞬间打满 CPU,CPU 使用率飙升至 100%,直接影响了线上实时业务的稳定性。</p>
<p>考虑到该服务是单节点部署,引入 Redis 做分布式限流显得“杀鸡用牛刀”,也增加了额外的运维成本。最终,我决定使用 <strong>Spring AOP + Guava RateLimiter + 自定义注解</strong>,实现一个 <strong>无侵入、可配置、轻量级</strong> 的<strong>单机限流组件</strong>。<br>
<img src="https://img2024.cnblogs.com/blog/3703499/202512/3703499-20251229094620348-1550169622.png"></p>
<hr>
<h2 id="一-为什么选择-aop--注解">一、 为什么选择 AOP + 注解?</h2>
<p>在介绍代码之前,先明确设计初衷。</p>
<p>以前我刚接触开发时,也喜欢在 Service 或 Controller 层直接硬编码限流逻辑,例如:</p>
<pre><code class="language-java">// ❌ 反例:硬编码,逻辑混杂且难以复用
if (!rateLimiter.tryAcquire()) {
    throw new RuntimeException("系统繁忙");
}
doBusiness();
</code></pre>
<p>这种写法的弊端很明显:</p>
<ol>
<li><strong>逻辑混杂</strong>:清晰的业务代码中夹杂着非业务的限流判断。</li>
<li><strong>复用性差</strong>:如果有十个接口需要限流,就需要重复编写十次。</li>
<li><strong>维护困难</strong>:一旦需要调整限流策略(例如升级为分布式限流),涉及的修改点将非常多。</li>
</ol>
<p><strong>AOP(面向切面编程)</strong> 的核心就是 <strong>“解耦”</strong> 和 <strong>“复用”</strong>。</p>
<p>我将限流逻辑封装为一个独立的“切面”,配合自定义注解作为“开关”。<strong>只需在目标方法上添加一个注解,限流策略随即生效</strong>。后续的维护与升级,也仅需聚焦于切面逻辑本身,无需触碰任何业务代码。</p>
<hr>
<h2 id="二-guava-ratelimiter-核心原理">二、 Guava RateLimiter 核心原理</h2>
<p>我这次选用的核心库是 Google Guava 的 <code>RateLimiter</code>。它是基于 <strong>令牌桶算法(Token Bucket)</strong> 实现的。</p>
<h3 id="1-简单回顾令牌桶">1. 简单回顾令牌桶</h3>
<p>它的机制不像“漏桶”那样死板(恒定速率流出),而是更加人性化:</p>
<ul>
<li><strong>生产令牌</strong>:系统以固定速率向桶中放入令牌。</li>
<li><strong>消费令牌</strong>:请求过来时,必须先拿到令牌才能执行。</li>
<li><strong>关键特性</strong>:<strong>支持突发流量</strong>。如果一段时间没有请求,桶里的令牌会积攒起来(直到达到桶上限)。当一波突发流量到来时,可以直接消耗积攒的令牌立刻执行,而不需要排队等待。</li>
</ul>
<h3 id="2-两种核心模式">2. 两种核心模式</h3>
<p>Guava 贴心地提供了两种实现:</p>
<ol>
<li><strong>SmoothBursty(平滑突发)</strong>:<strong>默认模式</strong>。适合大多数场景,允许短时间的流量突发。</li>
<li><strong>SmoothWarmingUp(平滑预热)</strong>:<strong>预热模式</strong>。启动初期令牌发放速率较慢,随着时间推移逐步提升到目标 QPS。这对于需要“热身”的资源(如数据库连接池、缓存填充)非常友好,防止冷启动时瞬间被打挂。</li>
</ol>
<h3 id="3-单机版警告-️">3. 单机版警告 ⚠️</h3>
<p><strong>注意</strong>:<code>Guava RateLimiter</code> 是 <strong>单机限流</strong> 工具!令牌是存在当前 JVM 内存里的。</p>
<ul>
<li>如果你的服务只部署一台机器,它完美胜任。</li>
<li>如果你部署了 10 台机器,每台设置 QPS=5,那么整个集群的总 QPS 上限是 50。</li>
</ul>
<h3 id="4-常用-api-详解">4. 常用 API 详解</h3>
<p>熟练掌握 API 是实战的基础,以下是 <code>RateLimiter</code> 的核心方法:</p>
<p><strong>核心创建方法</strong></p>
<table>
<thead>
<tr>
<th style="text-align: left">方法签名</th>
<th style="text-align: left">说明</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left"><code>create(double permitsPerSecond)</code></td>
<td style="text-align: left">创建 <strong>SmoothBursty</strong> 限流器,指定每秒生成的令牌数(默认:permitsPerSecond = QPS = 桶容量)。</td>
</tr>
<tr>
<td style="text-align: left"><code>create(double permitsPerSecond, long warmupPeriod, TimeUnit unit)</code></td>
<td style="text-align: left">创建 <strong>SmoothWarmingUp</strong> 限流器,指定 QPS + 预热时间。</td>
</tr>
</tbody>
</table>
<p><strong>核心获取方法</strong></p>
<table>
<thead>
<tr>
<th style="text-align: left">方法签名</th>
<th style="text-align: left">说明</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left"><code>double acquire()</code></td>
<td style="text-align: left"><strong>阻塞式</strong>获取 1 个令牌。若无令牌,线程会<strong>一直等待</strong>,直到获取成功。</td>
</tr>
<tr>
<td style="text-align: left"><code>double acquire(int permits)</code></td>
<td style="text-align: left"><strong>阻塞式</strong>获取指定数量的令牌(可一次获取多个)。</td>
</tr>
<tr>
<td style="text-align: left"><code>boolean tryAcquire()</code></td>
<td style="text-align: left"><strong>非阻塞式</strong>获取 1 个令牌。立即返回:成功 <code>true</code>,失败 <code>false</code>(不等待)。</td>
</tr>
<tr>
<td style="text-align: left"><code>boolean tryAcquire(long timeout, TimeUnit unit)</code></td>
<td style="text-align: left"><strong>限时等待</strong>获取 1 个令牌。在超时时间内拿到返回 <code>true</code>,否则返回 <code>false</code>。这是最推荐的用法,既避免了线程死等,又提供了一定的缓冲。</td>
</tr>
</tbody>
</table>
<hr>
<h2 id="三-代码实战打造企业级限流组件">三、 代码实战:打造企业级限流组件</h2>
<p>接下来,我来实现一个功能完备的 <code>@RateLimit</code> 组件,支持<strong>QPS配置、阻塞/非阻塞模式、超时控制以及预热模式</strong>。</p>
<h3 id="1-引入依赖">1. 引入依赖</h3>
<pre><code class="language-xml">&lt;dependency&gt;
    &lt;groupId&gt;com.google.guava&lt;/groupId&gt;
    &lt;artifactId&gt;guava&lt;/artifactId&gt;
    &lt;version&gt;32.1.3-jre&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
    &lt;artifactId&gt;spring-boot-starter-aop&lt;/artifactId&gt;
&lt;/dependency&gt;
</code></pre>
<h3 id="2-定义注解-ratelimit">2. 定义注解 <code>@RateLimit</code></h3>
<p>这个注解承载了限流的所有配置元数据。</p>
<pre><code class="language-java">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);设置 &gt;0 则开启预热模式 (SmoothWarmingUp)
   */
    long warmupPeriod() default 0;

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

    /**
   * 限流提示信息
   */
    String message() default "系统繁忙,请稍后再试";
}
</code></pre>
<h3 id="3-定义全局异常-ratelimitexception">3. 定义全局异常 <code>RateLimitException</code></h3>
<pre><code class="language-java">public class RateLimitException extends RuntimeException {
    public RateLimitException(String message) {
      super(message);
    }
}
</code></pre>
<h3 id="4-实现切面-ratelimitaop">4. 实现切面 <code>RateLimitAop</code></h3>
<p>这是限流组件的“大脑”。需要重点关注实例缓存、线程安全以及不同策略的执行逻辑。</p>
<pre><code class="language-java">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&lt;String, RateLimiter&gt; rateLimiterCache = new ConcurrentHashMap&lt;&gt;();

    @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 -&gt; createRateLimiter(annotation));

      // 3. 执行获取令牌逻辑
      boolean acquireSuccess;
      if (annotation.block()) {
            // --- 阻塞模式 ---
            if (annotation.timeout() &lt;= 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&lt;?&gt;[] parameterTypes = method.getParameterTypes();
      for (int i = 0; i &lt; parameterTypes.length; i++) {
            keyBuilder.append(parameterTypes.getSimpleName());
            if (i &lt; parameterTypes.length - 1) {
                keyBuilder.append(",");
            }
      }
      keyBuilder.append(")");
      return keyBuilder.toString();
    }

    /**
   * 工厂方法:根据配置创建具体的 RateLimiter
   */
    private RateLimiter createRateLimiter(RateLimit annotation) {
      if (annotation.warmupPeriod() &gt; 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());
      }
    }
}
</code></pre>
<h3 id="5-业务接入示例">5. 业务接入示例</h3>
<pre><code class="language-java">@Service
public class DataSyncService {

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

    // 场景2:非核心接口,流量大时直接丢弃,保护系统
    @RateLimit(qps = 50.0, block = false, message = "当前访问人数过多")
    public void refreshCache() {
      // ... 刷新逻辑 ...
    }
}
</code></pre>
<hr>
<h2 id="四-进阶聊聊动态代理那个大家都知道的坑">四、 进阶:聊聊动态代理那个“大家都知道”的坑</h2>
<p>在使用 AOP 时,有一个经典面试题级别的现象:<strong>类内方法自调用导致 AOP 失效</strong>。作为开发者,我们不止要知其然,更知其所以然。</p>
<h3 id="场景重现">场景重现</h3>
<pre><code class="language-java">@Service
public class TradeService {
    public void process() {
      // ... 前置处理 ...
      pay(); // ❌ 重点在这里:直接调用内部方法
    }

    @RateLimit(qps = 5.0)
    public void pay() { ... }
}
</code></pre>
<h3 id="为什么会失效">为什么会失效?</h3>
<p>Spring AOP 的底层使用的是 <strong>动态代理</strong>。</p>
<ul>
<li>容器启动时,Spring 为 <code>TradeService</code> 生成了一个代理对象(Proxy)。</li>
<li>外部调用 <code>process()</code> 时,先走的是代理。</li>
<li>但在 <code>process()</code> 内部执行 <code>pay()</code> 时,使用的是 <code>this.pay()</code>。<strong>这里的 <code>this</code> 指向的是目标对象本身,而非代理对象</strong>。</li>
<li>既然没经过代理,切面逻辑自然就像空气一样被穿透了。</li>
</ul>
<h3 id="避坑建议">避坑建议</h3>
<p>针对此类问题,我推荐以下处理方式:</p>
<p><strong>推荐:拆分大法(Best Practice)</strong></p>
<p>将 <code>pay()</code> 方法拆分到另一个独立的 Bean(例如 <code>PayService</code>)中。通过注入的方式调用,天然符合“通过代理调用”的规则,代码结构也更清晰。</p>
<p><strong>推荐:AopContext</strong></p>
<p>直接从 Spring 上下文中捞取当前代理对象。(老功能修改)</p>
<ol>
<li>SpringBoot启动类上开启配置:<code>@EnableAspectJAutoProxy(exposeProxy = true)</code></li>
<li>具体代码中修改:<code>((TradeService) AopContext.currentProxy()).pay();</code></li>
</ol>
<p><strong>不推荐:@Autowired 注入自身</strong></p>
<p>虽然能解决问题,但容易引发<strong>循环依赖</strong>异常,增加系统启动风险。</p>
<hr>
<h2 id="五-进阶思考从单机到分布式">五、 进阶思考:从单机到分布式</h2>
<p>前面我强调了 <code>Guava RateLimiter</code> 是<strong>单机限流</strong>。那么,如果系统做大了,部署了 50 个节点,需要对某个下游 API 做全局每秒 1000 次的限流,该怎么办?</p>
<p><strong>这时候,AOP + 注解 设计模式的威力就体现出来了。</strong></p>
<p>你<strong>完全不需要</strong>修改任何业务代码,也不用删掉 <code>@RateLimit</code> 注解。<br>
你只需要做一个动作:<strong>修改 <code>RateLimitAop</code> 切面的实现</strong>。</p>
<p>把切面里获取令牌的逻辑,从 <code>Guava RateLimiter</code> 换成 <strong>Redis + Lua</strong> 脚本,或者直接接入 <strong>Redisson</strong> 的 <code>RRateLimiter</code>。</p>
<pre><code class="language-java">// 伪代码示例:无缝切换分布式限流
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("分布式限流生效中...");
}
</code></pre>
<p>看,这就是架构设计的艺术。业务方无感知,底层能力平滑升级。</p>
<hr>
<h2 id="六-总结与结语">六、 总结与结语</h2>
<p>总的来说,AOP 让限流这类“基础设施”悄无声息地融入了业务脉络,这正是优雅架构的魅力所在——<strong>将复杂性收敛于一点,在别处换来 simplicity</strong>。</p>
<p>最后,想起一句被反复“魔改”的名言,放在这里格外贴切:<strong>“让架构的归架构,让业务的归业务”</strong>。</p>
<p>愿各位的代码世界,秩序井然,bug 退散。</p><br><br>
来源:https://www.cnblogs.com/xzqcsj/p/19413883
頁: [1]
查看完整版本: Spring AOP + Guava RateLimiter:我是如何用注解实现优雅限流的?