算法不等于工程
前三篇把窗口、滑动窗口、令牌桶的算法备齐了,但每个接口里手写一段 tryAcquire 显然不现实:限流是横切关注点,该用横切的方式接入——AOP 切面统一处理,业务代码只留一个注解。本篇把单机限流从「算法」推进到「工程」,顺手补齐那些注解背后必须想清楚的细节。
一个注解走天下
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
String key(); // 限流标识,支持 SpEL
double qps() default 100; // 每秒放行数
long timeout() default 0; // 抢不到令牌最多等多久(毫秒),0 直接拒
}@Aspect
@Component
public class RateLimitAspect {
private final Map<String, RateLimiter> cache = new ConcurrentHashMap<>();
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
String key = parseKey(rateLimit.key(), pjp); // 解析 SpEL
RateLimiter limiter = cache.computeIfAbsent(key,
k -> RateLimiter.create(rateLimit.qps()));
boolean ok = rateLimit.timeout() > 0
? limiter.tryAcquire(rateLimit.timeout(), TimeUnit.MILLISECONDS)
: limiter.tryAcquire(0);
if (!ok) {
throw new BusinessException(LIMIT_EXCEEDED); // 统一限流异常
}
return pjp.proceed();
}
}业务侧用起来只剩一行声明:
@RateLimit(key = "order:create:" + "#{#userId}", qps = 50, timeout = 200)
@PostMapping("/order/create")
public Result createOrder(@RequestParam Long userId) { ... }四个不能省的细节
细节一:key 要能动态拼。「接口级 200 QPS」往往不够,真正要的是「每个用户 1 QPS、接口整体 200」——key 必须支持 SpEL 取参数(userId、IP、商品 ID),维度在注解里声明,切面里解析。细节二:限流异常要友好。抛 LIMIT_EXCEEDED 专用业务码,前端转成「活动太火爆,稍后再试」,而不是裸 500——限流的本质是主动拒绝,拒绝的姿势决定用户体验的下限。细节三:降级优先于拒绝。能读缓存兜底的活动信息,限流触发时读缓存返回,比硬邦邦的「稍后再试」体面——拒绝是下策,降级是中策(第 13 篇专讲)。细节四:触发率要上报。限流次数是关键指标(Micrometer 打点):触发率长期为零说明阈值虚高白设了;突然飙升说明要么流量异常、要么容量缩水——限流指标是容量问题的烟雾报警器。
单机限流的天花板
注解切面再精致,单机限流有一个绕不过的结构性问题:各实例各数各的,总量不可控。想全接口限 1000,10 个实例每台限 100?前提是负载均衡绝对均匀——现实是长尾实例可能分到 200,总量冲到 2000;扩缩容之后实例数一变,每台的阈值全部失真,阈值成了跟实例数耦合的常数。高峰期弹性扩容 10 台变 15 台,每台还是 100,总限额凭空涨了 50%——保护形同虚设。同时,按用户的公平性也碎了:同一用户的两个请求被均衡到不同实例,各自计数互不知晓,「每人 1 QPS」翻倍变 2。
结论水到渠成:单机限流适合「防单实例被打死」的自保型场景(比如每个实例对某个慢下游最多 10 并发),凡是要精确控制「总量」与「每人配额」的,计数器必须集中——放 Redis 里,用 Lua 保证原子。下一篇就写这个:分布式限流的 Redis 加 Lua 完整实现,顺带把令牌桶的分布式版一并解决。
评论 (0)