大概半年前,我在负责的一个交易系统里遇到过一次典型的线上事故:一笔订单的支付回调因为下游网关重试,同一笔成功通知被连推了三次。第一次正常入账,第二次重复加了余额,第三次把订单状态从“已支付”打回了“支付中”。大半夜的,运营群里炸了锅,我盯着日志里那三条一模一样的请求,心里只有一个念头——接口幂等性这个老生常谈的问题,不处理的时候只是隐患,一旦触发就是事故。
后来我把这套系统的幂等方案整体重做了一遍,从数据库约束、状态机、Redis锁到Token机制都捋了个遍,该踩的坑一个没落。这篇文章就把我在实战里验证过的方案、选型逻辑和踩坑过程完整写出来。不管你是刚接触后端接口设计的新人,还是已经在分布式系统里摸爬滚打的开发者,只要你的接口会被重复请求、会被重试、会被并发打到,这篇内容都能直接拿来用。
1. 幂等不是"防重":先搞清楚问题的本质
很多同学一听到"幂等性",第一反应是"这不就是防止重复提交吗"。这话对了一半,但恰恰是没想清楚的那一半,会在设计上埋下大坑。
1.1 一次请求和多次请求,系统状态必须一致
幂等(Idempotent)的严格定义是:用户对同一个操作发起一次请求和发起多次请求,对系统产生的影响是相同的。注意,这里说的是"影响相同",不是"返回结果相同"。
举个例子:你调用"查询订单详情"接口,不管调一次还是一百次,返回的数据基本一致,这是天然幂等的。但如果你调用"创建订单"接口,POST一次生成一个订单,POST两次生成两个订单,这就不是幂等的。问题的核心在于操作是否改变了系统状态,以及状态变化的最终结果是否可收敛。
就我的经验而言,最容易出事的场景集中在三类:一是支付/退款回调,外部系统几乎一定会重试;二是前端表单重复提交,用户手快连点两次;三是MQ消费者在消费消息后宕机,重启后再次拉取到同一条消息。这三类场景有一个共同特点——调用方不信任你的响应,所以它会用重试来保证自己"消息必达",而你的接口必须在这种重复压力下做到系统状态不乱。
1.2 幂等、防重、并发控制:三件事别混为一谈
这里必须先掰扯清楚三个经常被混用的概念:
- 防重:强调同一时刻或同一时间段内,相同请求只被处理一次。防重的核心是拦截,但拦截失败的兜底手段往往就是幂等。
- 并发控制:强调多个不同请求同时修改同一份数据时,不能产生脏写。它解决的是"两个人同时操作"的问题,幂等解决的是"同一个人重复操作"的问题。
- 幂等:强调无论请求来几次,最终状态一致。它天然包含了防重的能力,但更强调"结果是收敛的"。
打个生活化的比方:防重像小区门禁,同一个门禁卡在一小时内只能刷进一次;并发控制像银行柜台的两个窗口同时给同一个账户记账,必须靠锁防止账目错乱;幂等像你有一张理发店的多次卡,但店员每次都会在你名下记一笔"已消费",哪怕你刷十次卡,最后卡里的剩余次数也只减一次。
设计一个高可用的接口,三个层面通常都需要。但很多人一上来就想着"我加个Redis锁就完事了",结果锁过期了、锁误删了、锁粒度太粗导致性能雪崩,问题一个接一个。真正的做法是先想清楚接口的天然属性,再叠加对应的保护机制。
1.3 HTTP动词本身的幂等属性,你利用起来了吗
从HTTP协议层面看,GET、PUT、DELETE天然具有幂等性,POST不具备。很多人对这个结论只停留在背面试题的层面,实际设计接口时完全没利用起来。
GET是查询,无副作用,天然幂等;DELETE是删除,删除一个不存在的资源通常也返回成功,因为目标状态就是"不存在",也天然幂等;PUT是整体更新,把资源A完全替换为新值,执行一次和十次,最终状态都是新值,所以也是幂等的;只有POST在语义上是"创建"或"追加",每执行一次就会多出一条数据,因此才需要额外的幂等机制。
这意味着一个很实用的设计思路:能使用PUT的更新接口就不要用POST,能使用DELETE的删除接口就不要用POST。我在重构订单系统的"修改收货地址"接口时,把POST改成PUT之后,同一个地址更新请求即使因为网络超时被客户端重发,也不会在数据库里产生多条更新记录,天然解决了一类重复问题。
当然,实际业务远比RESTful规范复杂,比如"创建订单"这个动作虽然语义上是POST,但业务上绝对不能因为用户连点两次就生成两个订单。所以接下来要聊的,是真正意义上的幂等方案实现。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 五种主流幂等方案的实现原理与玩法
有了前面的基础认知铺垫,下面进入硬核环节。我把自己在项目中验证过的五种方案都拆开讲,每一步涉及的关键代码、配置细节、适用边界都会说透,尽量做到你看完就能直接在自己项目里落地。
2.1 数据库唯一约束:最朴素也最可靠的兜底方案
在所有的幂等方案里,数据库唯一约束是我认为的"最后一道防线"。它的实现思路极其简单:在数据库表上建立一个包含业务唯一标识的联合唯一索引,当重复请求试图插入重复记录时,数据库直接拒绝。
假设业务上要求同一个用户对同一个商品只能下一个有效订单,那么可以在订单表上建立唯一索引:
sql复制ALTER TABLE `order`
ADD UNIQUE KEY `uk_user_product` (`user_id`, `product_id`, `order_type`);
当应用层第一次插入成功,第二次再插入时,数据库会抛出 DuplicateKeyException。应用层捕获到这个异常后,直接返回"该订单已存在"的提示即可。
这个方案的优势是绝对可靠——只要数据库不撒谎,重复插入就一定被拦截,不存在Redis宕机、锁过期之类的现网事故。代价是它只适用于插入场景,不适用于更新场景。你不可能靠唯一约束去限制"订单金额从100改成200"这种操作只会生效一次。
实操上还有一个关键细节:唯一索引的字段设计必须既包含业务主键,又包含幂等标识。比如支付回调场景里,同一笔支付单号可能会回调多次,但你希望同一个回调单号只记录一次回调通知。此时可以在回调记录表上建立 biz_order_no 的普通唯一索引,效果更好。
另外,使用唯一约束方案时,必须做到"先查后插"变"直接插"。很多新手喜欢先 select 判断是否存在,不存在再 insert,这在高并发下根本拦不住——两个请求同时查到都不存在,同时插入,最终还是会撞索引,而撞索引的报错还得靠捕获异常兜底。所以不如直接插入,把唯一约束当作天然的裁判。
2.2 乐观锁和状态机:让更新操作自行收敛
插入场景用唯一索引,更新场景怎么办?最常用的方案是乐观锁(Optimistic Lock)。
乐观锁的核心是CAS(Compare And Set)思想:更新时带上版本号或期望值,只有当前值与期望值一致时,才允许更新。以下面的库存扣减为例:
sql复制UPDATE `stock`
SET `quantity` = `quantity` - 1,
`version` = `version` + 1
WHERE `product_id` = #{productId}
AND `quantity` > 0
AND `version` = #{oldVersion};
执行此SQL后,如果返回的影响行数为1,说明更新成功;如果影响行数为0,说明版本号不匹配或库存不足,需要重新查询并处理。这种方式天然做到了并发控制,同时也具备幂等性——同一个请求带着相同的旧版本号重试多次,只有第一次能更新成功,后面的重试都会因为版本号不匹配而失败。
在实际业务中,我更推荐用"业务状态"而非"版本号"来做更新判断,也就是状态机方案。因为版本号只能告诉你"数据被改过",但无法告诉你"这次改动是否合法"。而状态机可以用状态字段的流转约束,直接拒绝非法更新。
典型的案例是订单状态流转:待支付 -> 支付中 -> 已支付 -> 已发货 -> 已完成。支付回调处理逻辑可以这样写:
sql复制UPDATE `order`
SET `status` = 'PAID',
`paid_time` = NOW()
WHERE `order_id` = #{orderId}
AND `status` = 'PENDING_PAYMENT';
同样判断影响行数:若为1,说明此次回调将订单从未支付状态推进到了已支付;若为0,说明订单当前状态已经不是"待支付",大概率已经被处理过,直接返回成功即可。这套逻辑让订单状态只有向前流转,绝不回退,天然的防重复、防乱序。
状态机方案特别适合流程类业务,比如订单、审批流、工单系统。我见过不少项目一开始用版本号方案,后来业务状态越来越多,判断逻辑越来越复杂,最终都切换到了状态机方案。如果你们系统里正好有状态字段,优先考虑用它,而不是单独加一个 version 字段。
2.3 Redis SetNX 分布式锁:高并发入口的第一道闸门
在流量较大的入口处,比如秒杀、拼团、支付回调,数据库唯一约束扛不住高并发插入,此时可以引入Redis分布式锁作为前置拦截。
Redis分布式锁最常见的实现是基于 SETNX 指令:
java复制String lockKey = "lock:order:pay:" + orderId;
String requestId = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, Duration.ofSeconds(10));
if (Boolean.TRUE.equals(locked)) {
try {
// 业务逻辑:处理订单支付
doProcessPayment(orderId);
} finally {
// 释放锁:用Lua脚本保证原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) " +
"else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
requestId
);
}
} else {
// 说明已有其他请求在处理,这里直接返回处理中或丢弃
}
这里有几个关键点必须注意:
- 加锁要带请求唯一ID:不能用简单的
SETNX lockKey 1,因为释放锁时需要校验是不是自己加的锁。如果不校验,A请求执行时间超过锁过期时间,锁自动释放,B请求拿到了锁,此时A请求终于执行完,一DEL把B的锁删了,后面C又进来了,锁就形同虚设。 - 释放锁必须用Lua脚本保证原子性:
get和del两步不能分开,否则在极端时间窗口下,可能刚get完发现是自己的,还没del锁就过期了,B请求重新加锁,此时A的del把B的锁误删。 - 锁过期时间要设置合理:太短,业务没执行完锁就自动释放,起不到互斥作用;太长,如果加锁方宕机,其他请求要等很久。一般建议设置成业务最大耗时的1.5到2倍,比如支付回调最大可能执行5秒,就设置10秒。
Redis锁本质上是拦截重复请求的性能闸门,但它有一个天然缺陷——锁只能保证"同一时刻只有一个请求在跑",无法阻止"前一个请求处理完后,后一个请求再进来执行一遍"。所以Redis锁必须搭配状态机或唯一约束使用,Redis负责拦截并发,数据库负责兜底最终一致。只有Redis锁没有数据库兜底的方案,很可能在锁过期或极端情况下捅娄子。
2.4 Token机制:给每次操作一个唯一身份
如果是用户主动触发的前端操作,比如提交订单、发布文章,Token机制是体验最好的方案之一。
它的流程是:
- 客户端在提交前,先调用后端接口申请一个业务Token。
- 后端生成唯一标识(UUID等),存入Redis并设置过期时间,然后返回给前端。
- 前端提交业务请求时,必须携带这个Token。
- 后端收到请求后,先校验Token是否存在,存在则删除并继续执行业务;不存在则说明请求重复,直接拦截。
关键点在于"先删除Token再执行业务",而不是"先执行业务再删除Token"。如果是后者,并发场景下两个请求同时查到Token存在,然后同时执行业务,又同时删Token,拦截就会失效。先删除Token,等于在入口处加了一个"一次性通行证"——只有持有通行证的请求能进入,而且用过即焚。
java复制String tokenKey = "biz:token:" + request.getToken();
Boolean success = redisTemplate.delete(tokenKey);
if (Boolean.FALSE.equals(success)) {
// token不存在,说明请求已被处理过
return Result.error("请勿重复提交");
}
// 继续执行业务
doBizLogic(request);
这里有个小陷阱需要特别注意:delete 返回的是Key是否删除成功,但在并发下,SETNX 的方式比 delete 更严谨。更稳妥的写法是用 SETNX:
java复制Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(tokenKey, "1", Duration.ofMinutes(5));
if (Boolean.FALSE.equals(acquired)) {
return Result.error("请勿重复提交");
}
我实际项目中用的就是 setIfAbsent 方案。Token方案最核心的价值是:它把幂等判断从"业务层"前置到了"入口层",业务逻辑里不需要写任何重复判断的代码,干净利落。但它的代价是要求前端配合,必须先调用Token接口再提交,增加了交互往返。
2.5 MQ消费端去重:消息队列里的幂等实践
在消息驱动的系统里,消费者的幂等问题经常被忽略。RabbitMQ、Kafka等消息队列为了保证消息不丢失,都提供了至少一次(At Least Once)的投递保证,这意味着消费者端的重复消息是必然的。
在消费者里做幂等,最简单的方式是"消费记录表+唯一约束"。消费前置一张 message_consume_log 表,以消息ID或业务唯一ID建立唯一索引:
sql复制CREATE TABLE `message_consume_log` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`message_id` VARCHAR(64) NOT NULL COMMENT '消息ID',
`biz_key` VARCHAR(64) NOT NULL COMMENT '业务唯一ID',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '处理状态',
`create_time` DATETIME NOT NULL,
UNIQUE KEY `uk_message_id` (`message_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
消费逻辑是:先尝试往表里插入一条消息记录,插入成功说明这条消息第一次来,继续执行业务;插入失败则说明重复消息,直接Ack掉。
这里有一个生产级的优化细节:不要单纯用 message_id 做唯一索引,最好用"业务唯一键"。因为消息重投时消息ID可能不变,但真正的防重核心是"同一条消息背后的业务动作只做一次"。比如订单支付成功的消息,业务唯一键是 order_id。如果消息体里既有订单号又有用户ID,直接用订单号做唯一键更贴合语义。
MQ消费端的幂等是整个链路里最容易被忽视的环节,我见过不止一个项目,入口接口幂等做得固若金汤,结果消费者里重复触发下游接口,照样把数据搞乱。如果你的系统里用了MQ,消费者去重务必加上。
3. 方案选型:不同业务场景该选哪把钥匙
方案不在多,关键在匹配。很多团队容易走入一个极端——"我全都要",结果同一个接口既加了Redis锁又上了唯一索引还套了Token机制,层层叠叠,问题没有变少,性能却下降了一大截。正确的姿势是先判断业务场景属于哪种类型,再选择对应的方案组合。
3.1 几个主流方案的核心差异对比
为了让你有一个全局视角,我先把五种方案的核心维度整理成一张表。这张表在我的团队里被贴在了文档首页,每次设计新接口时都会对照一遍。
| 方案 | 适用场景 | 主要优点 | 缺点/风险 | 幂等强度 |
|---|---|---|---|---|
| 数据库唯一约束 | 插入类操作,如创建订单、注册账号 | 绝对可靠,无中间件依赖 | 只适用插入;撞索引后靠异常兜底,不够优雅 | 最强 |
| 乐观锁/版本号 | 更新类操作,如库存扣减、余额变更 | 并发控制与幂等合二为一 | 需要额外的version字段;冲突率高时压力大 | 强,但依赖字段设计 |
| 状态机 | 流程类业务,如订单状态流转 | 语义清晰,天然防止状态回退 | 需要业务流程完整梳理 | 强 |
| Redis分布式锁 | 高并发入口,如秒杀、支付回调 | 拦截性能高,能扛住瞬时流量 | 依赖Redis;锁过期需谨慎处理 | 中等(需数据库兜底) |
| Token机制 | 前端主动操作,如表单提交 | 入口层拦截,业务代码干净 | 需要前端配合,多一次交互 | 中等 |
| MQ消费去重表 | 消息驱动架构的消费者 | 解决消息重复投递 | 多一张消费记录表,需要定期清理 | 强 |
3.2 我在实际项目里的选型决策路径
每个人遇到的具体业务不一样,但我可以分享一下自己沉淀出来的选型决策路径,每次设计接口时照着走一遍,基本不会出错:
- 看动作类型:如果是插入操作,优先考虑“消费表+唯一约束”;如果是更新操作,优先考虑乐观锁或状态机。
- 看业务并发:瞬时流量大、同一业务键的并发请求可能高达几十上百的,在入口加Redis分布式锁;并发量一般的,直接走数据库层,没必要引入Redis增加运维复杂度。
- 看调用方可靠性:外部系统(支付网关、物流平台)几乎一定会重试,必须做幂等;内部系统之间的调用,可以约定调用方不重试,但防御性幂等仍然建议做。
- 看交互链路:如果前端能配合先申请Token,就用Token机制;如果不能,就依赖后端独立的幂等逻辑。
- 看是否使用MQ:只要有MQ消费,就老老实实加消费去重表,不管上游是否是"可靠投递"。
这套决策路径不是拍脑袋定的,而是被一次次真实事故逼出来的。最早我们的订单接口只用了Redis锁,结果一次Redis集群抖动,锁服务短暂不可用导致大量请求穿透到数据库,直接打挂了主库。后来在数据库层加了唯一约束和状态机兜底,才算真正稳下来。所以我的个人建议是:任何核心链路,至少做两层幂等保护——入口层拦截一次,数据库层兜底一次。
3.3 主键策略在幂等设计中的隐藏作用
很多人聊幂等生成的订单号,很少聊主键在幂等方案里的作用。这里专门提一下,因为我在实战中踩过一个大坑。
当时我们用数据库唯一约束做幂等,唯一索引建在业务字段上,但业务字段后来因为需求变更允许为空,导致唯一约束在MySQL中对NULL不做限制(NULL != NULL),多个空值可以同时插入成功,重复请求直接穿透了约束。后来排查了半天才发现是NULL导致的。
所以,做唯一约束时,最好把主键ID也设计成带业务语义的幂等键。目前主流做法是使用全局唯一ID生成器(雪花算法)生成 order_id,它天然具备唯一性,加上数据库主键唯一约束,即使业务字段异常,主键也能拦截重复插入。
另外说一句:如果你是用了自动增长主键(AUTO_INCREMENT)且只靠主键做幂等,等于没做。因为同一个业务请求重试时,你没法保证生成的主键相同,只有显式传入或基于业务字段生成的ID才能真正作为幂等键。
4. 完整实战:支付回调接口的幂等设计与踩坑记录
光讲原理容易飘,接下来我用一个完整的支付回调场景,把整套设计思路走一遍。这是我亲自重构过的真实案例,包含了方案设计、代码实现、以及上线后踩到的各种坑,含金量足够高。
4.1 场景描述与方案组合
支付回调接口的业务背景是:下游支付网关在用户付款成功后,会向我们的接口发起异步回调通知,通知可能重复多次,顺序也可能乱,甚至同一笔订单的支付成功回调和退款回调交叉到达。
这个场景的幂等需求非常典型:一笔订单只能被支付成功一次;支付成功后被更新的订单状态不能被回调重置;重复回调必须安全返回成功(否则网关会一直重试)。
我的方案组合是:Redis分布式锁(入口拦截) + 状态机(订单状态流转) + 数据库唯一约束(回调记录兜底)。三层各司其职:
- 第一层,Redis锁保证同一订单同一时刻只有一个回调在走业务逻辑;
- 第二层,状态机保证回调无论重复多少次,订单状态只能从待支付流转到已支付,不能回退;
- 第三层,回调记录表的唯一索引保证哪怕前两层都被绕过,数据库层面也能拦住重复处理。
4.2 核心代码实现:三层防线叠加
先看入口处的处理逻辑。这里我用伪代码来展示核心链路,方便你理解先后顺序和每一层的职责:
java复制public void handlePayCallback(PayCallbackRequest request) {
String orderId = request.getOrderId();
String lockKey = "lock:pay:callback:" + orderId;
String requestId = UUID.randomUUID().toString();
// 第一层:Redis分布式锁,拦截并发回调
boolean locked = redisLock.tryLock(lockKey, requestId, 10, TimeUnit.SECONDS);
if (!locked) {
// 获取不到锁,说明同一订单正在被处理,直接返回成功
return;
}
try {
// 第二层:数据库唯一约束 + 状态机,保证业务只生效一次
PayCallbackResult result = payCallbackService.process(request);
if (!result.isSuccess()) {
log.error("pay callback process failed, orderId={}", orderId);
}
} finally {
redisLock.unlock(lockKey, requestId);
}
}
process 方法里是业务核心,先插入回调记录,再更新订单状态:
java复制@Transactional(rollbackFor = Exception.class)
public PayCallbackResult process(PayCallbackRequest request) {
String orderId = request.getOrderId();
// 插入回调记录,依赖唯一索引去重
try {
payCallbackRecordMapper.insert(new PayCallbackRecord(request));
} catch (DuplicateKeyException e) {
// 重复回调,直接返回成功,不再处理
return PayCallbackResult.success("duplicate callback");
}
// 基于状态机更新订单状态
int rows = orderMapper.updateStatusIfPending(
orderId, "PAID", "PENDING_PAYMENT");
if (rows == 0) {
String currentStatus = orderMapper.selectStatus(orderId);
// 如果已经是已支付或更靠后的状态,说明已处理过,安全返回
if ("PAID".equals(currentStatus) || "FINISHED".equals(currentStatus)) {
return PayCallbackResult.success("already paid");
}
// 其他状态(如已关闭、已退款)属于非法流转,告警
alertService.send("order status illegal, orderId=" + orderId);
return PayCallbackResult.fail("illegal status");
}
return PayCallbackResult.success();
}
updateStatusIfPending 的SQL是状态机方案的关键:
sql复制UPDATE `order`
SET `status` = 'PAID',
`paid_time` = NOW()
WHERE `order_id` = #{orderId}
AND `status` = 'PENDING_PAYMENT';
这三层配合起来的效果是:就算Redis因为网络抖动Key不存在了,状态机的 UPDATE 一行也不会让订单支付两次;就算某个请求绕过了状态机检查,回调记录表的唯一索引依然能兜底。所谓"层层设防",意义就在这。
4.3 回调乱序与状态回退:一次现场事故复盘
方案设计得再严密,上线后还是会有意外。这里复盘一个让我印象深刻的线上事故。
某天订单系统出现了一个诡异的问题:用户支付成功后,订单状态竟然从"已支付"回退到了"待支付"。日志显示,支付成功的回调先到了,正确处理完成;随后一笔"支付取消"的回调才姗姗来迟,把状态给改了回去。
问题出在哪里?第一版的回调处理逻辑里,我只对"支付成功"做了状态机保护,忽略了"支付取消"这个动作的幂等设计。"支付取消"回调进来时,判断当前状态是"已支付",不属于非法流转,就直接执行了状态更新。这在调用方眼中完全合法——因为对于取消回调来说,它的目标就是把订单置为取消状态。
修复方案有两个:第一,"支付取消"回调进来时,必须检查当前状态;如果已经是"已支付"或更靠后的状态,直接拒绝,不允许回退;第二,给每次回调增加业务语义顺序判断——回调报文里通常有事件时间或事件类型,要保证处理顺序和事件发生顺序一致。
从那以后,我把状态机从"仅处理支付成功"扩展成了覆盖全链路的"订单状态机",每个回调动作都严格走状态流转校验,任何一个动作都不能把状态往后退。这才算是真正把支付回调的幂等做完整了。
4.4 回调接口返回值的坑:必须让网关"放心"
支付回调还有一个常见坑——回调接口的返回值会直接影响网关的重试行为。如果你在重复回调时返回了错误码,网关会认为回调没送达,会一直重试,重试频率从几分钟到几小时递增。这会给你的系统带来持续的无效压力。
正确的做法是:对已经处理过的重复回调,业务上返回"成功"。注意,这里的"成功"指的不是"本次处理成功",而是"这个业务动作已经完成,你无需再重试"。上面代码里我在重复回调场景返回的就是 PayCallbackResult.success("duplicate callback"),目的就是告诉网关:这单已经处理完了,不用再烦我了。
如果你把重复回调标识为"失败",网关重试流量会一直来,甚至可能把真正的异常给淹没。这个细节看起来很小,但在生产环境里直接关系到系统的稳定性和告警噪声。
5. 那些设计细节和隐蔽坑:我是这样一个个填平的
最后这部分,我把过去几年和幂等性纠缠的过程中遇到的高频细节坑和设计教训系统性地写出来。每一条都是真实撞过墙才明白的,希望能让你少走弯路。
5.1 Redis锁的过期时间与重入问题
Redis锁最让人头疼的就是过期时间。设置太短,长任务还没结束锁就自动释放了;设置太长,一旦执行业务的节点宕机,其他请求就得等很久。这个平衡我建议这么做:
- 用续期机制:给锁加一个看门狗线程,在锁过期前自动续期。Java生态里Redisson的
RLock已经内置了看门狗功能,默认30秒过期,每10秒续期一次。如果是自研框架,就写一个定时器做续期。 - 如果不想引入Redisson,退而求其次的做法是:把过期时间设为业务预估最大耗时的2倍以上,同时做好监控告警,一旦出现频繁等待锁超时的现象,及时调整。
还有重入问题:同一个线程已经拿到锁了,再次尝试获取同一把锁,应该能成功。SETNX 方案天然不支持重入,如果业务代码里出现"A方法加锁后调用了B方法,B方法也去获取同一把锁"的情况,就会死锁。自研锁方案时记得用 ThreadLocal 保存当前锁的持有者线程,遇到重入时直接放行。
5.2 数据库事务与分布式锁的执行顺序
这个坑比较隐蔽,但踩到的人不少。写法是这样的:
java复制@Transactional(rollbackFor = Exception.class)
public void processWithLock(String orderId) {
redisLock.lock("lock:order:" + orderId);
try {
// 事务业务逻辑
doBiz();
} finally {
redisLock.unlock("lock:order:" + orderId);
}
}
表面上看没问题:先加锁,然后执行业务,最后释放锁。但Spring事务是在方法出口处提交的——事务提交的时机是方法结束后、代理方法返回前。也就是说,"释放锁"这行代码执行的时候,事务可能还没真正提交到数据库。
极端情况下:A请求释放锁,B请求立即拿到锁,此时A请求的事务还没提交,B读到的还是旧数据,又一次执行了相同业务。这就绕过了幂等保护。
正确的做法是将锁的范围覆盖到事务提交之后,比如在方法调用处加锁,然后单独调事务方法:
java复制public void processOuter(String orderId) {
redisLock.lock("lock:order:" + orderId);
try {
processInTransaction(orderId);
} finally {
redisLock.unlock("lock:order:" + orderId);
}
}
@Transactional(rollbackFor = Exception.class)
public void processInTransaction(String orderId) {
// 业务逻辑
}
这样锁的释放发生在事务方法返回之后,事务已经提交,下一个请求进来读到的一定是新数据。在我见过的事故里,这个细节引起的重复执行比Redis宕机还常见,值得重点检查。
5.3 幂等标识的生成来源:别用不可靠字段
幂等标识是整个方案的基石,如果标识本身可能重复,后面所有保护都会失效。我见过有团队用订单金额加上用户ID拼接作为幂等键,结果同一用户同一金额的两笔不同订单被错误拦截了;也见过用时间戳做幂等键的,高并发下直接重复。
经验法是:
- 幂等键优先使用业务全局唯一ID,比如订单号、支付流水号、消息ID。
- 如果没有现成ID,就在请求入口生成UUID,并存储下来供重试用。
- 用多个业务字段拼接时,要确认这些字段的组合确实能唯一定位一次业务操作。
- 所有参与幂等判断的字段,都要考虑NULL值场景。前面提过MySQL唯一索引对NULL不做限制,这是最容易踩的隐藏BUG。
我自己的项目里,进入核心业务逻辑前都会有一个 IdempotentContext 初始化环节,统一从请求头或请求体里提取幂等ID,缺失则自动生成。这样后续所有幂等逻辑都依赖同一个来源,不会出现各层各用各的标识导致判断不一致的情况。
5.4 幂等记录的数据清理:别让表无限膨胀
所有基于"记录表+唯一约束"的幂等方案,最终都会面临数据膨胀问题。支付回调记录表一天就会新增几百万行,如果不清理,半年后表就有几个亿的数据,索引效率感人。
清理方案有两种思路:
- 定期归档:将超过N天(比如30天)的幂等记录离线归档到冷存储,线上表只保留近30天数据。归档任务在业务低峰期跑,每次批量删除,避开大事务。
- 过期清理:如果幂等记录只用于判断重复,不需要追溯,可以直接物理删除超过保留周期的数据。但要注意删除时不要与业务高峰重叠,用分批删除的方式控制锁范围。
在写这套文章的时候,我还想提一个隐藏在幂等背后的老生常谈但必须强调的原则:不能过度设计。不是所有接口都需要大批量上幂等,读多写少且天然幂等的接口,加锁反而白白增加响应时间。最合理的做法是识别出"多写多查、可能重复执行"的核心链路,把有限的防护力量集中在这些节点上。
这阵子再做支付系统评审的时候,我经常会问团队一个问题:如果这个接口被同一参数压着打100次,系统的状态会不会乱?如果一个接口敢拍胸脯回答"不会",那么幂等性这块它基本是过关了。希望大家在设计和Review自己的接口时,也拿这个问题多问几次,很多隐患在正式上线前就能提前暴露出来。
