- 用户点了提交,页面在转圈,心里着急,又点了一下。这个再普通不过的动作,后端往往要面对两笔一模一样的订单、两条重复的扣款记录。如果只是用户连点还好,更麻烦的是支付平台回调重推、MQ消息重复消费、RPC重试机制,这些“系统级重复”根本不受前端控制,随时可能把数据搞乱。
- 大多数团队处理这种问题,第一反应是在业务代码里写一段
Redis setnx,第二反应是下一个项目里复制一份改一改。直到某天线上冒出一个漏网的重复请求,大家才意识到,散落在各个服务里的“防重代码”本身才是最大的风险源——每个接口的实现方式都不一样,有的把标记存在数据库,有的用本地锁,有的干脆只在前端做了个按钮置灰。 - 我最近把一个通用幂等和防重组件沉淀成了独立的
spring-boot-starter,基于Redis实现,通过一个注解就能让任意接口具备防重和幂等能力。从最初的“能跑”到真正敢放在生产环境,中间踩了不少坑,也推翻过好几版设计。这篇文章把完整的设计思路、核心实现和那些常规文档里不会写的问题都梳理一遍,给需要的团队做个参考。
1. 先搞清楚:防重、幂等、分布式互斥到底在解决什么
很多人把这三个词混着用,但设计阶段不把它们分开,做出来的组件一定别扭。我的理解是这样的:防重关注的是“这次请求不允许重复进来”,幂等关注的是“重复请求执行多次和执行一次的效果相同”,而分布式互斥关注的是“同一时刻只能有一个实例执行这段代码”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1.1 三者的典型场景差异
- 防重最常见的场景是用户下单时连点提交,或者表单提交时没等结果就再次点击。前端按钮置灰能挡住大多数情况,但总有绕过前端的人,服务端必须兜底。
- 幂等更典型的场景是支付回调。支付平台为了保证通知成功,会按间隔重试多次,甚至同一个回调在极端情况下会重复推送到两个不同的实例。消费端如果只是简单判断“重复就不处理”,第一次消费成功、第二次重复消费时仍然可能把数据改坏。
- 分布式互斥更像是一种底层能力,本质是让逻辑在并发环境下只执行一次,很多需要抢占式处理的定时任务、缓存重建场景会用到。
1.2 同一个中间件必须同时覆盖这三种诉求
这也是我把组件设计成“状态机”而不是简单“加个key”的原因。如果只用一个SETNX做防重,只能挡住“同一瞬间”的并发重复,但挡不住“第一次处理完成后,第二次请求又来了”这种后置重复;如果只做幂等,控制不了请求并发期间的互斥。所以中间件内部的Redis标记不能是一个“有/无”的二元状态,而应该是一个流转的状态机:请求进来时标记为“执行中”,业务完成后标记为“已成功”,业务失败时移除标记。这样“执行中”负责互斥,“已成功”负责幂等,“移除”负责允许重试。
简单做个对照:
| 能力 | 核心问题 | 标记状态 | 失败处理 |
|---|---|---|---|
| 防重 | 重复请求不允许进入 | 执行中 | 移除标记,允许重试 |
| 幂等 | 重复请求必须结果一致 | 已成功 | 不改变结果 |
| 互斥 | 同一时刻只能一个请求执行 | 执行中 | 等待或拒绝 |
2. 选型与设计决策:为什么是注解 + AOP + Redis 这套组合
通用中间件的第一步不是写代码,而是选型。我见过不少团队在这个环节纠结很久,有的用数据库唯一索引,有的用本地缓存,有的用Zookeeper分布式锁。客观说,这些方案都有自己的适用场景,但对于“通用幂等与防重”这个目标,Redis确实是最优解。
2.1 Redis 对比数据库唯一索引和本地锁
数据库唯一索引防重是最早的常见方案,在订单表上建一个业务单号的唯一索引,重复插入时数据库会抛异常。这个方案的优点是数据绝对准确,缺点是侵入业务表结构、依赖数据库性能,而且在高并发下数据库连接容易被打满。更重要的是,它只能防“写入型”重复,对“读取后计算再写入”这种典型非幂等操作无能为力。
本地锁的问题更明显:一个服务部署三个实例,每个实例都有自己的锁,同一个请求分发到不同实例上,本地锁根本感知不到彼此。用Redisson分布式锁能解决实例间互斥,但又引入一个新的问题——锁释放的时机很难把控,业务超时导致锁提前释放的场景几乎每个用Redisson的团队都踩过。
Redis方案的好处在于:SET NX EX一条命令就能实现原子性的“占位+过期”,GET + 比较 + DEL配合Lua脚本可以实现安全的锁释放,Redis本身又是绝大多数后端团队已有的基础设施,不用额外引入新组件。
2.2 注解驱动的侵入性取舍
接口防重最怕的就是业务代码里到处是Redis操作。我见过有的项目在Service层里写:
java复制Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
if (!success) {
throw new RuntimeException("重复请求");
}
这类代码最大的问题是:每个接口的key生成规则不同、过期时间不同、清理方式不同,散落一地,后来维护的人根本不敢动。而且如果调用方换了实现方式,比如从Redis换成了数据库,所有接口都要跟着改。
所以我坚持用注解 + AOP来封装。使用方只需要在接口方法上加一个@Idempotent注解,指定幂等键的取法,剩下的Redis交互、状态流转、锁释放全部由切面统一处理。业务代码零侵入,底层实现想要从Redis换成别的存储,只需要替换一个IdempotentRepository实现类。
2.3 令牌模式和参数模式的兼容设计
市面上常见的防重组件,要么只支持“请求头传token”的令牌模式,要么只支持“从参数提取key”的参数模式。我一开始也只想做一种,但实际使用中发现两种模式各有不可替代的场景:
- 令牌模式:前端先调用
POST /idempotent/token获取一个一次性token,提交业务请求时把这个token放在Header里。服务端消费token,消费成功才执行业务。这种模式防重效果最强,同一个token无论如何都不可能提交两次,适合下单、支付这类“提交一次就够了”的重操作。 - 参数模式:服务端从请求参数中提取业务唯一键,比如订单号、流水号、消息ID,拼接后作为Redis key。不需要前端额外配合,适合处理第三方回调、MQ消息这类“源头自带唯一键”的场景。
一个通用中间件如果只支持其中一种,使用面会窄很多。所以注解里我会加一个type字段,PARAM和TOKEN两种模式都支持,切面里根据类型走不同的处理分支。
3. 核心原理:Redis状态机、Lua脚本与看门狗续期
设计一个生产级中间件和写Demo最大的区别在于,要仔细推演各种并发时序下的行为。这一节我把组件的地基拆开讲清楚。
3.1 单一标记为什么不够:三个状态的设计由来
最直觉的防重实现是这样的:请求进来时SETNX一个key,成功就执行业务,失败就拒绝。只用一个key的“存在”来表达“不允许重复”,有一个很大的漏洞——第一次请求已经处理成功了,key还在,第二次请求仍然会被拒绝。
这个逻辑放在“防重”场景下是正确的,因为防重确实不希望同一个操作在短时间内被重复执行。但在“幂等”场景下就出问题了:幂等的要求不是“拒绝重复请求”,而是“重复请求要获得和第一次一样的处理结果”。同一个支付回调第一次处理成功了,第二次再推过来时应当被直接判断为“已处理”,而不是抛一个“处理中”的异常。
所以我把Redis value设计成三种状态:
| 状态 | value取值 | 含义 | 请求处理逻辑 |
|---|---|---|---|
| 执行中 | 请求唯一ID | 第一次请求正在执行 | 重复请求拒绝或等待 |
| 已成功 | SUCCESS | 第一次请求已处理完成 | 重复请求直接返回成功 |
| 已移除 | key不存在 | 业务失败或已过期 | 允许重新执行 |
这个设计让同一个组件既能做防重(遇到“已成功”也拒绝),又能做幂等(遇到“已成功”直接返回成功结果),还能做互斥(遇到“执行中”互相等待)。
3.2 原子性问题的经典陷阱与Lua脚本解法
上一节提到的“执行中”状态,常见实现是两步:
java复制Boolean success = redisTemplate.opsForValue().setIfAbsent(key, requestId);
if (success) {
redisTemplate.expire(key, 10, TimeUnit.SECONDS);
}
这段代码有个很经典的问题:setIfAbsent和expire是两次独立的Redis操作,如果第二次设置过期时间前进程崩溃,这个key就成了“永不释放的锁”,后面所有相同key的请求都会被永久拒绝。很多人写的时候没注意,线上出了故障才排查出来。
正确做法是用Redis的SET NX EX一条命令完成“占位+过期”,或者直接用Lua脚本把两步合并成一步。我在组件里用的是Lua脚本,因为后续还有“检查value再删除”“检查value再更新状态”这些复合操作,脚本统一管理更清晰:
lua复制-- 尝试获取执行权
if redis.call('set', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then
return 1
else
return 0
end
KEYS[1]是幂等key,ARGV[1]是本次请求的唯一ID,ARGV[2]是过期时间秒数。这条脚本的原子性由Redis单线程模型保证,不需要额外加锁。
3.3 看门狗续期机制:避免长业务被打断
过期的意义是“兜底”,防止进程崩溃后key永远不释放。但有了过期时间,就引入了另一个问题:业务执行时间超过过期时间,Key提前失效,后一个重复请求就能再次获得执行权,导致同一业务被并发执行两次。
举个例子,一个订单处理接口正常耗时1秒,你给了30秒过期时间,看起来绰绰有余。但某天接口因为外部依赖变慢,一个请求跑了40秒,30秒时key到期被清掉,此时另一个相同请求进来,发现key不存在,于是也进来执行,两个
