做后端的时间一长,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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>Spring 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:userId=1024:orderId=3001:hashCode=9f8a2b分解开来就是三段。第一段是固定前缀,等于注解的 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、arg1,SpEL 表达式就会解析失败。遇到这种情况,排查思路是先确认编译参数,再检查参数名是否能正确获取。
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 Result<Void> handleRateLimit(RateLimitException e) { return Result.fail(429, e.getMessage()); } @ExceptionHandler(RepeatSubmitException.class) @ResponseStatus(HttpStatus.OK) public Result<Void> 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 方案最常遇到的问题。我接手过一个同事留下的限流代码,注解和切面都写了,运行起来就是不执行。逐个排查下来主要有四个原因。
第一个原因是忘了给切面类加@Component,AOP 容器扫描不到它,自然不生效。第二个原因是切点表达式写法不对,我见过有人写成@annotation(com.xxx.RateLimit)少了参数绑定,导致参数接收不到。第三种情况是方法被类内部调用绕过了代理,比如 Controller 里this.execute()调用本类的另一个加了注解的方法,Spring AOP 基于代理,内部调用不会经过代理对象,因此注解完全失效。解决办法是注入自身代理对象或者把被调方法拆到另一个 Bean 里。
还有一类是 Spring Boot 3.x 升级带来的兼容问题。Spring Boot 3 使用 Spring Framework 6,AOP 默认基于 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 版本。更彻底的方案是替换成 Jedis,Jedis 在连接池模式下表现更稳定,但两者不要混用,配置方式差异较大。
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 的方案我用了好几年,整体稳定,扩展性也好。如果你们的接口维度、限流阈值需要频繁调整,后续还可以把它扩展成基于配置中心动态读取规则的方式,但那就是另一个话题了。