做后端的时间一长Spring Boot 接口限流和防重复提交这两件事迟早都会摊到你头上。限流是我经历了一次线上事故之后才彻底想明白的技术点那会儿接口被脚本刷得毫无还手之力数据库连接池直接被打满业务停摆了将近二十分钟。防重复提交则是另一个高频痛点用户手快连点两次提交按钮服务端一下收到两条请求订单建了两个、扣款扣了两次客服那边光是处理重复订单就能忙一整天。这两类问题看着简单但想在分布式环境下真正做好还真不是前端按钮加个 loading 就能糊弄过去的。这篇文章把两套完整方案一起给你核心思路就是 AOP 切面 Redis 原子操作代码全部可以直接复制下来用。限流部分包含固定窗口和滑动窗口两种 Lua 脚本防重复提交部分包含短时防抖和长期幂等两种模式最后还附上我踩过的一些坑位排查记录。这套东西适合几类人准备做分布式限流但不知道从哪下手的后端新人、被重复下单问题追着跑的业务开发、以及想把接口防护快速落地的技术负责人。看完之后不保证你成为限流专家但至少遇到同类需求时不至于临时抱佛脚去网上翻碎片代码。1. 限流和防重复提交到底差在哪里1.1 一个保护系统一个保护数据很多刚接触这两块概念的同事会把我搞混限流和防重复提交看起来都是“拦住不该进来的请求”但二者要解决的问题本质上完全不一样。限流保护的是服务端的处理能力。你要回答的问题是单位时间内系统到底能扛住多少请求超出能力上限的那部分流量宁可主动拒掉也不能让它们把服务打垮。这就像高速路的收费站入口闸机一直放车但路面容量有限车一多就堵死所以必须按“每秒钟放行多少辆”的标准来控流。限流本身是一种业务策略对合法的大流量也不客气超了就是不给进。防重复提交保护的是业务数据的正确性。你要回答的问题是同一个逻辑请求被我处理了两次会不会产生脏数据典型场景就是用户提交订单时猛点三下按钮三次请求到达后端订单服务如果不做任何防护就会生成三笔一模一样的订单。防重复提交的思路是同一时刻、同一用户、同一业务动作只允许被处理一次第二次到达的直接返回“正在处理中”。它更像餐厅里的取号机制同一个手机号只发一张排队单重复来取号不给发。两件事经常同时出现但设计的时候必须分开考虑。限流可以只针对 IP 或接口维度防重复提交则必须带着业务参数去判断比如用户 ID、订单号、请求体里的唯一标识。把这两个概念混为一谈很容易写出一个全局限流把正常用户挡在外面、或者防重设计拦不住真正重复请求的尴尬方案。1.2 为什么选 AOP Redis而不是过滤器或本地锁实现限流和防重摆在面前的可选方案很多。最简单粗暴的是用 Servlet Filter 或者 Spring Interceptor在请求进入 Controller 之前统一做检查。这个方案的问题在于粒度太粗很难灵活表达“只有一个 userId 参数为 xxx 的写操作才需要防重”这样的规则。另一个常见方案是把判断逻辑写在业务代码里每个方法开头徒手敲一段 Redis 操作。这么干确实直观但侵入性太强一百个接口就要重复写一百遍而且哪天要改 key 策略或者调整限流算法还得逐个方法去翻。AOP 切面配合自定义注解是这两者之间的平衡点。你只需要定义一个RateLimit注解和RepeatSubmit注解然后给要求防护的接口方法加上一行注解剩下的限流或防重逻辑全部集中到切面中处理。业务方不需要关心 Redis 到底是怎么操作的将来要切限流算法、改异常响应结构也只动一个类。这个模式最大的好处是“声明式”接口需要什么防护看注解就能一目了然。选 Redis 而不是本地 JVM 锁原因也很实在。单机部署用 ConcurrentHashMap 加锁确实能解决问题但只要服务拆成多个节点或者同一台服务器上跑了多个实例本地锁就立刻失效了。两个进程同时拿到锁和没加锁没有任何区别。Redis 是跨进程共享的存储天然能在多实例环境下保证互斥。而且 Redis 单命令本身是原子性的配合 Lua 脚本可以完成“先判断、再修改、再设置过期”这种组合操作不用自己处理并发竞态。2. 工程初始化与 Redis 配置基础2.1 依赖引入和项目结构在开始写切面前先把工程基础打好。我使用的技术栈是 Spring Boot 2.7 或 3.x 都可以核心依赖一共三个Spring AOP、Redis、Web。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependencySpring Boot 3.x 下如果使用EnableAspectJAutoProxy的默认配置切面功能是不需要额外手动开启的starter-aop 引入后自动生效。但注意一点如果在 Spring Boot 3.4 以上版本里遇到切面完全不执行的情况大概率是自动代理配置被调整过可以在启动类或配置类上显式加上EnableAspectJAutoProxy(proxyTargetClass true)强制使用 CGLIB 代理。项目结构上建议把限流和防重相关代码独立成一个aspect包包含注解定义、AOP 切面、异常定义三类文件后续维护起来非常清晰。Controller 层只暴露业务接口业务逻辑不用感知底层防护机制的存在。2.2 Redis 连接参数与序列化选择Redis 连接配置本身不复杂但有几个参数如果没调好会在高并发下暴露问题spring: data: redis: host: 127.0.0.1 port: 6379 password: your_password timeout: 3s lettuce: pool: max-active: 64 max-idle: 16 min-idle: 8timeout 不要图省事设成 -1那样很危险。Redis 出现网络抖动时连接池里的线程会无限期挂着把整个服务的 Tomcat 线程全部拖死。建议 2 到 3 秒比较合适。lettuce 连接池的 max-active 要结合接口并发量去调整默认是 8压测的时候一旦达到上限请求会阻塞等待表现为接口响应突然变慢。我一般会把它调到 50 到 100 之间。序列化这里我强烈建议直接使用StringRedisTemplate而不要用默认的RedisTemplate。默认RedisTemplate使用 JDK 序列化键和值都会带一串\xAC\xED\x00\x05t之类的二进制头你在 Redis Desktop Manager 里面根本看不清 key排查问题非常痛苦。StringRedisTemplate一切以字符串形式存取key 结构一目了然读写简单直接。至于值的反序列化问题限流和防重场景里我们其实并不需要存复杂的对象所有 value 都可以用一个整数或一个随机字符串代表所以 String 类型完全够用完全用不上 JSON 序列化。这算是这个方案里最清爽的一点。3. 接口限流基于 Redis Lua 的完整实现3.1 限流注解设计key、limit、window 三个字段怎么定先定义一个自定义注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RateLimit { /** * 限流 key 表达式支持 SpEL例如 #userId * 默认空字符串表示全局限流 */ String key() default ; /** * 窗口期内允许的最大请求次数 */ int limit() default 100; /** * 窗口大小单位秒 */ int window() default 60; }三个字段的设计逻辑要展开说一下。limit 和 window 合在一起就是限流规则某接口 60 秒内最多允许 100 次调用。这里最容易忽略的是 key 字段的粒度。默认情况下限流 key 如果只包含接口路径那整个接口的所有用户共享同一个计数效果是“这个接口所有用户加起来一秒钟只能打 100 次”。这适合保护一些公共资源接口比如小程序全局配置拉取、验证码发送这类接口本身就不该有特别大的调用量。但更多时候我们想针对单个用户限流每个用户 1 分钟最多调用 200 次。这时候 key 就需要拼接用户身份注解上写key #userId切面里通过 SpEL 表达式解析参数。更复杂的维度还可以拼接 IP、设备号、业务单号。key 的粒度越细防护越精准但 Redis 中维护的 key 数量也会越多需要结合实际场景取舍。3.2 固定窗口限流INCR EXPIRE 的经典写法先看最简单也最常用的固定窗口限流。逻辑是在窗口时间内每次请求对 key 做自增如果自增后的值超过 limit就拒绝请求如果这是窗口的第一次请求顺便设置过期时间。我最初是分两步写这个逻辑的先INCR再判断current 1后EXPIRE。在高并发下就踩了一次坑两个请求同时进来都INCR之后发现自己是第一个结果第二条EXPIRE命令直接覆盖了第一条设置的过期时间导致 key 变成了永久 key。所以后来老老实实用 Lua 脚本把整个逻辑原子化-- 固定窗口限流脚本 -- KEYS[1]: 限流 key -- ARGV[1]: 窗口内允许的最大请求数 -- ARGV[2]: 窗口大小秒 local current tonumber(redis.call(GET, KEYS[1]) or 0) if current tonumber(ARGV[1]) then return 0 end current redis.call(INCR, KEYS[1]) if current 1 then redis.call(EXPIRE, KEYS[1], ARGV[2]) end return 1脚本先读当前计数如果已经达到上限就返回 0否则执行 INCR。INCR 返回 1 说明是窗口内第一个请求顺手设置过期时间。这里有个细节判断顺序是“先读再增”理论上存在两个请求同时读到的 current 都是 0然后都执行 INCR最终值变成 2。但因为脚本在 Redis 里是串行执行的同一时刻只有一个脚本实例在跑所以不存在这个并发问题。Redis 单线程执行 Lua 脚本的特性就是关闭并发竞态的关键。切面代码对应如下Aspect Component public class RateLimitAspect { private static final String LIMIT_SCRIPT local current tonumber(redis.call(GET, KEYS[1]) or 0) if current tonumber(ARGV[1]) then return 0 end current redis.call(INCR, KEYS[1]) if current 1 then redis.call(EXPIRE, KEYS[1], ARGV[2]) end return 1; private final StringRedisTemplate redisTemplate; public RateLimitAspect(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } Around(annotation(rateLimit)) public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { String key buildKey(joinPoint, rateLimit.key(), rate:limit:); Long result redisTemplate.execute( new DefaultRedisScript(LIMIT_SCRIPT, Long.class), Collections.singletonList(key), String.valueOf(rateLimit.limit()), String.valueOf(rateLimit.window()) ); if (result ! null result 1L) { return joinPoint.proceed(); } throw new RateLimitException(请求太频繁请稍后再试); } }调用joinPoint.proceed()让原始方法继续执行这一步是切面工作流的核心。如果限流不通过我选择直接抛异常然后在全局异常处理器里转成 HTTP 429 响应。你也可以改成直接返回一个 JSON 结果看团队现有接口的返回风格决定。3.3 滑动窗口限流解决固定窗口的临界突变问题固定窗口限流最大的毛病是临界突变。举个例子某接口限流规则是 60 秒 100 次用户在第 59 秒打满 100 次被拒绝但到了第 60 秒因为 key 过期重置又可以打满 100 次。这意味着在 2 秒内用户实际上可以打 200 次请求瞬间流量是预期的两倍。对要求严格的接口这显然不够平滑。滑动窗口算法用 ZSet 实现把每次请求的时间戳作为一个 member 存入有序集合查询时只统计当前时间往前滑动 window 秒内的请求数量-- 滑动窗口限流脚本 -- KEYS[1]: 限流 key -- ARGV[1]: 窗口内允许的最大请求数 -- ARGV[2]: 窗口大小毫秒 -- ARGV[3]: 当前时间戳毫秒 redis.call(ZREMRANGEBYSCORE, KEYS[1], 0, ARGV[3] - ARGV[2]) local count redis.call(ZCARD, KEYS[1]) if count tonumber(ARGV[1]) then return 0 end redis.call(ZADD, KEYS[1], ARGV[3], ARGV[3] .. : .. math.random(1000000)) redis.call(PEXPIRE, KEYS[1], ARGV[2]) return 1这段脚本的执行过程是先把窗口外的时间戳记录清掉再统计集合中剩下的请求数足够就加入本次请求并返回 1否则返回 0。每次 zadd 的 member 需要唯一所以用时间戳拼接一个随机数。脚本最后重新设置过期时间保证 ZSet 不会无限积累。滑动窗口比固定窗口更平缓但代价是每个请求都涉及 ZSet 操作内存占用也更大。一个 key 即使只有 100 个请求也要存 100 个 member。生产环境里用不用滑动窗口取决于接口对流量平滑性的要求以及 Redis 内存预算。大部分接口用固定窗口就够了重要的核心交易链路才需要上滑动窗口。3.4 限流降级Redis 挂了怎么办所有依赖 Redis 的方案都必须考虑一个问题Redis 不可用时限流逻辑怎么办。选择无脑放行意味着防护失效高并发流量可能直接压垮服务选择拒绝所有请求则 Redis 抖动会让系统整体不可用。我实践下来更倾向于放行但要加监控和告警。具体做法是在切面里 catch Redis 相关异常记录 WARN 日志然后直接proceed()让请求继续走业务逻辑。理由很简单限流是防护手段不是业务主逻辑。在 Redis 故障的极端场景下服务应该降级为“不限流”状态保证正常用户不被误伤同时通过告警让值班人员快速介入。降级放行可能让少量异常流量透传进来但总比全站瘫痪要好。如果你们对某些接口的限流要求极为严格不允许任何透传那么可以配置成“故障拒绝”但这通常只用于内部核心系统对外接口一般不会这么做。两种模式我都做成配置项通过一个开关控制方便不同团队按需调整。4. 防重复提交基于 Redis 分布式锁的完整实现4.1 两种防重模式短暂防抖与长期幂等先想清楚再动手防重复提交比限流复杂的一点在于你得先明确业务上到底想防什么。我归纳下来市面上大部分防重需求可以分成两类。第一类是短暂防抖典型场景就是用户点提交按钮时手抖点了两次。两次请求几乎是同一个瞬间到达的防重只需要在很短的时间窗口比如 3 到 5 秒内让第二次请求不再执行即可。窗口过去后用户又可以正常提交下一个单子。这类需求对应“SETNX 获取锁执行完立即释放”或者“设置短过期时间自动重置”的模式。第二类是长期幂等典型场景是同一个业务请求允许多次到达但只允许第一次真正处理。比如支付回调、消息队列重投、单据状态变更接口网络超时后消息被重发三次服务端必须保证状态只更新一次。这类需求要求在更长时间窗口内保持防重标记比如 10 分钟、1 小时甚至更久。这两种模式的代码结构几乎一样区别只在于防重 key 的过期时间和执行完是否删除 key。必须在动手写代码前和产品确认清楚否则实现出来的防重经常隔三差五出现“用户明明只提交了一次却被提示重复提交”或者“重复提交了还是产生了重复数据”两边不讨好的问题。我自己的代码里直接给注解加了一个expireMode或叫releaseOnSuccess的字段来区分两种行为。4.2 防重复提交注解与切面实现先看注解定义Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RepeatSubmit { /** * 业务防重 key 前缀 */ String prefix() default ; /** * SpEL 表达式用来拼接唯一业务键例如 #userId : #orderId */ String key() default ; /** * 锁标记过期时间单位秒 * 短时防抖建议 3~5 秒长期幂等建议 600 秒以上 */ long expire() default 3; /** * 处理成功后是否立即释放标记 * true短暂防抖模式允许窗口过后下一次正常提交 * false长期幂等模式保留标记直到过期 */ boolean releaseOnSuccess() default true; /** * 防重提示信息 */ String message() default 请勿重复提交; }核心切面代码如下Aspect Component Order(2) public class RepeatSubmitAspect { private static final String LOCK_SCRIPT local result redis.call(SET, KEYS[1], ARGV[1], NX, EX, ARGV[2]) if result then return 1 else return 0 end; private static final String UNLOCK_SCRIPT if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end; private final StringRedisTemplate redisTemplate; public RepeatSubmitAspect(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } Around(annotation(repeatSubmit)) public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable { String key buildKey(joinPoint, repeatSubmit.key(), repeatSubmit.prefix()); String requestId UUID.randomUUID().toString(); Long locked redisTemplate.execute( new DefaultRedisScript(LOCK_SCRIPT, Long.class), Collections.singletonList(key), requestId, String.valueOf(repeatSubmit.expire()) ); if (locked null || locked 0L) { throw new RepeatSubmitException(repeatSubmit.message()); } try { return joinPoint.proceed(); } finally { if (repeatSubmit.releaseOnSuccess()) { redisTemplate.execute( new DefaultRedisScript(UNLOCK_SCRIPT, Long.class), Collections.singletonList(key), requestId ); } } } }这里有几个非常关键的实现细节单独拎出来讲。第一个细节是锁的 value 必须用 UUID 而不是固定字符串。假设锁的过期时间设为 3 秒你的业务方法实际执行了 5 秒锁在 3 秒时就自动过期了第二个请求此时进来成功拿到锁开始执行。如果第一个请求执行完毕后直接DEL key它会把第二个请求的锁删掉这就会导致第三个请求又能成功拿到锁整个防重就失效了。用 UUID 做 value 之后释放锁时通过 Lua 脚本判断当前 value 是否等于自己的 UUID不属于自己的锁绝不删除。第二个细节是获取锁的原子性。SET 命令一次性带上NX和EX两个参数是 Redis 官方推荐的分布式锁写法。不要拆成“SETNX 然后再 EXPIRE”两步中间进程一旦崩溃key 就残留成了永久锁。这个坑我在早期版本里踩过线上出现过一次防重 key 残留导致某个用户的后续请求全被拦截客服工单刷了屏。第三个细节是释放锁也要用 Lua 脚本保证“判断 删除”的原子操作。不要用“先 GET 判断再 DEL 删除”的 Java 代码写法判断和删除之间可能插入其他请求的干扰。4.3 key 怎么拼才合理接口维度 用户维度 业务参数防重 key 的设计是整个方案里最考验业务理解能力的地方。设计得好防重精准不误伤设计得不好要么锁不住重复请求要么把正常用户挡住了。一个合理的防重 key 通常长这样repeat:submit:order:userId1024:orderId3001:hashCode9f8a2b分解开来就是三段。第一段是固定前缀等于注解的 prefix 字段我一般用“功能名 接口名”来区分不同业务。第二段是核心业务标识比如 userId、orderId、paySerialNo这些决定了“什么样算同一个业务请求”。第三段是请求体关键参数的摘要防止同一用户携带不同业务参数时被误判为重复提交。如果接口并发量不大且参数简单用 SpEL 表达式拼接原始字符串就行。如果参数很多或者包含复杂对象建议把关键字段拼起来之后再做个 MD5缩短 key 长度。Redis 的 key 过长不会报错但会占用更多内存而且肉眼排查时也难看。我的实际操作中注解一般是这么用的PostMapping(/order) RepeatSubmit(prefix order:create, key #userId : #orderId, expire 3, releaseOnSuccess true) public Result createOrder(RequestBody OrderCreateRequest request) { // 业务处理 return Result.success(); }SpEL 表达式写在注解里切面通过DefaultParameterNameDiscoverer拿到方法参数名再把参数值注入表达式上下文。有个前提条件需要说明方法的参数名必须真实保留编译时不能去掉。Spring Boot 的 Maven 插件默认会加-parameters参数但如果有人调整过编译参数运行时拿到的参数名会变成arg0、arg1SpEL 表达式就会解析失败。遇到这种情况排查思路是先确认编译参数再检查参数名是否能正确获取。5. Controller 使用实战与压测验证5.1 两个注解叠加使用先限流再防重实际项目中限流和防重往往要同时出现在同一个接口上。比如秒杀下单接口既要防止某个用户高频刷单又要防止他手抖重复提交。两个切面叠加时执行顺序必须明确先执行限流再执行防重。理由很简单限流是更前置的保护如果流量已经超过阈值根本没必要再进入防重判断防重是业务层面的最终防线留给限流放行后的请求即可。Order注解就是用来控制切面优先级的数字越小优先级越高越先执行。我在上面的代码里已经写了Order(1)给限流切面、Order(2)给防重切面这样叠加时顺序就不会乱。还有一种情况是限流或防重某一方被跳过另一方照常生效二者完全独立互不依赖这也是分别写两个切面而不是揉成一个的好处。Controller 里叠加使用的写法就是两个注解叠在方法上PostMapping(/seckill/order) RateLimit(key #userId, limit 1, window 1) RepeatSubmit(prefix seckill:order, key #userId : #seckillId, expire 2, releaseOnSuccess true) public Result seckillOrder(RequestParam Long userId, RequestParam Long seckillId) { // 秒杀下单逻辑 return Result.success(); }这段的含义是单个用户每秒最多调用 1 次秒杀下单接口同一用户针对同一秒杀活动 2 秒内不能重复提交。限流和防重双保险基本可以挡住绝大多数脚本刷单和手抖误触。5.2 全局异常处理与响应结构限流和防重都是通过抛异常中断请求的所以必须配套一个全局异常处理器把这些异常翻译成足够清晰的提示信息返回给客户端。我用一个RestControllerAdvice处理RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(RateLimitException.class) ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) public ResultVoid handleRateLimit(RateLimitException e) { return Result.fail(429, e.getMessage()); } ExceptionHandler(RepeatSubmitException.class) ResponseStatus(HttpStatus.OK) public ResultVoid handleRepeatSubmit(RepeatSubmitException e) { return Result.fail(400, e.getMessage()); } }注意防重的返回状态码不要也用 429。限流是服务端能力不足返回 429语义上是对的防重则是业务请求处理方式问题返回 400 更合理。客户端对接时前端可以根据不同状态码分别提示“请稍后再试”和“请勿重复提交”这两个话术交给用户时体验差别很大。5.3 压测验证模拟 500 并发看看效果代码写完必须验证不能光看逻辑觉得没问题就上线。我用 JMeter 起了 500 个线程同时请求一个限流配置为“1 秒最多 5 次”的测试接口压测结果大致是这样第一波到达的 5 个请求正常返回成功后边 495 个请求全部返回 429 错误。Redis 里的限流 key 在窗口结束后自动过期下一波请求进来又能正常放行。这个结果基本符合预期。防重接口的验证也类似用脚本循环提交 10 次同一个订单号返回结果是第一次成功后续九次全部提示“请勿重复提交”。等防重 key 过期后再次提交又能正常处理。注意压测时要做参数化如果每个请求的用户 ID 都不同防重 key 也会不同自然不会触发拦截测出来就像没生效一样。模拟真实用户行为时一定要保证同一批请求的业务标识一致。另外提醒一句压测时观察一下 Redis 的内存变化和慢查询日志。滑动窗口限流的 ZSet 操作会带来一些写入压力key 数量大时Redis 的过期清理策略会占用 CPU内存曲线出现持续上升或者锯齿状波动都要引起注意。6. 常见问题与排查技巧实录6.1 切面没生效接口直接绕过防护这是第一次使用 AOP 方案最常遇到的问题。我接手过一个同事留下的限流代码注解和切面都写了运行起来就是不执行。逐个排查下来主要有四个原因。第一个原因是忘了给切面类加ComponentAOP 容器扫描不到它自然不生效。第二个原因是切点表达式写法不对我见过有人写成annotation(com.xxx.RateLimit)少了参数绑定导致参数接收不到。第三种情况是方法被类内部调用绕过了代理比如 Controller 里this.execute()调用本类的另一个加了注解的方法Spring AOP 基于代理内部调用不会经过代理对象因此注解完全失效。解决办法是注入自身代理对象或者把被调方法拆到另一个 Bean 里。还有一类是 Spring Boot 3.x 升级带来的兼容问题。Spring Boot 3 使用 Spring Framework 6AOP 默认基于 CGLIB。某些老项目里手动配了EnableAspectJAutoProxy(exposeProxy true)且原有代码依赖 JDK 动态代理的类升级后会出现代理类型不一致。遇到这种情况先检查启动类上有没有多余的 AOP 配置再看项目里是否混用了实现了接口和未实现接口的切面服务。6.2 Redis key 莫名带前缀或者序列化报错只要看到 Redis 里存储的 key 长这样\xac\xed\x00\x05t\x00\x09xxx基本可以确定是RedisTemplate的序列化配置问题而不是业务逻辑 bug。默认的 JdkSerializationRedisSerializer 会把 key 和 value 都做二进制序列化存进去就是乱码。使用StringRedisTemplate之后这个问题就消失了。还有一个坑是限流 key 全局共用不同接口用同一个固定 key结果一个接口的流量占满了额度其他接口全部被拦截。出现这个问题时检查注解的 key 表达式是不是都用了空字符串或其他相同值。建议限流 key 一定包含接口路径或方法名具体做法可以给注解增加key默认值为当前方法的类名加方法名这样即使漏写也不至于全站共享一个计数器。Redis 连接慢或者连接丢失也算高频问题。Spring Boot 2.x 默认使用 Lettuce早期版本在高并发下会偶发连接共享问题表现为RedisConnectionFailureException: Connection is closed. 如果线上遇到这种日志可以先用异常过滤器判断是否是 Lettuce 内部连接问题或者升级 Spring Boot 版本。更彻底的方案是替换成 JedisJedis 在连接池模式下表现更稳定但两者不要混用配置方式差异较大。6.3 防重锁释放失败或者误删其他请求的锁防重锁出问题的最典型症状是用户明明只提交了一次后续一直提示“请勿重复提交”。我个人遇到过的原因有两种。第一种是防重 key 过期时间设置太短业务方法执行时间超过了锁的过期时间程序还没跑完锁就自动过期了另一个请求拿着新锁进来然后两个请求同时执行防重形同虚设。解决办法是把过期时间拉开安全余量比如业务方法平均耗时 200ms设置 3 秒一般足够但如果业务里有远程调用、大量数据库操作时间就要放大。另一种更严谨的思路是锁续期类似 Redisson 的 watchdog 机制但实现复杂度高大多数场景没必要。第二种是释放锁时误删了别人的锁也就是前面说过的“UUID 匹配”问题。如果释放锁时没有判断 value第一个请求执行完把第二个请求的锁删掉会导致后续请求连续穿透。我最终用 Lua 脚本做“GET 判断 DEL 删除”同时给注解加上releaseOnSuccess开关。长期幂等场景直接不释放锁等它自然过期连误删的机会都没有。6.4 限流参数设置的两个实操建议限流规则具体怎么定没有标准答案但我摸索出两个原则先摸底后放量。上线前用压测工具摸清楚接口的真实处理能力比如数据库连接池 20 个、服务单实例能抗 200 QPS那限流阈值按 150 QPS 设留出一些缓冲。这个阈值放到 Redis 里做全局维度多实例部署也适用。第二个原则是宁可先严后松。新上线的限流规则先设得比预期低一些运行几天看监控没有被误伤再加。我见过团队上线第一天就把限流阈值调成压测极限值结果线上几个极端流量接口触发限流导致核心用户操作失败。限流是保护机制不是普通接口的并发承诺它首先应该保护系统业务增长后的扩容问题应该交给其他手段。最后再分享一个小细节。切面里获取方法参数名时我建议所有限流和防重的 SpEL 表达式都加上日志输出把最终构造出的 Redis key 打出来。上线初期把日志级别调成 INFO观察一段时间确认 key 生成规则符合预期再降回 WARN。这一步能帮你提前发现“所有用户共用一个 key”“参数名解析失败”这类问题等到线上用户反馈再来回头查日志成本就高多了。这套 AOP Redis 的方案我用了好几年整体稳定扩展性也好。如果你们的接口维度、限流阈值需要频繁调整后续还可以把它扩展成基于配置中心动态读取规则的方式但那就是另一个话题了。