幂等与限流
这篇解决什么
按钮连点、接口被刷时,要挡住重复提交和过高 QPS。读完能在方法上加 @Idempotent / @RateLimiter,会选 Key 解析器,并分清「窗口内只进一次」和「窗口内最多 N 次」。
默认连本机 Redis 127.0.0.1:6379,库号 0。管理端前缀仍是 /admin-api。幂等、限流和锁共用这套连接。
示意图:Controller 加注解;框架用 Redis 占位或计数后再进业务。
互斥临界区、租约、@Lock4j 见 分布式锁,本篇不重复。
组件位置
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| Protection Starter | 幂等、限流、分布式锁 | ruoyi-office/yudao-framework/yudao-spring-boot-starter-protection/ |
| 幂等装配 | 注册切面和三个 Key 解析器 | .../idempotent/config/YudaoIdempotentConfiguration.java |
| 限流装配 | 注册切面和五个 Key 解析器 | .../ratelimiter/config/YudaoRateLimiterConfiguration.java |
| Redis Starter | 幂等用 StringRedisTemplate,限流用 RedissonClient | ruoyi-office/yudao-framework/yudao-spring-boot-starter-redis/ |
| 单体入口 | 引入 protection Starter,切面随进程生效 | ruoyi-office/yudao-server/pom.xml |
| 错误码 | 幂等 900,限流 429 | yudao-common/.../GlobalErrorCodeConstants.java |
这两个注解写在 protection 里,不是 optional。yudao-module-system-server、yudao-module-bpm-server 的依赖目前注释掉了。哪个模块要编译注解,哪个模块的 pom 补同一坐标。
幂等、限流没有单独 yaml。窗口、次数、文案都写在注解上。锁的 acquire-timeout / expire 仍看 分布式锁。
怎么选
| 名称 | 说明 | 仓库路径 |
|---|---|---|
@Idempotent | 窗口内同 Key 只进一次;成功不删 Key | .../idempotent/core/annotation/Idempotent.java |
@RateLimiter | 窗口内同 Key 最多 count 次 | .../ratelimiter/core/annotation/RateLimiter.java |
@Lock4j / RLock | 互斥,出方法就解锁 | 见 分布式锁 |
| 业务唯一键 | 单据级可重试,不靠时间窗口 | 例如 FinanceVoucherSourceServiceImpl#submitSource |
幂等还是锁
成功后还要挡一段时间:用 @Idempotent。 成功就放行下一次、只要互斥:用 @Lock4j,不要把 deleteKeyWhenException 理解成「成功也删」。 注解窗口挡不住跨天重放,财务来源那种按账套 + 单据 + 版本查重。
登录页先过验证码,再谈接口限流,见 验证码。Redis 连接与 Key 排查见 Redis 与本地缓存。
幂等
IdempotentAspect 在方法前后拦截。先 SETNX,失败抛 ServiceException;成功才进业务。默认超时 1 秒,单位秒。
示意图:占位成功才往下走;正常返回不删 Key。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
timeout / timeUnit | 占位存活时间,默认 1 秒 | @Idempotent |
message | 重复时的 msg,默认「重复请求,请稍后重试」 | 同上 |
keyResolver | 默认 DefaultIdempotentKeyResolver | 同上 |
keyArg | 给表达式解析器的 Spring EL | 同上 |
deleteKeyWhenException | 默认 true,失败放开窗口 | 同上 |
idempotent:%s | Redis Key 形态,Value 是空串 | IdempotentRedisDAO |
REPEATED_REQUESTS | code 900 | GlobalErrorCodeConstants |
@Idempotent(timeout = 10, timeUnit = TimeUnit.SECONDS, message = "正在提交,请勿重复点击")
@PostMapping("/create")
public CommonResult<Long> create(@Valid @RequestBody XxxSaveReqVO reqVO) {
return success(xxxService.create(reqVO));
}默认解析器对「方法签名 + 入参」做 MD5。同一入参在窗口内,所有调用方共享一把占位。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
DefaultIdempotentKeyResolver | MD5(方法名 + 参数),全局一份 | .../keyresolver/impl/DefaultIdempotentKeyResolver.java |
UserIdempotentKeyResolver | 再拼 userId、userType | .../UserIdempotentKeyResolver.java |
ExpressionIdempotentKeyResolver | 只取 keyArg 的 EL 结果 | .../ExpressionIdempotentKeyResolver.java |
@Idempotent(timeout = 5, keyResolver = UserIdempotentKeyResolver.class)
public CommonResult<Boolean> submit(Long id) { /* ... */ }
@Idempotent(timeout = 10,
keyResolver = ExpressionIdempotentKeyResolver.class,
keyArg = "#reqVO.id")
public CommonResult<Boolean> retry(XxxSaveReqVO reqVO) { /* ... */ }按用户挡,换 UserIdempotentKeyResolver。按业务字段挡,换表达式。表达式不再拼方法名,两个方法都写 #reqVO.id 且 id 相同,会抢同一把 idempotent:%s。
成功不删,超时会放行
方法跑完 Key 还在,窗口内同参再来仍是 900。这是防重复提交,不是锁。 业务比 timeout 长,Key 先过期,第二次会再进。估不准就加长。 异常且 deleteKeyWhenException = true 时立刻删 Key,失败后可以重试。
前端按 code !== 0 提示即可,不要再包一层业务错误码。
限流
RateLimiterAspect 在方法前拦截。RateLimiterRedisDAO 拿 Redisson RRateLimiter,tryAcquire() 失败抛 429。默认窗口 1 秒、额度 100。
示意图:先计数,再进业务;限流不在结束后删 Key。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
time / timeUnit | 窗口,默认 1 秒 | @RateLimiter |
count | 窗口内次数,默认 100 | 同上 |
message | 空则用「请求过于频繁,请稍后重试」 | 同上 |
keyResolver | 默认 DefaultRateLimiterKeyResolver | 同上 |
keyArg | 给 ExpressionRateLimiterKeyResolver | 同上 |
rate_limiter:%s | Redis Key;RateType.OVERALL | RateLimiterRedisDAO |
TOO_MANY_REQUESTS | code 429 | GlobalErrorCodeConstants |
自定义表达式要用 ExpressionRateLimiterKeyResolver,不要写成幂等那个类。
@RateLimiter(count = 10, timeUnit = TimeUnit.MINUTES)
@PostMapping("/create")
public CommonResult<Long> create(@Valid @RequestBody XxxSaveReqVO reqVO) {
return success(xxxService.create(reqVO));
}上面是全局:同一入参,所有人合计每分钟 10 次。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
DefaultRateLimiterKeyResolver | MD5(方法名 + 参数),全局一份 | .../impl/DefaultRateLimiterKeyResolver.java |
UserRateLimiterKeyResolver | 再拼登录用户 | .../UserRateLimiterKeyResolver.java |
ClientIpRateLimiterKeyResolver | 再拼客户端 IP | .../ClientIpRateLimiterKeyResolver.java |
ServerNodeRateLimiterKeyResolver | 再拼 host@pid,按进程 | .../ServerNodeRateLimiterKeyResolver.java |
ExpressionRateLimiterKeyResolver | 只取 keyArg | .../ExpressionRateLimiterKeyResolver.java |
登录短信
AuthController#smsLogin 留了按手机号限流,默认注释。打开后:同一手机号 60 秒内最多 6 次。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
POST /system/auth/sms-login | 短信登录 | yudao-module-system-server/.../auth/AuthController.java |
| 拟开注解 | time = 60, count = 6,keyArg = "#reqVO.mobile" | 同上注释 |
@PostMapping("/sms-login")
@PermitAll
@RateLimiter(time = 60, count = 6,
keyResolver = ExpressionRateLimiterKeyResolver.class,
keyArg = "#reqVO.mobile")
public CommonResult<AuthLoginRespVO> smsLogin(@RequestBody @Valid AuthSmsLoginReqVO reqVO) {
return success(authService.smsLogin(reqVO));
}要编译这段,先在 yudao-module-system-server/pom.xml 恢复 protection 依赖。发验证码本身另有间隔,不是这个注解。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
send-frequency | 同一手机号间隔 1 分钟 | yudao-module-system-server/.../application.yaml 的 yudao.sms-code |
send-maximum-quantity-per-day | 每天 10 条 | 同上 |
| 校验 | 查上次记录,过快抛 SMS_CODE_SEND_TOO_FAST | SmsCodeServiceImpl#createSmsCode |
验证码组件自己的 get / check 分钟限流,见 验证码。
窗口单位会被收成秒
RateLimiterRedisDAO 用 timeUnit.toSeconds(time) 配 Redisson。MILLISECONDS 且不足 1000,间隔会变成 0。 新建限流器后会 expire 同一窗口,避免 Key 常驻。改 count / 窗口会 setRate 再设过期。 表达式 Key 同样不带方法名:两个接口都按 #reqVO.mobile 限流,会共用 rate_limiter:%s。
本机连不上 127.0.0.1:6379 时,先看 Redis 进程和 application-local.yaml,不要改成别的环境的地址。
配置与操作
防重复提交加 @Idempotent,限流加 @RateLimiter,锁见分布式锁篇。Key 表达式不要和别的接口撞车。要 Redis。
开启见 框架层。
