我知道很多人看到“Spring Boot接口防抖”“防重复提交”“接口幂等性”这几个词,第一反应是:这不就是加个 Redis 锁嘛,有什么好写的。但我在实际项目里把这套东西完整做过一轮之后,发现坑远比想象中多:请求体读不到、参数签名不一致、异常被吞、并发压测下误杀率居高不下。这篇文章把我从需求分析、方案选型到代码落地、压测收尾的完整过程捋一遍,重点讲清楚每一步为什么这么做,以及哪些地方最容易翻车。
1. 先把对手看明白:防抖、防重和幂等到底在解决哪些问题
1.1 一次生产事故让我重新审视重复请求
那次事故的场景很简单:前端表单页在用户点击“提交订单”后,按钮没有做状态锁,用户网络有点卡,手快连点了两下。两条一模一样的请求几乎同时到达服务端,接口里也没有做任何保护,于是数据库里插入了两条完全相同的订单记录。后面财务对账的时候发现了这条重复订单,我花了一个下午加一个晚上去补数据、写脚本清理、给用户道歉。排查日志更扎心:两条请求的时间差只有 32 毫秒,日志里长得一模一样,唯一不同的是请求的 traceId。
这件事之后我认真想了一个问题:我们天天说“接口防抖”“防重复提交”“接口幂等”,它们真的是同一个东西吗?实际处理的时候又该怎么区分?
1.2 三类需求不能混为一谈
我把日常开发里遇到的需求归纳成三类:
| 需求名称 | 典型触发场景 | 核心目标 | 处理维度 |
|---|---|---|---|
| 防抖 | 用户连续点击按钮、双击提交 | 短时间内自动忽略重复触发 | 时间窗口 + 请求指纹 |
| 防重复提交 | 同一条请求在短时间或系统异常后再次进入服务端 | 同一业务请求只会被处理一次 | 请求唯一标识 + 服务端原子拦截 |
| 接口幂等 | 网络重试、消息队列重投、第三方回调重复通知 | 无论执行多少次,结果和状态都保持一致 | 业务语义 + 存储层约束 + 状态流转控制 |
简单说,防抖是“不让多余请求进来”,防重复提交是“重复请求进来也拦得住”,幂等则是“就算请求真的被重复执行了,系统也不会出问题”。
这三者的关系是层层递进的。防重和防抖负责把可见的重复请求挡在门外,幂等性负责在更底层的机制层面兜底。我在后面的设计里是两套都做了:入口处用 Redis 做防重复提交拦截,业务数据层面再用唯一约束和状态字段保证幂等。很多团队只做了前者就觉得万事大吉,结果遇到 MQ 重投或者第三方回调重试,照样出现重复数据。
1.3 重复请求是怎么产生的
搞清楚对手是谁,还要知道它是怎么来的,否则方案容易设计过头或者漏掉场景。结合我的经历,重复请求主要来源有四个:
- 用户交互产生的重复:手抖双击、按钮未置灰、移动端弱网导致前端重发。这类请求通常间隔极短,也就是人所说的“毫秒级尖峰”;
- 网关或代理层重试:Nginx、Spring Cloud Gateway 这些组件在超时后可能自动重发请求;
- MQ 消息重投:RocketMQ 默认至少一次语义,消费者处理完还没来得及提交 offset 就挂了,几分钟后消息会重新投递;
- 第三方回调重试:支付结果通知、短信状态回执,回调方往往有自己的重试策略,短则几秒,长则数天。
不同来源对“重复”的定义不一样。比如支付回调,回调方重试时可能请求体完全一致,也可能携带不同的回调标识。如果是消息重投,业务请求的幂等键往往需要自己从消息体里提取。所以我在做设计时不会只锚定“一个接口 + 固定参数”这种简单模型,而是要求业务方能够显式提供幂等键,或者系统能从参数中提取一个稳定唯一字段。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 防重复提交的方案选型:为什么最终落在 Redis 原子操作上
2.1 单体内存锁的局限
最早我在小项目里是用 ConcurrentHashMap 加锁来做防重的,实现非常简单,但只限于单机。现在稍微像样点的服务基本都多实例部署,用户第一次请求打到 A 实例,第二次请求打到 B 实例,内存锁就完全失效了。也有人说可以用数据库唯一索引做,但这会把“拦截重复请求”的动作变成“数据库发现冲突”,依赖报错来做业务判断,体验和语义都不太对,而且高并发下数据库压力很大。
Redis 的优势在于三点:跨实例共享状态、单线程执行命令天然具备原子性、SETNX 配合过期时间可以很优雅地处理“一定时间内只允许一次成功”。我选型时基本没有太多犹豫,直接用 Redis。
2.2 锁、布隆过滤器还是请求指纹
定下来用 Redis,还有个前置问题需要想清楚:Redis 里面到底存什么?怎么判断两个请求是不是重复的?
我见过有团队直接用“用户 ID + 接口路径”做 key,这样用户 5 秒内只能调用一次接口,不管请求参数是什么。这种做法对并发很粗暴,会把同一用户并发调用两个不同参数的合法请求也误杀。例如用户同时提交两个不同地址的订单,理论上两个请求都该成功,直接按用户维度限流就不合理了。
我的做法是按“请求指纹”判断,也就是把接口路径、关键参数组合成一个唯一标识。只有路径和参数完全一致,或者命中了业务方显式提供的幂等键,才会被判定为重复请求。第一步先做参数签名:
- 从请求中取参数,包括 GET 的 query string 和 POST 的 form/body;
- 对参数进行排序、拼接,过滤掉无意义的时间戳字段;
- 对拼好的字符串做 SHA-256,得到 64 位十六进制指纹;
- 最终 Redis key 结构固定为
no_repeat:{userId}:{method}:{fingerprint}。
这么做的好处是通用性高,每个接口可以直接复用,不要求业务方额外做改动。坏处是如果两个请求参数中存在随机数,比如每次前端都生成一个 uuid 放在请求头里,那指纹就完全对不上了。所以到后面我在设计里做了一层抽象:优先使用业务方指定的幂等字段,拿不到再从参数中生成指纹,比较灵活。
2.3 选择 SETNX 还是 Lua 脚本
Redis 防重最常见的命令是 SET key value NX EX timeout。这个命令表示:只有当 key 不存在的时候才写入,并且设置过期时间,写入成功就说明当前请求首次到达,写入失败则说明已经有重复请求在处理。
但是单独用 SETNX 有一个小问题:如果我们不只是想做“是否出现过”,还想在 Redis 里记录第一次请求的处理状态(比如处理中、成功、失败),就需要一个复合动作。这时候我倾向于用一段短小的 Lua 脚本,把“判断是否已存在 + 写入请求状态”合并成一次原子调用。Redis 本身支持执行 Lua,不用太担心性能问题,一次调用走的是同一个事件循环,不会被其他命令插队,这一点和用多个 Redis 命令拼起来有本质区别。
我在实际代码里维护了一个工具类,核心方法如下:
java复制public boolean tryAcquire(String lockKey, String requestId, long expireSeconds) {
// 用 RedisTemplate 执行 Lua 脚本
// 逻辑:如果 key 不存在,set 并返回 1
// 如果 key 存在且 value 等于当前 requestId,说明是同一请求的重试,返回 1
// 否则返回 0
String script =
"if redis.call('exists', KEYS[1]) == 0 then " +
"redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2]); " +
"return 1; " +
"else " +
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return 1; " +
"else " +
"return 0; " +
"end; " +
"end;";
// ...
}
这里有个细节值得多说一句:value 我存的是请求唯一 ID,而不是固定字符串“1”。这样设计的好处是,当同一客户端因为网络超时重发了同一个请求,我们能够识别出它其实还是原来的请求,从而放行而不是再次拦截。但如果两个请求携带的 requestId 不同而业务参数相同,那它们就是真正的重复业务请求,应当被拦截。
上面这就是防重策略的关键。很多人容易在这个点写错,一旦判断条件过严,所有重试都会被拦死,反而影响正常的网络自愈。
3. 拿到一张“完整请求身份”需要解决的两个前置问题
3.1 拦截器里到底能不能拿到完整的请求体
做了半年 Spring Boot 开发的人基本都会遇到这样一个问题:在拦截器(HandlerInterceptor)里调用 request.getInputStream() 或 request.getReader() 之后,进入 Controller 时再读一次请求体就变成空了。原因是 Servlet 的输入流只能读取一次,它本质上是单向的流对象,读到底之后不会自动重置。
这个问题在防重复提交场景里非常致命。因为我们必须在进入业务方法之前完成指纹计算,而指纹恰恰需要依赖请求体内容。如果为了取参数把流读完,Controller 拿到的就是空 body,参数解析直接失败。
解决方式是用 ContentCachingRequestWrapper 包装原始的 HttpServletRequest,这个包装器会把请求体缓存一份到内存,后续重复读取完全没有问题。但有一点容易忽略:ContentCachingRequestWrapper 默认并不在请求进来时就把 body 全部读入内存,它是在 getInputStream().read() 被调用时才逐步缓存。所以你在拦截器里如果只是取一次参数然后就结束,到 Controller 那边再读包装器时,缓存里可能依然是空的。
我踩过这个坑之后总结了一个可靠流程:在 Filter 层做包装,并且尽早读取一次请求体,强制把缓存填充完整,再传递给后续链路。
java复制@Component
public class RepeatSubmitFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
if (isJsonContent(req)) {
ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(req);
// 强制读取一次请求体,触发缓存写入
wrapper.getInputStream().readAllBytes();
chain.doFilter(wrapper, response);
} else {
chain.doFilter(request, response);
}
}
}
这样在拦截器里再对 wrapper.getContentAsByteArray() 做转换,拿到的就是完整的请求体内容,不会再出现“第一次读到了,第二次读不到”的怪问题。
3.2 请求体参与指纹计算时的字段噪声
Post 请求体是 JSON 时,直接对整个字符串做哈希,会有几个明显问题:
- 字段顺序不同但内容相同,哈希结果会不同。例如
{"name":"Tom","age":18}和{"age":18,"name":"Tom"}对 json 字符串来说是两串文本,但对业务来说可能是同一个请求; - 请求体里带时间戳、随机数这类每次请求都变化的字段。如果不剔除,哪怕是用户正常的双击,两次请求的指纹也不同,防重策略就失去作用;
- null 字段,有些框架序列化时会把
null写出来,有些客户端又不传,导致同一个请求的序列化结果不完全一样。
我的策略是:不做通用 JSON 解析树去遍历筛除字段,因为那样太重,容易把代码写得非常繁琐。更好的做法是为需要防重的接口定义一个幂等键规则,让业务方告诉系统“这个接口用哪个字段区分业务唯一性”。比如下单接口,业务幂等键可能是 orderNo;支付回调接口,幂等键可能是 outTradeNo 或者 transactionId。
如果确实是通用防重,要兼容字段顺序,那么就在计算指纹前要转成一个规范结构。我实际用的是把 JSON 解析成 Map<String, Object>,再对 key 排序以后生成规范化的字符串。这样字段顺序不会影响结果。需要注意,对于嵌套数组或嵌套对象,要用递归方式处理,否则 inner 字段的顺序问题依然存在。
4. 整套落地方案:注解 + 拦截器 + Redis 三件套的工程化写法
4.1 自定义注解:让业务方声明这个接口需要防重
工程上我习惯用一个自定义注解把防重逻辑暴露给业务方,而不希望每个接口开发者都去理解 Redis 的 key 设计、指纹算法、拦截器细节。注解的核心属性有三个:过期时间、幂等键来源、是否启用。
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
// 锁自动释放时间,默认 3 秒
long expireSeconds() default 3L;
// 幂等键:支持 SpEL 表达式,如 #orderNo
String idempotentKey() default "";
// 是否对相同参数合并,默认 false 表示必须幂等键相同才算重复
boolean useRequestFingerprint() default false;
}
用的时候直接加在 Controller 方法上:
java复制@PostMapping("/order/create")
@NoRepeatSubmit(expireSeconds = 5, idempotentKey = "#req.orderNo")
public Result<OrderVO> createOrder(@RequestBody OrderCreateRequest req) {
// 业务逻辑
}
注解的生命周期我们选择保留到运行时,因为拦截器里要通过反射读取方法上的注解,动态决定哪些接口走防重逻辑。之前有同事问过为什么用注解而不用配置中心的白名单,我的回答是:注解把防重规则和业务接口直接绑定在一起,开发者维护成本最低,也不会出现改了配置中心忘记改某个敏感接口这种事故。
4.2 拦截器实现:计算幂等键、发起 Redis 原子抢占
拦截器是整个防重逻辑的主战场。我选择在 HandlerInterceptor.preHandle 阶段处理,原因有两个:这个阶段 Controller 方法还没有进入,拦截掉重复请求不会产生任何业务副作用;同时我们可以通过 HandlerMethod 直接获取到目标方法的注解,拿不到就放行,非常干净。
java复制@Component
public class RepeatSubmitInterceptor implements HandlerInterceptor {
private final StringRedisTemplate redisTemplate;
public RepeatSubmitInterceptor(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
NoRepeatSubmit annotation = handlerMethod.getMethodAnnotation(NoRepeatSubmit.class);
if (annotation == null) {
return true;
}
// 获取幂等键
String userId = getCurrentUserId();
String idempotentKey = resolveIdempotentKey(annotation.idempotentKey(), request, handlerMethod);
if (!StringUtils.hasText(idempotentKey) && annotation.useRequestFingerprint()) {
idempotentKey = buildFingerprint(request);
}
if (!StringUtils.hasText(idempotentKey)) {
throw new BizException(400, "无法生成幂等键,请检查接口参数");
}
String lockKey = "no_repeat:" + userId + ":" + handlerMethod.getMethod().getName() + ":" + idempotentKey;
String requestId = UUID.randomUUID().toString();
boolean acquired = tryAcquireLock(lockKey, requestId, annotation.expireSeconds());
if (!acquired) {
response.setCharacterEncoding("UTF-8");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(200);
response.getWriter().write(JSON.toJSONString(Result.error(500, "操作太频繁,请稍后再试")));
return false;
}
// 当前请求通过,把 requestId 放到 ThreadLocal 或 request attribute 中,
// 方便业务完成后主动删除锁
request.setAttribute("repeat_lock_key", lockKey);
request.setAttribute("repeat_request_id", requestId);
return true;
}
}
这里有一个很容易被忽视的问题:如果 Redis 抢占成功但业务处理耗时超过了 expireSeconds,那么锁已经自动过期,别的重复请求可能趁虚而入。所以我实际推荐把过期时间设置成比接口最长响应时间略大一些,比如接口最慢 2 秒,过期时间就设 5 秒左右。设置太短,起不到拦截效果;设置太长,用户真的因为网络闪断重试时会被长时间拒之门外。
4.3 注册拦截器时的一个顺序坑
Spring Boot 注册拦截器很简单,继承 WebMvcConfigurer 重写 addInterceptors 即可。但我在老项目里遇到过一个隐蔽问题:拦截器注册顺序和 Filter 执行顺序不同,如果同时存在认证拦截器,防重拦截器放到了认证拦截器之前,那么在没有登录的情况下也会执行防重逻辑,导致用户身份无法获取,指纹 key 精度下降。
所以要确保顺序合理,要么把防重拦截器加到认证拦截器之后,要么在防重拦截器里做一次判断,如果取不到用户 ID,就退化为 Ip + 接口地址这种粗粒度方案。我自己的做法是 registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**").order(2),保证认证拦截器的 order 比它小。
5. 防线之外的第二层:业务层如何做到真正的幂等
5.1 用数据库唯一索引兜底
拦截器和 Redis 这一层,做得再好也只能挡住“正常形态”的重复请求。一旦请求穿过了拦截器,进入业务代码之后,可能因为事务回滚后重试、消息重复消费等复杂原因,重复请求依然会发生在执行过程中。这时候,就是幂等设计出场的地方。
我看过很多人讨论幂等时,提倡只用“查询再判断”的方式来避免重复处理,比如:处理前查一下订单状态,如果已经是成功就返回。这种在单线程环境有效,但在并发环境下,两个请求都同时查到“待处理”状态,然后同时往下执行,照样会重复创建数据。正确做法一定是在存储层加硬约束,让第二条请求无法成功写入。
以“创建订单”为例:
- 订单表里对
biz_no字段建唯一索引; - 接口写库时直接插入这条记录;
- 如果插入时抛出 DuplicateKeyException,说明这个业务编号已经存在,就直接返回已存在的结果,而不是把异常抛给前端。
这种做法不需要预先加分布式锁,也不依赖消息队列去重,数据库的唯一索引就是最稳固的“最后一道防线”。
5.2 状态流转约束:不是所有字段都可以随便 UPDATE
幂等设计里还有一个高频翻车点:只判断了“存在与否”,没有判断“状态是否允许流转”。典型的例子是支付回调:
第一次回调过来,订单是“待支付”,我们更新成“已支付”;
第二次回调又过来,订单已经是“已支付”,这时候如果不判断状态,直接执行更新,可能把支付时间、回调流水号等字段覆盖成旧值,甚至把订单从“已支付”误更新回“处理中”。
这里我一般使用条件更新 SQL 做保护:
sql复制UPDATE t_order
SET status = #{newStatus},
pay_time = #{payTime},
version = version + 1
WHERE order_no = #{orderNo}
AND status = #{expectStatus}
UPDATE 的影响行数如果是 0,就说明状态不对,直接返回“无需重复处理”。这是比先查再更新更可靠的方案,避免了并发下两个事务同时读到同一个旧状态的问题。另外在有乐观锁字段的表里,把 version 条件带上,重复请求连影响行数都不会产生,排查问题也更方便。
5.3 消息消费场景下的去重表
Spring Boot 项目基本都会接入 MQ,而消息重复消费几乎是必然事件。RocketMQ 默认至少一次语义,RabbitMQ 在手动 ack 失败后会重新入队,Kafka 在 rebalance 后也可能导致部分消息被重复读取。
我在一个积分系统里用“消费去重表”解决过这个问题。核心思想很简单:消费消息前,把消息的唯一 ID(例如 RocketMQ 的 msgId,或者业务自定义的 eventId)插入一张去重表。这个表的主键就是消息唯一 ID,插入成功说明消息第一次消费;插入冲突说明已经消费过了,直接跳过。
sql复制CREATE TABLE mq_consume_log (
msg_id VARCHAR(64) PRIMARY KEY,
biz_id VARCHAR(64) NOT NULL,
consume_time DATETIME NOT NULL,
handler VARCHAR(64) NOT NULL,
KEY idx_biz_id (biz_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这张表要和业务更新在同一个事务里完成,否则会出现“业务更新成功,去重表记录失败”的情况,消息重投后重复处理。这一点非常重要。顺序上一般是:开启事务 -> 插入去重表 -> 执行业务更新 -> 提交事务。如果第二步去重表插入冲突,说明该消息已经处理过,直接回滚返回。
5.4 幂等方案对比与选择建议
做技术选型时常常要权衡成本,我整理了一个对照表,方便根据业务形态决定落在哪一层:
| 方案 | 实现成本 | 适用场景 | 局限性 |
|---|---|---|---|
| Redis SETNX 拦截 | 低 | 用户请求入口、短时间重复提交 | 依赖 Redis 可用性,锁过期时间不好把握 |
| 数据库唯一索引 | 中 | 订单创建、流水记录 | 插入冲突靠异常处理,需要定义好返回值 |
| 乐观锁版本号 | 低 | 更新类操作、余额变更 | 并发冲突激烈时会导致大量重试 |
| 条件更新 UPDATE | 低 | 状态机类操作 | 每类业务都需要独立写 SQL 条件 |
| 消费去重表 | 中 | MQ 消息消费、异步处理 | 需要额外表,事务要求高 |
| 状态机设计 | 高 | 长流程订单状态 | 需要梳理完整状态流转,不适合临时接入 |
没有银弹。我的原则是“入口 Redis 拦截 + 存储层约束兜底 + 业务状态机做最后防线”,三者配合,而不是互相替代。
6. 压测结果和上线后的一些经验补充
6.1 200 并发压测前的预判
上线之前我用 JMeter 做了一个并发压测,场景是模拟同一个用户对同一个订单号连续请求 500 次,线程数 200,看服务端最终能否只创建一个订单,同时观察正常用户的误杀率。
实际返回的数据是:Redis 拦截层拦下了 495 个请求,只有 5 个请求进入 Controller 层。这 5 个请求落在不同的 Tomcat 线程,但因为有数据库唯一索引和最外层状态判断,最终订单也只有一条。整个过程中接口没有出现 500 错误,被拦的请求统一返回了“操作太频繁”的友好提示。
这里也能看到,拦截器把绝大部分压力挡在了最前面,数据库层几乎没怎么承担重复压力。这也说明一个合理的防重设计应该让请求在“最便宜”的地方被拦下来,而不是一路渗透到底层存储才被发现。
6.2 Redis 不可用时的优雅降级
Redis 是防重拦截的关键依赖。一旦 Redis 宕机或者网络抖动,如果防重拦截器直接抛异常,所有需要防重的业务接口都会不可用,那实际上是把一个小概率故障放大成了大面积故障。
我采用的策略是:拦截器判断 Redis 调用异常时,不对请求做拦截,直接放行。防重属于“有更好”的增强能力,不能成为业务可用性的短板。同时我们可以通过监控 Redis 异常次数实时告警,确保能及时发现问题。当然有些强约束场景,比如扣减库存、余额支付,就算 Redis 挂了,数据库那层条件更新也会兜住,放行后业务自身仍然有幂等保障。
6.3 锁的删除时机和处理结果透出
有一个容易被忽略的点是:防重锁什么时候删除。
如果请求成功,业务正常结束,锁自然过期即可,不需要主动删除;如果请求失败,那应该立即删除锁,否则用户很快重试时会发现自己被一个“失败状态”的老请求挡住。有些团队用 finally 块主动删除锁,但又引入了新的问题:如果业务 A 还没执行完锁就过期了,业务 B 抢到了相同 key 的锁,此时 A 的 finally 执行删除锁,会把 B 的锁误删。所以主动删除时必须校验 value 是不是自己的 requestId,也就是要用 Lua 脚本保证“先比较再删除”是原子的。
java复制String delScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]); " +
"else " +
"return 0; " +
"end;";
这种细节在高并发上线前不会暴露,只有压测时才会出现莫名其妙的“锁失效”问题。写代码的时候尽量按这个标准来做,哪怕一时用不上,也不要给后人埋雷。
6.4 幂等性从不是单一技术问题
整套方案做完后我有一个特别直观的感受:防重复提交和幂等性看起来是代码层的技术问题,实际却是业务分析和协作问题。你要让前端在交互层就做好按钮防抖;要让网关层明确自己的重试策略;要让后端清楚每个接口的幂等键是什么;要让 DBA 为关键表建好唯一索引;还要让产品知道“同一个业务编号只有一个成功结果”这个规则是系统底线。
在我经历的那个事故里,如果前端按钮置灰做得好,后面所有事情都不会发生。但作为后端开发,我们没办法把系统的安全寄托在用户不手滑、前端不出 bug、网络永不重发这些不可控因素上。所以把入口拦截、Redis 防重、数据库唯一约束、业务状态机这几层按照成本和可靠性匹配好,才是真正的稳妥方案。代码实现上其实不算难,难的是把所有环节思考完整并串联起来。做完这次改造之后,我至少已经有底气说:同一笔业务请求,无论重复多少次,也不会再出现两条一样的订单了。
