接口限流从手写到落地:自定义注解+Redis滑动窗口——一行@RateLimit就搞定
引言
上线第二个月的某个周五晚上,运营在群里发了个爆款活动链接。十分钟后,订单服务的监控曲线齐刷刷跳水——不是宕机,是一个"查优惠券"的接口被人写脚本疯狂调用,单机 QPS 冲到 8000,把 Tomcat 的 200 个工作线程全部占满,正常的下单请求全部在排队超时。
那一刻我们才意识到:限流不是"高可用锦上添花",是保命的。
当时团队内部手写的限流方案长这样——每个需要限流的接口里塞一段:
// 散落在 20 多个 Controller 里的"祖传代码"
if (!rateLimiter.tryAcquire("coupon/query", 100)) {
throw new BizException("操作太频繁,请稍后再试");
}
doQuery();
三个致命问题:
- 代码侵入:业务逻辑里混着限流细节,改限流阈值要发版
- 策略写死:硬编码在代码里,运营说"大促期间查询放宽到 200/s",得走发布流程
- 单机视角:只限了单台实例,集群 8 台实例就是 8 倍配额,"100 次/秒"名存实亡
这篇文章把我们最终落地的方案完整讲一遍:自定义 @RateLimit 注解(支持 key/limit/window)→ AOP 切面拦截 → Redis Lua 脚本滑动窗口(原子性 + 精确计数)。最终效果是一行注解搞定限流:
@RateLimit(key = "'login:' + #phone", limit = 1, window = 1, timeUnit = TimeUnit.SECONDS)
@PostMapping("/login")
public Result login(@RequestParam String phone, @RequestParam String pwd) { ... }
文章最后给出三种典型接口的落地配置(登录 1 次/秒、查询 100 次/秒、下单 10 次/秒)、压测数据、以及 6 个高频踩坑问答,所有代码可直接抄走。
一、从需求出发:三种接口,三种限流策略
方案设计前先把需求摆清楚。我们梳理了核心接口,发现限流诉求完全不同:
| 接口类型 | 限流策略 | 维度 | 桶的 Key | 超限行为 |
|---|---|---|---|---|
| 登录/验证码 | 1 次/秒(防撞库、防轰炸) | 手机号维度 | login:{phone} | 快速失败,不提示太多信息 |
| 查询类 | 100 次/秒(防爬虫、防刷) | 用户ID维度 | query:{userId} | 快速失败 |
| 下单类 | 10 次/秒(防恶意下单,但不能误伤正常用户) | 用户ID维度 | order:{userId} | 快速失败 + 告警 |
从这张表里能提炼出限流组件必须支持的四个能力:
- 灵活的 Key:有的按手机号、有的按用户ID、有的要按 IP——所以 key 必须支持 SpEL 表达式动态计算
- 灵活的阈值和窗口:1 次/秒 和 100 次/秒 显然不能共用常量
- 集群级精确计数:8 台实例共享一个 100 次/秒 的配额,计数必须放 Redis
- 原子性:"读计数 → 判断 → 写计数"三步绝不能被并发打断,否则限流形同虚设
还有个容易被忽略的点:超限后的行为。我们统一为抛 RateLimitException,由全局异常处理器转成 HTTP 429(Too Many Requests)+ 友好文案,业务代码无感知。
二、方案选型:为什么是 注解 + AOP + Redis 滑动窗口
2.1 限流算法对比
先把算法选对。主流四种:
| 算法 | 原理 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 计数器(固定窗口) | 每分钟一个计数器 | 实现最简单 | 临界突刺:59 秒 100 次 + 61 秒 100 次 = 2 秒内 200 次 | 精度要求低 |
| 滑动窗口 | 把窗口切成 N 个小格,滚动统计 | 平滑无突刺,精确 | 实现稍复杂 | 本文选择 |
| 漏桶 | 恒定速率放行 | 出口绝对平滑 | 无法应对合理突发 | 流量整形 |
| 令牌桶 | 恒定速率发令牌,桶有容量 | 允许突发,Guava RateLimiter 同款 | 桶容量设计需斟酌 | 单机突发场景 |
固定窗口的临界突刺值得单独画出来(这是面试高频题):
阈值 100 次/分钟,固定窗口:
窗口1 [00:00~01:00) :100 次(用满)
窗口2 [01:00~02:00) :100 次(用满)
实际观察 00:59 ~ 01:01 这 2 秒:
→ 最多通过了 200 次!是阈值的 2 倍
滑动窗口把 1 分钟切成 6 个 10 秒小格,每次检查的是"过去 60 秒的总和",从数学上消灭了突刺:
滑动窗口(60s 窗口 = 6 × 10s 格):
时刻 01:00 检查:统计 [00:00~01:00) 的总和 → 含完整窗口1
时刻 01:10 检查:统计 [00:10~01:10) 的总和 → 窗口向右滑动一格
时刻 01:20 检查:统计 [00:20~01:20) 的总和 → 继续滑动
...
任意时刻都是"最近 60 秒的精确总和",无突刺
2.2 为什么计数要放 Redis,还要用 Lua?
放 Redis:8 台实例要共享 100 次/秒 的集群配额,本地内存计数器只能管单机。Redis 单线程模型 + 高性能(内网读写 < 1ms),天然适合做集中计数器。
用 Lua:这是整个方案的正确性基石。不用 Lua 的话,Java 侧的限流逻辑是三步:
① GET count → 拿到当前计数
② IF count >= limit → 判断是否超限
③ INCR count → 未超限则 +1
并发下这三步会交错。两个线程同时执行①,都拿到 count=99,都通过②,各自③,最终 count=101——超卖了 1 个配额。压测并发越高,超卖越多,限流阈值等于形同虚设。
Redis 执行 Lua 脚本时整个脚本作为单个原子命令执行,期间不会插入其他命令,从根上消灭竞态。这就是官方推荐的分布式限流姿势。
2.3 最终架构
HTTP 请求
│
▼
┌─────────────────────┐
│ @RateLimit 注解 │ ← 声明限流策略(key/limit/window)
├─────────────────────┤
│ RateLimitAspect │ ← AOP 切面:解析 SpEL key → 调 Lua
│ (AOP 切面) │
├─────────────────────┤
│ Redis Lua 脚本 │ ← 原子执行:滑动窗口计数 + 判断 + 记录
├─────────────────────┤
│ Redis (ZSET) │ ← 集群共享的计数存储
└─────────────────────┘
│
▼ 未超限 → 放行执行业务
▼ 超限 → 抛 RateLimitException → 全局异常处理 → HTTP 429
三、第一步:自定义 @RateLimit 注解
3.1 注解定义
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* 接口限流注解:基于 Redis 滑动窗口的集群级限流
*
* 使用示例:
* @RateLimit(key = "'login:' + #phone", limit = 1, window = 1)
* public Result login(String phone, String pwd) { ... }
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/**
* 限流 Key,支持 SpEL 表达式。
* 常用写法:
* "'login:' + #phone" 按参数(手机号)
* "'order:' + #userId" 按用户ID
* "'ip:' + T(space.jiangyi.util.IpUtil).getClientIp()" 按 IP
*/
String key();
/** 窗口内允许的最大请求数 */
int limit();
/** 时间窗口大小,默认 1 */
int window() default 1;
/** 时间窗口单位,默认秒 */
TimeUnit timeUnit() default TimeUnit.SECONDS;
/** 限流提示文案,为空则用全局默认 */
String message() default "";
/** 是否启用(方便配置中心动态开关,配合 APollo/Nacos 覆盖) */
boolean enabled() default true;
}
设计说明:
- key 支持 SpEL 是灵魂。
'login:' + #phone会把方法参数拼进 key,实现"每个手机号各自 1 次/秒";不加前缀直接写常量字符串就是全局限流 - limit + window 分开声明而不是写死"per second",因为需求里有"100 次/10 秒"这种非整秒窗口
- message 和 enabled 是实战补出来的字段:前者让不同接口有不同提示,后者配合配置中心做紧急降级开关
3.2 SpEL 解析:把 'login:' + #phone 变成真实 Key
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
/**
* SpEL 表达式解析工具:从方法签名和入参中解析出限流 Key
*/
public class SpelKeyResolver {
private static final ExpressionParser PARSER = new SpelExpressionParser();
private static final ParameterNameDiscoverer NAME_DISCOVERER = new DefaultParameterNameDiscoverer();
public static String resolve(String spel, MethodSignature signature, Object[] args) {
// 1. 拿到参数名数组(需要 -parameters 编译参数或 Spring 的 LocalVariableTable)
String[] paramNames = signature.getParameterNames();
if (paramNames == null) {
throw new IllegalStateException(
"无法解析参数名,请检查编译是否加 -parameters(Spring Boot 3.2+ 默认开启)");
}
// 2. 构造求值上下文:参数名 → 参数值
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
// 3. 解析表达式,结果作为 key
Expression expression = PARSER.parseExpression(spel);
Object value = expression.getValue(context);
return value == null ? "null" : value.toString();
}
}
一个 90% 的人会踩的坑:SpEL 里 #phone 要拿到参数名,依赖编译产物保留参数名。Spring Boot 3.2+ 的 spring-boot-starter-parent 默认给 maven-compiler-plugin 加了 -parameters,老项目或者自定义了 compiler 插件配置的,务必确认这一项,否则运行期 getParameterNames() 返回 null,#phone 解析直接炸。
四、第二步:AOP 切面拦截
4.1 切面实现
import lombok.RequiredArgsConstructor;
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.core.annotation.Order;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 限流切面:拦截所有 @RateLimit 方法,执行 Redis Lua 滑动窗口判断
*/
@Slf4j
@Aspect
@Component
@Order(1) // 限流尽量最先执行:连业务参数校验都省了,尽快拒绝
@RequiredArgsConstructor
public class RateLimitAspect {
private final StringRedisTemplate redisTemplate;
/** Lua 脚本只需加载一次(DefaultRedisScript 内部会缓存 SHA1) */
private static final DefaultRedisScript<Long> SLIDING_WINDOW_SCRIPT;
static {
SLIDING_WINDOW_SCRIPT = new DefaultRedisScript<>();
SLIDING_WINDOW_SCRIPT.setScriptText(SlidingWindowLua.SCRIPT);
SLIDING_WINDOW_SCRIPT.setResultType(Long.class);
}
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
// 全局开关(配置中心动态下发),关掉就直通
if (!rateLimit.enabled() || !RateLimitProperties.GLOBAL_ENABLED) {
return pjp.proceed();
}
// 1. 解析 SpEL key
MethodSignature signature = (MethodSignature) pjp.getSignature();
String dynamicKey = SpelKeyResolver.resolve(rateLimit.key(), signature, pjp.getArgs());
// 2. 组装完整 Redis key:前缀 + 方法信息 + 动态 key
// 带上方法签名,避免不同接口的同名参数互相污染
String redisKey = "rate_limit:"
+ signature.getDeclaringTypeName() + "#" + signature.getName()
+ ":" + dynamicKey;
// 3. 计算窗口毫秒数
long windowMillis = rateLimit.timeUnit().toMillis(rateLimit.window());
// 4. 原子执行滑动窗口判断
long now = System.currentTimeMillis();
Long allowed = redisTemplate.execute(
SLIDING_WINDOW_SCRIPT,
Collections.singletonList(redisKey),
String.valueOf(now),
String.valueOf(windowMillis),
String.valueOf(rateLimit.limit()));
// 5. 判定:1=放行,0=超限
if (allowed != null && allowed == 1L) {
return pjp.proceed();
}
// 6. 超限:记录日志(带 key 便于排查是谁触发的)并抛业务异常
log.warn("[限流触发] key={}, limit={}/{}ms, uri={}",
redisKey, rateLimit.limit(), windowMillis,
RequestContext.currentUri());
String msg = rateLimit.message().isEmpty()
? "请求过于频繁,请稍后再试" : rateLimit.message();
throw new RateLimitException(msg);
}
}
4.2 全局异常处理:把限流转成标准 429
@RestControllerAdvice
public class GlobalExceptionHandler {
/** 限流异常 → HTTP 429,带 Retry-After 提示客户端 */
@ExceptionHandler(RateLimitException.class)
public ResponseEntity<Result<Void>> handleRateLimit(RateLimitException e) {
return ResponseEntity
.status(429)
.header("Retry-After", "1")
.body(Result.fail(429, e.getMessage()));
}
}
为什么坚持返回 429 而不是 200 + 错误码?429 是 HTTP 标准语义,网关、监控、客户端 SDK 都认识它:网关能统计限流命中率、前端能针对性弹"稍后再试"、爬虫脚本也能正确触发退避。返回 200 + code 只会让监控曲线看起来"一切正常"。
4.3 一个必须处理的故障场景:Redis 挂了怎么办?
限流组件自身不能成为新的单点故障——Redis 不可用时,策略必须是"放行"而不是"拒绝"(除非业务要求强限流)。切面里加一层兜底:
Long allowed;
try {
allowed = redisTemplate.execute(SLIDING_WINDOW_SCRIPT,
Collections.singletonList(redisKey), args...);
} catch (Exception e) {
// Redis 不可用:降级放行 + 打点告警(Prometheus 指标 rate_limit_fallback_total)
log.error("[限流降级] Redis 异常,本次放行。key={}", redisKey, e);
Metrics.counter("rate_limit_fallback_total").increment();
return pjp.proceed();
}
取舍说明:降级放行意味着 Redis 故障期间限流失效,但比"Redis 挂 → 全站接口 429"好得多。可用性优先于防护强度,除非你的接口涉及资金安全(那种场景 Redis 必须高可用部署 + 本地限流兜底)。
五、第三步:Redis Lua 滑动窗口(核心)
5.1 数据结构选择:ZSET
滑动窗口要回答的问题是:"过去 window 毫秒内,这个 key 发生了多少次请求?"
用 ZSET(有序集合)存储,每个请求一条记录:
- member:唯一标识(时间戳 + 随机数,防止同一毫秒多条记录互相覆盖)
- score:请求时间戳(毫秒)
那么"过去 60 秒的请求数"就是一次范围统计:
ZCOUNT key [now-60000, +inf] ← 或者先清理再 ZCARD,见下文
为什么不用 Hash/String 计数器?因为滑动窗口需要按时间删除过期记录,ZSET 的 ZREMRANGEBYSCORE 一条命令搞定;普通计数器只能做固定窗口。
5.2 Lua 脚本全文
-- SlidingWindowLua.SCRIPT
-- 滑动窗口限流:KEYS[1]=限流key
-- ARGV[1]=当前时间戳(ms) ARGV[2]=窗口大小(ms) ARGV[3]=最大请求数
-- 返回:1=放行 0=超限
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
-- ① 清理窗口外的过期记录(滑动窗口左移)
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- ② 统计窗口内的请求数
local current = redis.call('ZCARD', key)
-- ③ 超限判断
if current >= limit then
return 0
end
-- ④ 记录本次请求
-- member 用 now + 随机数保证唯一,防止同一毫秒内多条记录覆盖
local member = now .. '-' .. math.random(1000000, 9999999)
redis.call('ZADD', key, now, member)
-- ⑤ 给整个 key 设置过期时间 = 窗口大小
-- 防止冷 key 的 ZSET 永久驻留内存(清理逻辑由 Redis 自动兜底)
redis.call('PEXPIRE', key, window)
return 1
五步逐条解释:
- ZREMRANGEBYSCORE(0, now-window):把 score 小于"窗口起点"的记录全删掉——这就是"窗口滑动"的实现,删完剩下的全是最近 window 内的
- ZCARD:数一下还剩几条,就是当前窗口内的请求数
- 达到 limit 直接返回 0,不发新的记录
- ZADD 记录本次请求。member 必须唯一:ZSET 的 member 是唯一索引,两个请求如果同一毫秒且 member 相同,后者会覆盖前者,计数就少了。
now + 随机数实测碰撞概率可忽略 - PEXPIRE(window) 是内存兜底:即使这个 key 之后再也没人访问,ZSET 也会在窗口结束后自动过期,不会泄漏
5.3 正确性验证:为什么它是原子的
Redis 执行 Lua 时整个脚本独占执行,上面 5 步中间不可能插入其他客户端的命令。等价于把"清理+统计+判断+记录+过期"做成了一个不可分割的操作——第 2.2 节的超卖问题被彻底消灭。
压测验证(100 并发打一个 limit=100/window=10s 的接口):
| 方案 | 10 秒内实际放行数 | 预期 |
|---|---|---|
| Java 三步(GET/判断/INCR) | 137 | 100 |
| Redis Lua 滑动窗口 | 100 | 100 |
Lua 版分毫不差,非 Lua 版超卖 37%——这就是"原子性"三个字的分量。
5.4 性能与内存开销
单次限流的 Redis 开销:
| 指标 | 数值(实测,内网) |
|---|---|
| Lua 脚本执行耗时 | P99 < 0.3ms |
| 接口增加的 RT | P99 < 0.5ms |
| 单 key 内存(limit=100/10s 窗口满载) | < 4KB |
两个生产级优化(流量大了再上,别过早优化):
- 脚本缓存:Spring 的
DefaultRedisScript首次执行后自动用EVALSHA(脚本 SHA1 缓存在 Redis),后续不再传输脚本本体 - 高频接口前置本地限流:单机 QPS 上万时,先用 Guava RateLimiter 按"集群配额 / 实例数 × 1.2"做本地粗筛,挡掉 80% 流量后再查 Redis 精判,Redis 压力降一个数量级
六、三种接口的落地配置
回到第一章的需求表,最终代码长这样:
6.1 登录:每手机号 1 次/秒
@PostMapping("/login")
@RateLimit(key = "'login:' + #phone", limit = 1, window = 1,
message = "操作过于频繁,请 1 秒后再试")
public Result<Token> login(@RequestParam String phone,
@RequestParam String password) {
return authService.login(phone, password);
}
防撞库场景一般还会叠加"每小时最多 5 次失败"的规则,同一套注解声明两次不行(Java 注解默认不可重复),加 @Repeatable 支持即可:
// 注解定义上加
@Repeatable(RateLimits.class)
public @interface RateLimit { ... }
// 使用:两层限流——1 次/秒(防轰炸)+ 5 次失败/小时(防撞库,由切面区分成功失败计数)
@RateLimit(key = "'login:' + #phone", limit = 1, window = 1)
@RateLimit(key = "'login-fail:' + #phone", limit = 5, window = 3600,
message = "尝试次数过多,账户已临时锁定")
@PostMapping("/login")
public Result<Token> login(...) { ... }
6.2 查询:每用户 100 次/秒(集群共享)
@GetMapping("/coupon/list")
@RateLimit(key = "'query:' + T(space.jiangyi.util.AuthUtil).currentUserId()",
limit = 100, window = 1)
public Result<List<Coupon>> listCoupons() {
return couponService.listAvailable();
}
key 用当前登录用户 ID:已登录用户按人限流;未登录的查询接口换成取 IP 即可。100 次/秒是集群配额(Redis 集中计数),8 台实例一起分。
6.3 下单:每用户 10 次/秒 + 告警
@PostMapping("/order")
@RateLimit(key = "'order:' + #request.userId",
limit = 10, window = 1,
message = "下单太频繁啦,休息一下再试试")
public Result<OrderVO> createOrder(@RequestBody @Valid OrderRequest request) {
return orderService.create(request);
}
下单接口额外建议:在切面里对 P0/P1 级接口的超限事件打 Prometheus 指标(rate_limit_rejected_total{uri=..., key=...}), Grafana 配一个"限流拒绝率"面板——拒绝率突增往往就是攻击或活动流量的前兆,比业务报错早 10 分钟发现。
七、完整流程走查
一次请求进来,全链路是这样的(以登录接口为例):
1. POST /login?phone=13800001111&pwd=xxx
2. AOP 切面命中 @RateLimit(key="'login:'+#phone", limit=1, window=1s)
3. SpEL 解析:'login:' + '13800001111' → 'login:13800001111'
4. 拼装 Redis key:
rate_limit:space.jiangyi.controller.AuthController#login:login:13800001111
5. EVALSHA 执行 Lua:
① ZREMRANGEBYSCORE 清理 1 秒前的记录
② ZCARD = 0(窗口内没有请求)
③ 0 < 1 → 未超限
④ ZADD 记录本次请求
⑤ PEXPIRE 1s
→ 返回 1
6. 放行 → 执行 login() 业务逻辑
7. 同一手机号 300ms 后再发一次:
② ZCARD = 1 → 1 >= 1 → 返回 0
8. 切面抛 RateLimitException → 全局处理器 → HTTP 429 "操作过于频繁,请 1 秒后再试"
测试用例三连(写进集成测试,防止后人改坏):
@SpringBootTest
class RateLimitIntegrationTest {
@Autowired TestRestTemplate rest;
@Test
void 登录接口_同手机号_1秒内第二次应429() {
var first = rest.postForEntity("/login?phone=13800001111&pwd=x", null, String.class);
var second = rest.postForEntity("/login?phone=13800001111&pwd=x", null, String.class);
assertEquals(200, first.getStatusCode().value());
assertEquals(429, second.getStatusCode().value());
}
@Test
void 不同手机号_互不影响() {
rest.postForEntity("/login?phone=13800001111&pwd=x", null, String.class);
var other = rest.postForEntity("/login?phone=13900002222&pwd=x", null, String.class);
assertEquals(200, other.getStatusCode().value());
}
@Test
void 窗口滑过后_恢复可用() throws Exception {
rest.postForEntity("/login?phone=13800001111&pwd=x", null, String.class);
Thread.sleep(1100); // 越过 1s 窗口
var again = rest.postForEntity("/login?phone=13800001111&pwd=x", null, String.class);
assertEquals(200, again.getStatusCode().value());
}
}
八、常见问题
8.1 为什么用 ZSET 不用 Redis-Cell(CELL 模块)?
Redis-Cell 的 CL.THROTTLE 是现成的令牌桶模块,开箱即用。但三个现实问题:需要自编译模块(云 Redis 大多不支持)、算法是令牌桶(我们要滑动窗口的精确计数语义)、无法自定义 key 结构。ZSET 方案零依赖、算法可控,够用且好排查。
8.2 大量用户会把 Redis 打爆吗?内存呢?
算笔账:1 万活跃用户 × 每 key 满载 4KB ≈ 40MB,完全无压力。QPS 方面,一次限流 = 1 次脚本执行(内网 < 1ms),Redis 单机 10 万+ QPS,限流本身占用不到 10%。真到了单机 Redis 瓶颈,上 Redis Cluster 按 key 分片即可(key 天然带 hashTag 能力的话注意把不同用户的 key 分散开,别用 {login} 这种固定 hashtag 把流量全打到同一个 slot)。
8.3 时钟漂移会影响精度吗?
会有一点,但可控。脚本里 now 由 Java 侧传入(System.currentTimeMillis()),多台实例时钟差 50ms 以内,对秒级窗口的影响 < 5%。要求严格就上 NTP;注意不要用 redis.call('TIME') 后再自己换算——用服务器时间反而是官方推荐做法之一,两种都可以,保持一致即可。
8.4 ZADD 的 member 随机数会不会碰撞?
now(毫秒) + 6 位随机数,同一毫秒内碰撞概率 = 1/900 万。就算碰撞,后果也只是两条记录合并为一条、计数少 1,窗口滑动后自愈,不影响正确性。强需求可用 now .. '-' .. redis.call('INCR', key..':seq') 序列号方案,但一般不必。
8.5 和 Guava RateLimiter / Sentinel 怎么选?
| 方案 | 集群级 | 注解声明 | 依赖 | 适用 |
|---|---|---|---|---|
| 本文:@RateLimit + Redis | ✅ | ✅ | 仅 Redis | 接口维度的集群精确限流 |
| Guava RateLimiter | ❌ 单机 | ❌ | 零依赖 | 单机兜底、方法级熔断限速 |
| Sentinel | ✅(需 Token Server) | ✅ | 引入全家桶 | 流控+熔断+降级一体化 |
不冲突的组合拳:Sentinel/网关做南北向大流量防护,本文方案做业务维度的精细限流(按手机号/用户ID),Guava 做每台实例的本地兜底。
8.6 限流阈值怎么定?拍脑袋吗?
三步法:
- 压测定容量:单接口压出 P99 < 200ms 时的最大安全 QPS,比如 300
- 留安全余量:阈值 = 安全 QPS × 70%,即 210,防止限流形同虚设或误伤
- 灰度观察:先放宽 2 倍上线,看 Grafana 的"限流拒绝率 + 接口 P99"两个面板,逐步收紧到目标值
阈值变化走配置中心(Nacos/Apollo 监听 @RateLimitProperties),改阈值不发版,这是注解方案相对硬编码的最大红利。
九、总结
落地三步速查卡
┌────────────┬────────────────────────────────────────────────┐
│ 第一步 │ @RateLimit 注解 │
│ │ key(SpEL) / limit / window / message / enabled │
├────────────┼────────────────────────────────────────────────┤
│ 第二步 │ AOP 切面 │
│ │ SpEL 解析 key → EVALSHA 执行 Lua │
│ │ 超限抛 RateLimitException → HTTP 429 │
│ │ Redis 挂了降级放行 + 打点告警 │
├────────────┼────────────────────────────────────────────────┤
│ 第三步 │ Redis Lua 滑动窗口(ZSET 实现) │
│ │ ZREMRANGEBYSCORE 清过期 → ZCARD 计数 │
│ │ → 判断 → ZADD 记录 → PEXPIRE 兜底 │
│ │ 全脚本原子执行,消灭并发超卖 │
└────────────┴────────────────────────────────────────────────┘
关键数据
- 滑动窗口彻底消灭固定窗口的临界突刺(2 秒 200 次的 bug)
- Lua 原子性实测:100 并发下放行数 = 100 精确无超卖,非原子方案超卖 37%
- 限流增加的接口 RT:P99 < 0.5ms
- 单 key 内存 < 4KB,1 万活跃用户 ≈ 40MB
- 阈值调整走配置中心,不发版
一句话
注解声明策略(谁限、限多少、多久),AOP 负责拦截,Redis Lua 负责集群级的原子计数——三层各司其职,业务代码里就剩一行 @RateLimit。
给团队的建议
| 场景 | 建议 |
|---|---|
| 只有几个接口要限 | 直接抄本文方案,半天落地 |
| 接口超过 50 个要限 | 同方案 + 把阈值全部挪到配置中心 |
| 已在用 Sentinel | Sentinel 管大面防护,本文方案管用户级精细限流 |
| 涉及资金安全 | Redis 高可用必须做 + 本地 Guava 兜底双层限流 |
互动话题:你们的接口限流是怎么做的?有没有被"集群配额 × 实例数"坑过?限流阈值是压测出来的还是拍的?欢迎留言讨论!
参考资料
- Redis Lua 脚本官方文档
- Redis ZSET 命令手册(ZREMRANGEBYSCORE/ZADD)
- Spring AOP 官方文档
- SpEL 表达式官方文档
- Guava RateLimiter(本地兜底)
- Sentinel 流控官方文档
- HTTP 429 与 Retry-After(RFC 6585)
标题:接口限流从手写到落地:自定义注解+Redis滑动窗口——一行@RateLimit就搞定
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/08/30/1787967293901.html
公众号:服务端技术精选
- 引言
- 一、从需求出发:三种接口,三种限流策略
- 二、方案选型:为什么是 注解 + AOP + Redis 滑动窗口
- 2.1 限流算法对比
- 2.2 为什么计数要放 Redis,还要用 Lua?
- 2.3 最终架构
- 三、第一步:自定义 @RateLimit 注解
- 3.1 注解定义
- 3.2 SpEL 解析:把 'login:' + #phone 变成真实 Key
- 四、第二步:AOP 切面拦截
- 4.1 切面实现
- 4.2 全局异常处理:把限流转成标准 429
- 4.3 一个必须处理的故障场景:Redis 挂了怎么办?
- 五、第三步:Redis Lua 滑动窗口(核心)
- 5.1 数据结构选择:ZSET
- 5.2 Lua 脚本全文
- 5.3 正确性验证:为什么它是原子的
- 5.4 性能与内存开销
- 六、三种接口的落地配置
- 6.1 登录:每手机号 1 次/秒
- 6.2 查询:每用户 100 次/秒(集群共享)
- 6.3 下单:每用户 10 次/秒 + 告警
- 七、完整流程走查
- 八、常见问题
- 8.1 为什么用 ZSET 不用 Redis-Cell(CELL 模块)?
- 8.2 大量用户会把 Redis 打爆吗?内存呢?
- 8.3 时钟漂移会影响精度吗?
- 8.4 ZADD 的 member 随机数会不会碰撞?
- 8.5 和 Guava RateLimiter / Sentinel 怎么选?
- 8.6 限流阈值怎么定?拍脑袋吗?
- 九、总结
- 落地三步速查卡
- 关键数据
- 一句话
- 给团队的建议
- 参考资料
评论