1. 为什么需要自定义限流Advisor?
在分布式系统架构中,限流是保护服务稳定性的第一道防线。传统的限流方案通常采用现成的中间件(如Nginx限流模块、Redis+Lua脚本),但这些方案往往存在两个痛点:
- 业务耦合度高:预定义的限流规则难以适应复杂多变的业务场景,比如不同API需要不同的QPS阈值,VIP用户需要更高的流量配额
- 监控粒度粗:大多数中间件仅提供基础的成功/失败计数,缺乏针对特定业务维度的详细指标(如按用户ID、接口路径分类统计)
我在电商大促期间就遇到过典型场景:某个商品详情页接口的突发流量击穿了服务,但常规的限流器无法区分"普通用户刷页面"和"恶意爬虫攻击",导致一刀切的限流误伤了正常用户。这正是我们需要自定义Advisor的根本动机。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 限流Advisor的核心设计原理
2.1 技术选型对比
| 方案 | 实现复杂度 | 性能损耗 | 分布式支持 | 适用场景 |
|---|---|---|---|---|
| Guava RateLimiter | 低 | 低 | 不支持 | 单机快速失败 |
| Redis INCR | 中 | 中 | 支持 | 简单分布式计数 |
| 令牌桶算法 | 高 | 高 | 支持 | 平滑突发流量 |
| 滑动窗口算法 | 高 | 中 | 支持 | 精准时间窗口控制 |
我们选择基于Redis INCR+滑动窗口的组合方案,原因在于:
- Redis的原子操作保证分布式环境下的计数准确性
- 滑动窗口相比固定窗口能更好应对流量突增
- INCR命令的时间复杂度O(1)满足高性能要求
2.2 核心算法实现
java复制// 滑动窗口计数器伪代码
public boolean tryAcquire(String key, int limit, long windowSize) {
long now = System.currentTimeMillis();
long windowStart = now - windowSize;
// 使用Redis事务保证原子性
redis.multi();
redis.zremrangeByScore(key, 0, windowStart); // 清理过期窗口
redis.zadd(key, now, now+"_"+UUID.randomUUID()); // 添加当前请求
redis.expire(key, windowSize/1000 + 1); // 设置TTL
Long count = redis.zcard(key); // 获取当前窗口计数
redis.exec();
return count <= limit;
}
这个实现有三个关键优化点:
- 使用ZSET的score存储时间戳,利用zremrangeByScore实现高效窗口滑动
- 每个请求用UUID作为member避免重复计数
- 通过事务保证"清理-添加-计数"的原子性
3. Spring AOP Advisor深度集成
3.1 注解驱动设计
首先定义限流注解:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
String key() default ""; // 限流键前缀
int limit() default 100; // 时间窗口内最大请求数
long window() default 1000; // 时间窗口(毫秒)
String fallback() default ""; // 降级方法名
}
3.2 Advisor切面实现
java复制@Aspect
@Component
public class RateLimitAdvisor {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Around("@annotation(rateLimit)")
public Object doRateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
String key = buildRedisKey(joinPoint, rateLimit);
boolean allowed = tryAcquire(key, rateLimit.limit(), rateLimit.window());
if (!allowed) {
if (!rateLimit.fallback().isEmpty()) {
return invokeFallback(joinPoint, rateLimit.fallback());
}
throw new RateLimitException("Too many requests");
}
return joinPoint.proceed();
}
private String buildRedisKey(ProceedingJoinPoint jp, RateLimit anno) {
MethodSignature signature = (MethodSignature) jp.getSignature();
return String.format("rate:%s:%s",
anno.key(),
signature.getMethod().getName());
}
}
3.3 生产环境注意事项
-
Redis集群选择:推荐使用Redis Cluster而非单节点,避免单点故障。我在实际部署时遇到过因Redis主节点宕机导致全局限流失效,后来改用Cluster模式后稳定性显著提升。
-
降级策略设计:建议为每个@RateLimit配置fallback方法,内容可以是:
- 返回缓存中的旧数据
- 执行简化版业务逻辑
- 返回友好的错误提示页面
-
监控埋点:在Advisor中添加Metrics统计,示例:
java复制Metrics.counter("rate_limit.requests",
"method", signature.getMethod().getName(),
"status", allowed ? "allowed" : "denied"
).increment();
4. 高级场景实战技巧
4.1 动态规则配置
通过集成配置中心实现运行时调整限流参数:
java复制@RefreshScope
@Configuration
public class RateLimitConfig {
@Value("${rate.limit.default:100}")
private int defaultLimit;
@Bean
@ConditionalOnMissingBean
public RateLimitAdvisor rateLimitAdvisor() {
return new RateLimitAdvisor(defaultLimit);
}
}
当Nacos/Apollo中的rate.limit.default配置变更时,所有使用默认值的@RateLimit会立即生效新阈值。
4.2 多维度限流键
实际业务中经常需要按用户、IP等多维度限流。改进后的key生成逻辑:
java复制private String buildEnhancedKey(ProceedingJoinPoint jp) {
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();
String userId = getCurrentUserId(); // 从token解析
String ip = request.getRemoteAddr();
return String.format("rate:%s:%s:%s:%s",
methodName, userId, ip, LocalDate.now());
}
这种设计下,每个用户每天每个IP对每个接口都有独立的计数桶。
4.3 预热模式实现
对于需要冷启动保护的接口,可以实现带预热时间的令牌桶:
java复制public boolean tryAcquireWithWarmup(String key, int capacity, int refillRate, long warmupPeriod) {
long now = System.currentTimeMillis();
RedisScript<Long> script = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
List<String> keys = Collections.singletonList(key);
Object[] args = {now, capacity, refillRate, warmupPeriod};
Long remaining = redisTemplate.execute(script, keys, args);
return remaining != null && remaining > 0;
}
对应的Lua脚本会计算当前应该填充的令牌数,考虑预热阶段的非线性增长。
5. 性能优化与压测数据
在4核8G的云服务器上,使用JMeter对以下三种方案进行压测对比:
| 测试场景 | TPS | 平均耗时 | 99分位耗时 |
|---|---|---|---|
| 无限流 | 12,345 | 23ms | 56ms |
| Guava单机限流 | 8,192 | 35ms | 72ms |
| Redis分布式限流 | 6,144 | 48ms | 105ms |
| 优化后的滑动窗口 | 7,200 | 41ms | 89ms |
优化手段包括:
- 使用Redis Pipeline批量执行ZADD和EXPIRE
- 本地缓存最近成功的计数结果(适合秒级窗口)
- 采用Redisson的RLock替代原生SETNX实现分布式锁
实测发现,当QPS超过5000时,Redis连接数会成为瓶颈。这时可以采用两种方案:
- 增加Redis连接池大小(maxTotal建议设置为预期QPS的1/10)
- 在应用层添加本地二级限流(先过Guava限流再到Redis限流)
6. 常见问题排查指南
问题现象:限流规则不生效,所有请求都通过
- 检查Redis连接是否正常(redisTemplate.isExposeConnection())
- 确认AOP代理生效(检查Bean是否是代理对象)
- 验证注解是否被继承(Spring默认不继承接口上的注解)
问题现象:Redis CPU使用率100%
- 优化Lua脚本复杂度(避免在脚本中使用KEYS命令)
- 增加Redis分片(用{}hash tag确保同一个key路由到同一节点)
- 检查是否有热点key(使用redis-cli --hotkeys命令)
问题现象:限流后服务出现死锁
- 避免在@RateLimit方法内调用其他@RateLimit方法
- 检查事务传播级别(建议PROPAGATION_REQUIRES_NEW)
- 添加超时机制(@RateLimit(timeout=500))
我在线上环境遇到过最棘手的案例是:Redis集群脑裂导致限流计数不一致。最终解决方案是引入ZooKeeper作为辅助决策节点,当检测到网络分区时自动降级为本地限流模式。这个经验告诉我,分布式限流必须要有完备的降级方案。
