1. 幂等性到底解决什么问题
1.1 一个事故现场:重复支付回调引发的惨案
先讲一个我踩过的坑。几年前我做一个商户收单系统,对接某第三方支付渠道,上线第二天就出了事故:用户支付成功后,渠道的回调通知因为网络抖动连续投递了三次,我们的回调接口每收到一次就执行一次"订单置为已支付 + 用户余额增加"的逻辑。结果用户付了100元,账户里凭空多了300元,订单状态还被反复刷新。那笔差错一直到月底对账才发现,最后走了一堆人工流程才把账抹平。
这个事故的核心问题只有一个:回调接口不具备幂等性。
所谓幂等性,用一句大白话说就是:同一个请求,不管被执行多少次,产生的业务效果都和只执行一次完全一样。数学上的表达更简洁:f(f(x)) = f(x)。放到接口场景,就是客户端重复提交同一个业务操作,服务端要保证只生效一次,不会因为重复请求导致数据错乱、金额多算、状态被覆盖。
你可能觉得"网络抖动只投递三次"是小概率事件,但实际在分布式系统里,重复请求是常态而不是意外。网关超时重试、客户端双写、消息队列重新投递、人工手动补单,随便哪个环节来一遍,你的接口就会被同一个请求砸好几次。所以业内有个共识:凡是对外暴露的写接口,都必须默认具备幂等性,这不是可选项,是必选项。
1.2 幂等和"并发安全"是两回事,别搞混
我经常看到有人把幂等性和并发安全混为一谈,这俩其实是不同维度的问题。
并发安全关注的是"多个不同请求同时到达时怎么保证数据一致",典型手段是加锁、事务隔离、乐观锁。它的目标是让一把数据在多线程环境下不脏读、不丢失更新。
幂等关注的是"同一个请求重复到达时怎么避免重复处理",它的目标是让一个业务动作只能落地一次。比如用户连续点击三次"提交订单",接口虽然被调了三次,但数据库里只有一条订单记录。
两者有交集:并发场景下,两个完全相同的请求同一瞬间到达,数据库层面可能同时命中同一条数据,这时候并发控制和幂等控制都要做。但它们解决的是不同的问题,设计接口时也要分开考虑,不能以为加了分布式锁就万事大吉,锁只能保证互斥,不能保证"同一个请求只处理一次"这个语义。
当你搞清楚这两个概念的区别,后面选择方案时会清晰很多:哪些场景用锁,哪些场景用唯一索引,哪些场景用状态校验,心里就有数了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 哪些场景必须考虑幂等性
不是所有接口都需要幂等设计,但下面这几类场景是重灾区。我跟不少人聊过,发现一个规律:凡是线上出过重复数据问题的,基本都逃不出这几类。
2.1 支付回调、下单这类"天然重试"场景
支付回调是最典型的例子。支付宝、微信的支付结果通知协议里明确写了"通知可能会多次发送",而且如果你返回的结果不是它们期望的报文,它们还会按照衰减策略继续重发,最长能持续好几天。所以回调接口必须把"重复通知"当成正常情况来设计,而不是异常情况。
下单场景同样高频踩坑。用户在前端点了一下"提交订单",按钮灰了但请求还在途中,用户以为没点上又点了一下;或者前端的轮询机制和手动刷新叠加,同一个订单请求就被发了好几次。如果没有幂等保护,数据库就会出现多条一模一样的订单,用户支付时看到一堆待付款单,体验直接崩掉。
还有个容易被忽略的场景是充值、提现这类金额操作。用户的手机银行、支付App在超时后往往会自动重试,渠道侧和商户侧同时认为"这笔钱应该到账",如果接口不幂等,用户充100到账200,这笔账迟早要查出来,而且查出来的时候往往已经形成坏账了。
2.2 消息队列消费、定时任务里的重复消费
消息队列是幂等问题的"批发商"。以Kafka为例,它提供的是at-least-once语义,也就是说消息在极端情况下一定会被重复投递。最典型的现象:消费者处理完一条消息,业务逻辑已经执行成功,但在提交offset之前服务宕机了,消费者重启后,这条消息会被重新拉取并再次处理。
定时任务也一样。分布式定时任务框架(比如XXL-JOB、ElasticJob)在路由策略配置不当、执行器漂移、手动触发补跑等情况下,同一个任务可能在多个节点上同时执行。如果任务内容是"扫描昨天所有未结算的订单并结算",重复执行的结果就是订单被结算两次。
所以只要你的系统里有MQ消费者、有定时任务、有对外回调接收,你就已经在幂等性的高危名单里了。与其等线上出问题再去补,不如在设计阶段就搞清楚自己的场景属于哪一类,然后对症下药。
3. 五种主流的幂等性落地方案
方案没有绝对的好坏,只有适合不适合。我按使用频率和适用场景,把常见的五种方案拆开讲清楚,包括实现原理、代码思路和要注意的坑。
3.1 数据库唯一约束:最可靠、最底层的兜底方案
数据库唯一约束是我最推崇的方案,没有之一。它的原理很简单:给业务表加一个唯一键,重复插入时数据库会直接报错,应用程序根据这个报错判断"这条数据已经处理过了"。
举个例子,支付回调表的结构可以这样设计:
sql复制CREATE TABLE pay_callback_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
channel_order_id VARCHAR(64) NOT NULL COMMENT '渠道订单号',
order_id VARCHAR(64) NOT NULL COMMENT '商户订单号',
callback_payload TEXT COMMENT '回调原始报文',
handle_status TINYINT DEFAULT 0 COMMENT '处理状态',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_channel_order (channel_order_id)
) COMMENT '支付回调记录表';
这里的channel_order_id是幂等键,它代表"同一笔渠道通知只允许插入一次"。当回调接口收到通知后,先尝试插入一条回调记录。插入成功,说明这是第一次处理,继续走业务逻辑;插入失败(抛DuplicateKeyException),说明是重复通知,直接返回成功即可。
这个方案最妙的地方在于,你不用先查再插,查了再插在并发下依然会重复,而唯一索引天然具备原子性,数据库引擎层面就帮你把"同一瞬间到达的两个相同请求"拦住了。它是真正的最终防线。
不过它有个局限:只适用于"新增数据"这种场景。如果是对已有数据做更新(比如改余额、改状态),后面几种方案会更合适。
3.2 Redis分布式锁:灵活通用,但要处理好细节
Redis分布式锁的用途很广,既能做并发控制,也能做幂等控制。它的核心逻辑是:业务处理前先抢一把锁,抢到了才执行,抢不到说明有别的请求正在处理同一个业务,直接返回或被丢弃。
简化版实现大概长这样:
java复制String lockKey = "pay:callback:" + dto.getChannelOrderId();
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(30));
if (!Boolean.TRUE.equals(locked)) {
log.info("重复请求被拦截, channelOrderId: {}", dto.getChannelOrderId());
return "success";
}
try {
// 执行业务逻辑
handleBusiness(dto);
} finally {
redisTemplate.delete(lockKey);
}
代码看着简单,坑却不少。第一个坑是锁的过期时间。如果业务逻辑执行时间超过了锁的过期时间,Redis自动把锁释放了,另一个请求又拿到锁进来,幂等保护就形同虚设了。业界通用做法是用Redisson这种自带"看门狗"机制的客户端,它能自动续期,业务没执行完锁不会过期。
第二个坑是锁的粒度。刚才例子里的key精确到了渠道订单号,如果写成pay:callback这种全局粒度,同一时刻所有回调请求都被串行化,性能直接被打爆。锁的粒度一定要精确到具体业务ID,宁可多写几个key,也不能一把锁锁住整个接口。
第三个坑是锁和业务要在同一个生命周期。很多人习惯在进入方法时加锁,业务处理完finally释放,这个思路没问题,但要确认锁的逻辑和事务的回滚是匹配的,否则会出现"锁释放了但事务还没提交"的间隙,并发请求还是会读到旧数据。
3.3 状态机校验:让业务状态自己说话
状态机校验是我最喜欢的一种方案,因为它非常"业务化"——幂等与否的判断标准就是业务状态本身,不需要额外引入存储和数据结构。
它的原理很简单:业务对象的生命周期是有状态的,而且状态流转是单向的、确定的。比如一个订单的合法流转路径是"待支付 -> 已支付 -> 已发货 -> 已完成",不允许"已支付 -> 待支付"这种回跳,更不允许"待支付 -> 已支付"重复发生两次。
实现的时候,更新语句里带上状态条件:
sql复制UPDATE t_order
SET order_status = 'PAID',
pay_time = NOW()
WHERE order_id = #{orderId}
AND order_status = 'PENDING_PAY'
这条SQL的核心在于AND order_status = 'PENDING_PAY',它保证只有"待支付"的订单才能被置为"已支付"。如果订单已经是"已支付",这个更新会返回0行,说明有重复请求,直接返回成功即可——因为业务目标已经达成了。
这个方案的优势在于:它用业务状态本身来承载幂等语义,不需要单独维护幂等标记表,也没有锁过期时间的问题。只要状态机设计得足够严谨,重复请求无论如何都翻不出浪花来。
缺点也很明显:它要求业务对象必须有清晰的状态流转,对于复杂的业务(比如订单有多种取消原因、退款有部分退和全额退)需要把各个状态分支都设计清楚,状态设计得不对,后面改起来成本很高。
3.4 Token机制:把幂等判断前置到请求入口
Token机制的思路是:在真正执行业务操作前,先给请求发放一个唯一的令牌(Token),然后要求请求必须携带这个Token,服务端在处理时校验Token是否存在,存在就删除并执行业务,不存在就认为是重复请求。
流程如下:
- 客户端调用
获取Token接口,服务端生成一个幂等Token,存入Redis并设置过期时间(比如10分钟),返回给客户端。 - 客户端提交业务请求时,在Header或Body中携带这个Token。
- 服务端收到请求后,先执行"检查并删除Token",删除成功说明是首次请求,继续处理;删除失败说明Token已被使用过,直接返回"重复提交"的提示。
关键点在于"检查并删除"这两个操作必须是原子的。写成"先get判断是否存在,再delete"会有并发窗口,两个相同请求同时进来可能都get到同一个Token,都认为自己有权处理。正确做法是用Lua脚本:
lua复制if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
或者用Spring Data Redis的opsForValue().getAndDelete()(高版本支持)来实现原子删除。
Token机制的优点是把幂等控制和业务逻辑解耦,业务代码几乎无感知;缺点是多了一次前置请求,客户端需要配合改造,对纯后端接口来说没有前面几个方案直接。它最适合的是"防表单重复提交"这类场景。
3.5 版本号/乐观锁:更新类操作的最优解
如果你的核心操作是"更新一条已有数据",并且更新多个字段是基于同一个业务条件,乐观锁是性价比很高的选择。
方法是在表上增加一个version字段,每次更新时同时校验版本号:
sql复制UPDATE t_account
SET balance = balance - #{amount},
version = version + 1
WHERE user_id = #{userId}
AND version = #{oldVersion}
或者用业务条件替代版本号,比如扣款的余额条件:
sql复制UPDATE t_account
SET balance = balance - #{amount}
WHERE user_id = #{userId}
AND balance >= #{amount}
这种写法保证了"并发重复扣款"时只有一条SQL能命中。败下阵来的请求返回0行影响,业务层拿到0就知道是重复请求,直接返回"操作成功"或者"余额不足",看具体语义判断。
乐观锁的优点是简单、无额外存储、并发性能好;缺点是它适用于"同一行数据的更新",如果你的幂等需求跨多张表、多个操作,就需要配合事务和前面的方案一起使用。
3.6 五种方案怎么选:一张决策表
说了这么多方案,落到实际项目里怎么选?我根据自己的项目经验,整理了一张粗粒度的决策表:
| 业务场景 | 推荐方案 | 理由 |
|---|---|---|
| 新增记录(创建订单、创建流水等) | 数据库唯一约束 | 最可靠,数据库层兜底 |
| 更新已有数据(改状态、改余额等) | 状态机校验 + 乐观锁 | 业务语义清晰,不用额外存储 |
| 接口入口防重复提交 | Token机制 | 与业务解耦,前置拦截 |
| MQ消费端重复消息 | 唯一约束 + 状态机组合 | 消费场景复杂,需要多重保障 |
| 高并发写操作(秒杀、抢购) | Redis分布式锁 + 状态机 | 需要锁来控制并发窗口 |
实际操作中,一个接口往往不是只用一种方案。比如支付回调,我先用支付流水表的唯一约束挡住重复通知,再用订单状态机校验防止状态被回跳,这是非常典型的双保险组合。
4. 实战:支付回调接口的幂等改造全过程
前面讲了理论,这节用一个完整的实战案例把方案串起来。假设我们正在做一个扫码支付系统,对接了一个第三方支付渠道,渠道的通知协议是HTTP POST,要求商户端在收到通知后返回字符串success,否则渠道会重试,重试次数最多可达15次。
4.1 原始代码的问题分析
改造前的回调接口长这样:
java复制@PostMapping("/pay/callback")
public String payCallback(@RequestBody String payload) {
// 1. 解析渠道通知报文
PayNotifyDTO notifyDTO = parsePayload(payload);
// 2. 验签(省略)
// 3. 把订单置为已支付
Order order = orderMapper.selectByOrderId(notifyDTO.getOrderId());
if (order != null && order.getStatus() == OrderStatus.PENDING_PAY) {
order.setStatus(OrderStatus.PAID);
orderMapper.updateById(order);
// 4. 给用户账户加余额
accountService.increaseBalance(order.getUserId(), order.getAmount());
}
return "success";
}
这段代码表面上没什么大毛病,但它有三处隐患。第一,如果渠道重复通知,而订单状态还是"待支付",就会再次走一遍置为已支付和加余额的逻辑。第二,就算第二次进来时订单状态已变成"已支付",跳过执行业务,但账户加余额的动作如果独立于订单状态之外,依然可能被重复执行。第三,两个通知同时到达,两个线程都读到订单是"待支付",都可能通过状态判断,导致加余额执行两次。
4.2 设计目标与方案选择
改造目标很明确:无论渠道通知多少次,无论通知是否并发到达,最终的效果都必须是"订单只被置为已支付一次,用户账户只增加一次余额"。
我采用的方案是:唯一约束 + 状态机 + 事务 三件套。
具体来说:
- 建一张支付流水表,用唯一索引约束渠道通知号,保证同一笔渠道通知只能插入一次。
- 更新订单状态时带上"待支付 -> 已支付"的条件,保证状态只能流转一次。
- 整个处理逻辑放在一个事务里,任何一步失败都整体回滚,让渠道重试。
4.3 具体代码实现
先建表:
sql复制CREATE TABLE pay_flow_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
channel_order_id VARCHAR(64) NOT NULL,
order_id VARCHAR(64) NOT NULL,
pay_amount DECIMAL(10,2) NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_channel_order (channel_order_id)
) COMMENT '支付流水表';
核心处理逻辑:
java复制@Transactional(rollbackFor = Exception.class)
@Override
public void handlePayCallback(PayNotifyDTO dto) {
// 1. 插入支付流水,利用唯一索引挡住重复通知
try {
payFlowMapper.insert(PayFlowRecord.build(dto));
} catch (DuplicateKeyException e) {
// 出现唯一键冲突,说明这条渠道通知已经被处理过
log.info("[幂等拦截] 重复支付回调, channelOrderId: {}", dto.getChannelOrderId());
return; // 直接返回,事务正常提交,不抛异常
}
// 2. 状态机校验:只有待支付状态的订单才能被置为已支付
int updateCnt = orderMapper.updateOrderStatus(
dto.getOrderId(),
OrderStatus.PENDING_PAY.getCode(),
OrderStatus.PAID.getCode()
);
if (updateCnt == 0) {
// 订单状态已经不是待支付了,可能是已支付或已取消
Order order = orderMapper.selectByOrderId(dto.getOrderId());
if (order != null && order.getStatus() == OrderStatus.PAID) {
log.info("[幂等拦截] 订单已支付, orderId: {}", dto.getOrderId());
return; // 目标已达成,不继续处理
}
// 其他情况说明数据有问题,抛出异常让渠道稍后重试
throw new BizException("订单状态异常,无法完成支付确认");
}
// 3. 给用户账户加余额
accountService.increaseBalance(dto.getUserId(), dto.getPayAmount());
}
这段代码的每一行都值得展开讲。
关于第一步的try-catch,为什么插入冲突时不抛异常?因为渠道通知重试是正常行为,我们拦截住之后直接返回成功即可,不需要让渠道知道"我们收到了一条重复通知",如果抛异常出去,渠道会继续重试,虽然也无害,但白白增加了链路压力和日志噪音。事务里return不会触发回滚,所以插入成功的记录会被保留,这是幂等标记落地的关键。
关于第二步的状态校验,用updateCnt == 0和查一次订单信息来区分"已支付"和"业务异常"两种情况。区分"已支付"和"业务异常"是关键,因为已支付时重复通知是正常现象,直接返回成功;而订单不存在或被取消时,说明业务有问题,抛出异常让渠道重试,避免数据不一致。
关于事务边界,这三个步骤在同一个事务里。一旦中间任何一个service抛异常,整个事务回滚,已经插入的支付流水也会被回滚。这意味着渠道重试时,会从头再来一遍完整流程。这种设计保证了数据的一致性,不会出现"流水记录了但订单状态没变"的中间状态。
4.4 改造后验证与监控要点
改造完成后,我建议至少做三件事来验证效果。第一,用一个渠道通知号连续调用接口十次,检查支付流水表只有一条记录、订单状态始终是已支付、账户余额只增加一次。第二,用一个从未出现过的渠道通知号调用接口,确认能够正常走完整个流程。第三,模拟并发场景,用JMeter开10个线程同时提交相同的渠道通知号,观察数据库唯一索引能否拦住重复插入。
监控方面,我习惯在幂等拦截的日志点加上独立的日志标识,比如[幂等拦截],然后配置日志监控和告警。一旦幂等拦截的次数异常升高,说明渠道侧或者前端可能存在重复调用问题,需要及时排查。此外,支付流水表的唯一性约束还可以作为对账的辅助依据,每月对账时核对渠道对账单和内部流水记录,能发现一些隐蔽的异常。
5. 常见的坑和排查心得
方案看懂了、代码也写完了,并不代表上线就高枕无忧。下面这几个问题是我在实际项目中反复踩过的坑,每一个都对应着线上真实事故,写出来供你参考。
5.1 幂等键选错了,等于白做
很多初学者做幂等设计时,幂等键选得很随意。比如用户提交订单时用"用户ID"作为幂等键,结果用户连续提交两次订单,第二次直接被拦截,用户以为没提交成功,实际上后台连个提示都没有,这就把幂等机制做成了业务功能的阻碍。
幂等键的选择要遵循一个原则:同一个业务动作重复到达时,生成的幂等键必须完全相同;不同业务动作的幂等键必须不同。 用"用户ID+操作类型"组合更合理,因为同一个用户提交的同一个操作,无论重复多少次都应该是同一条;但用户提交不同操作时,幂等键需要不同。
再举一个反例。有次我们的回调接口用了"订单号"作为幂等键,结果发现渠道对一个订单可能发送"支付成功"和"退款成功"两种不同的通知,这两种通知都被当成同一条请求拦截了,导致退款成功的通知永远处理不了。后来把幂等键改成"订单号+通知类型",问题才解决。幂等键选错,要么拦截了不该拦截的请求,要么放过了该拦的重复请求。
5.2 锁过期时间不合理的连锁反应
用Redis分布式锁做幂等的时候,锁过期时间是个老大难问题。设短了,业务还没执行完锁就没了,后面进来的重复请求直接穿破防线;设长了,如果业务直接崩溃,锁会一直占用到过期,影响后续正常处理。
我见过一个真实案例:一个订单处理接口的锁过期时间设置为10秒,但业务在极端情况下需要处理20秒。结果一个请求在15秒时还没结束,第二个相同请求已经拿到锁进来,两个请求同时处理同一笔订单,库存被扣了两次。事后排查时,定位到锁过期只是表象,根本原因是业务耗时估算错误。
解决这个问题,最简单的办法是使用Redisson的lock.lock()自动续期机制,或者自己实现一个定时续期的守护线程。如果嫌麻烦,就把锁的过期时间设得足够宽,同时监控业务耗时,一旦业务耗时接近锁过期时间就预警,让架构师评估是否需要优化业务性能或者换掉这个方案。
5.3 幂等判断和业务操作不在同一个事务里
幂等判断的结果(比如"已处理"标识)必须和被保护的业务操作处于同一个事务中。如果幂等标识更新成功,但业务操作失败回滚,两者不一致,就会留下"标识显示已处理,实际上业务没执行"的错误状态。
举个例子,我用Redis的setnx做幂等标记,业务处理完再删除标记。如果业务处理过程中抛出异常,标记还在Redis里,后续重试请求会被拦截,但这笔业务其实没有成功。正确做法是:要么在业务事务里维护幂等标识(比如唯一约束),要么在异常发生时主动清理Redis标记。
这个问题的本质是:幂等方案的落点必须和业务操作共享同一个原子边界。数据库唯一约束天然具备这个能力,Redis锁则需要额外处理异常情况。所以我在实际项目中总是优先选择数据库方案,Redis方案只有在无法改表结构或者业务不需要强一致时才使用。
5.4 状态更新影响行数为0时的思考盲区
使用状态机校验时,一个常见的问题是"更新影响行数为0,到底表示重复请求还是业务异常"?很多人的第一反应是"说明订单已经被处理过了",然后直接返回成功,这个判断有时候是错的。
我遇到过一个退款场景。订单退款接口的状态机设计是"已支付 -> 退款中 -> 已退款"。用户发起退款,第一次请求把状态从"已支付"更新为"退款中",影响行数为1。但由于接口超时,客户端重试了同一个请求。第二次请求进来时,订单状态已经是"退款中"了,更新条件"status = 已支付"不成立,影响行数为0。如果此时直接返回成功,看起来没问题;但如果用户紧接着又发起一次新的退款,就会变成"已退款 -> 退款中"这种非法状态流转。
所以,影响行数为0时,不能简单判断为重复请求就完事,需要查一次当前状态,再决定是"幂等成功"还是"业务冲突"。幂等成功可以直接返回,业务冲突要抛出异常让调用方感知,否则最终会积累出一堆混乱的数据。
5.5 回调返回报文格式踩坑
有一次对接渠道,我们处理完业务后返回了success但带了个换行符,结果渠道侧解析失败,一直重发通知。这种问题看起来很小,但会造成大量重复请求涌入接口,虽然幂等机制能扛住,但白白消耗资源,而且告警日志刷屏。
我的经验是:回调接口的返回报文必须严格遵循渠道文档,不要自己加多余内容。文档说返回"success",就一个字都不能多,连空格和换行都不要加。排查的时候如果发现渠道一直在重试,先去对返回报文格式,往往能最快定位问题。
6. 一个统一的幂等控制框架思路
最后分享一个让我省了很多事的设计思路:如果项目里需要幂等保护的接口很多,不建议在每个接口里各写一套幂等逻辑,而是做一个统一封装。
我的做法是自定义一个注解+切面(AOP),通过注解声明幂等控制方式:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* SpEL表达式,用于从参数中提取幂等键
*/
String key();
/**
* 控制方案:UNIQUE(数据库唯一约束)、REDIS(Redis锁)、TOKEN(前置令牌)
*/
Strategy strategy() default Strategy.REDIS;
/**
* 过期时间(秒),默认30分钟
*/
long expire() default 1800;
}
然后写一个切面,统一处理幂等逻辑:
java复制@Aspect
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(idempotent)")
public Object doAround(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 1. 解析SpEL表达式,从请求参数里提取幂等键
String idempotentKey = parseKey(idempotent.key(), joinPoint.getArgs());
// 2. 组装Redis锁的key
String lockKey = "idempotent:" + idempotentKey;
// 3. 尝试加锁
String requestId = UUID.randomUUID().toString();
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, Duration.ofSeconds(idempotent.expire()));
if (!Boolean.TRUE.equals(acquired)) {
// 锁已存在,说明是重复请求
throw new DuplicateRequestException("重复请求: " + idempotentKey);
}
try {
// 4. 执行业务逻辑
return joinPoint.proceed();
} finally {
// 5. 只有锁的value是当前requestId才能释放,防止误删其他请求的锁
String currentValue = redisTemplate.opsForValue().get(lockKey);
if (requestId.equals(currentValue)) {
redisTemplate.delete(lockKey);
}
}
}
}
这样业务代码就清爽很多,只需要在需要幂等的接口上加一行注解:
java复制@PostMapping("/order/create")
@Idempotent(key = "#dto.orderId", strategy = Strategy.REDIS)
public Result createOrder(@RequestBody CreateOrderDTO dto) {
// 这里只管写业务逻辑,幂等控制交给了切面
return orderService.create(dto);
}
不用注解的话,也可以参考这个思路写一个通用的幂等工具类,按需调用。这套框架的好处是把跨接口的通用逻辑抽离出来,统一维护、统一升级,等发现新的坑(比如锁续期、线程池问题)时,只改切面就好了,不用去每个接口里改代码。
我自己在实际操作中的体会是:幂等设计一定要放在系统设计的最早期,而不是等到出了问题再回头补。接口设计阶段就想清楚"这个接口会不会被重试、重复调用会不会有副作用",一遍就把幂等做好,后面能省下大量运维精力和口头解释的功夫。遇到已有的老接口需要补幂等,优先选数据库唯一约束,因为它最可靠、最容易解释清楚;等把数据库方案用扎实了,再根据实际需要引入Redis锁、Token机制这些更灵活的手段。
