接口幂等性设计的六种方案与选型建议
一、为什么"幂等"总在出问题之后才被想起
几乎所有线上重复扣款、重复下单、重复发券的事故,根因都可以归成同一句话:请求被投递了不止一次,而服务端默认"一次请求 = 一次业务"。
重复的来源比大多数人想象的多:
- 用户手抖连点两次提交按钮;
- 移动端弱网超时后自动重试,第一次请求其实已经成功;
- Nginx / 网关层超时重发;
- 消息队列的 at-least-once 语义,消费失败重投;
- 上游服务 RPC 超时重试(Dubbo、Feign、gRPC 默认都带重试);
- 运维手动补数据、重放消息。
这些都不是"异常情况",而是分布式系统的常态。所以幂等不是可选优化,而是接口契约的一部分。
二、先区分两个概念:幂等 ≠ 去重
很多人把幂等写成"查一下有没有,有就返回"。这只能挡住串行重复,挡不住并发重复——两个请求同时查到"没有",然后同时插入。
真正的幂等需要满足三个条件:
- 唯一标识:能判断"这是不是同一个业务请求",通常是一个业务流水号(bizNo)或请求 ID。
- 原子性判定:判定与写入必须在同一个原子操作里完成,或者由存储层保证唯一。
- 结果一致:重复请求返回的结果与首次一致,而不是报错或返回空。
第二点是最容易漏的。下面六种方案,本质区别就在于"原子性由谁保证"。
三、方案一:数据库唯一索引(最朴素,也最可靠)
把幂等键做成唯一索引,让数据库替你做并发控制。
CREATE TABLE t_order (
id BIGINT PRIMARY KEY,
biz_no VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(12,2) NOT NULL,
status TINYINT NOT NULL,
UNIQUE KEY uk_biz_no (biz_no)
);
插入时捕获唯一键冲突:
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
// 说明这个 bizNo 已经处理过,查询后返回既有结果
return orderMapper.selectByBizNo(order.getBizNo());
}
优点:零额外组件,并发安全由存储引擎保证,最不容易出错。
缺点:幂等键必须有业务含义且能稳定生成;跨库分表时唯一索引失效,需要换成方案四或方案六。
在 GaussDB / MySQL 上,这个方案配合 INSERT ... ON DUPLICATE KEY UPDATE 或 ON CONFLICT DO NOTHING 会更简洁,能直接拿到"是否新插入"的语义。
四、方案二:乐观锁与版本号
适合"更新类"操作,比如账户余额变更、订单状态流转。
UPDATE t_account
SET balance = balance - #{amount}, version = version + 1
WHERE id = #{id} AND version = #{version};
影响行数为 0 就说明被别人改过,重试或直接返回冲突。
要点:更新条件里必须带上旧值或版本号,只判断 WHERE id = ? 是没有任何并发保护的。金额类操作建议再加一层 AND balance >= #{amount},避免把"并发"变成"透支"。
五、方案三:状态机约束
业务状态天然带方向,把状态作为更新的前置条件,是最廉价也最贴合业务的幂等。
-- 只有"待支付"才能变成"已支付"
UPDATE t_order SET status = 2
WHERE id = #{id} AND status = 1;
重复回调打进来时,第二次 status 已经是 2,影响行数为 0,直接返回成功即可。
这个方案的价值在于:它同时解决了幂等和乱序问题。支付成功回调和支付关闭回调如果乱序到达,状态机可以保证结果终态正确,而纯幂等键做不到。
六、方案四:分布式锁(DCS for Redis)
当幂等键无法做成唯一索引(比如跨分片、跨服务)时,用分布式锁把"判定 + 执行"串起来。
String key = "idem:" + bizNo;
boolean locked = redis.set(key, "1", SetParams.setParams().nx().ex(60));
if (!locked) {
throw new BizException("请求处理中,请勿重复提交");
}
try {
doBusiness();
} finally {
// 注意:不要在这里无条件删锁,见下方"坑"
}
三个必须注意的点:
SET NX EX必须是原子命令,不要写成setnx+expire两条。- 锁的过期时间要大于业务最长耗时,否则业务没跑完锁就释放了,等于没加。
- 释放锁要校验持有者(用 value 存 requestId,配合 Lua 脚本比对后删除),否则可能删掉别人的锁。
另外,分布式锁只解决"同时处理",不解决"处理成功后再次请求"。所以通常还需要方案一或方案五兜底——锁是并发控制,不是幂等本身,这一点经常被混淆。
七、方案五:令牌机制(先取票,后消费)
客户端先申请一个一次性令牌,提交业务时带上;服务端校验并删除令牌,删除成功才执行业务。
// 取令牌
String token = UUID.randomUUID().toString();
redis.set("token:" + token, "1", SetParams.setParams().ex(300));
// 提交时校验
Long deleted = redis.del("token:" + token); // 返回 1 表示令牌有效且已消费
if (deleted == null || deleted == 0) {
throw new BizException("重复提交");
}
适合表单提交、支付发起这类"用户主动触发"的场景。缺点是增加一次网络交互,且令牌丢失后用户需要重新取票。
八、方案六:消息消费去重表
消息队列场景(DMS for Kafka / RabbitMQ)下,消费端必须自己保证幂等,因为 MQ 只承诺 at-least-once。
标准做法是"业务表 + 去重表,同一事务写入":
@Transactional
public void consume(Message msg) {
int inserted = dedupMapper.insertIgnore(msg.getMsgId()); // 唯一索引
if (inserted == 0) {
log.info("消息 {} 已消费,跳过", msg.getMsgId());
return;
}
doBusiness(msg);
}
关键点:去重记录和业务操作必须在同一个本地事务里。如果先写去重表、再调远程服务,中间失败就会造成"消息被标记为已消费但业务没做"的数据丢失——这比重复消费更危险。
如果业务本身跨服务无法放进一个事务,退而求其次:去重表只记录"处理中"和"已完成"两个状态,配合定时任务补偿"处理中"超时的记录。
九、选型建议
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 单库单表的下单、发券 | 唯一索引 | 最简单,并发安全由存储保证 |
| 余额、库存变更 | 乐观锁 / 状态机 | 天然带前置条件,顺带解决乱序 |
| 跨分片、跨服务的业务流水 | 分布式锁 + 唯一索引兜底 | 锁管并发,索引管重复 |
| 用户表单提交、支付发起 | 令牌机制 | 交互层拦截,用户体验清晰 |
| MQ 消费 | 去重表 + 本地事务 | 应对 at-least-once 的必要手段 |
| 支付/退款回调 | 状态机 | 必须同时处理重复和乱序 |
十、几个真实踩过的坑
坑一:幂等键用错了维度。
用 userId + amount 做幂等键,结果用户连续买两笔同价商品,第二笔被当成重复直接丢弃。幂等键必须是"一次业务动作"的唯一标识,通常由客户端生成并透传,不能由服务端从业务参数拼出来。
坑二:把幂等做成了"防重"。
只在网关层做 N 秒内相同参数拦截。这在分布式部署下毫无意义(请求可能落到不同节点),而且会误杀合法的重复业务。
坑三:锁释放写在 finally 里但不校验持有者。
A 业务卡住 70 秒,锁 60 秒过期,B 拿到锁开始处理;A 结束时在 finally 里把 B 的锁删了。C 立刻又拿到锁——三个请求同时跑。这是分布式锁最经典的事故模型。
坑四:只做接口幂等,不做数据幂等。
接口层挡住了重复请求,但下游服务自己还有重试。幂等要在每一个会改变状态的地方做,而不是只在入口做一次。
十一、小结
幂等设计的核心不是选哪个方案,而是先回答三个问题:
- 这个操作重复执行会不会产生副作用?
- 我能不能拿到一个稳定的、一次业务动作唯一的标识?
- 判定"是否已执行"和"执行"能不能原子完成?
三个问题回答清楚,方案自然就定了。反过来,如果只记住"加个分布式锁",那大概率会在某个并发窗口里翻车。
一句话总结:幂等不是加锁,而是让"重复"这件事在数据层变得不可能。
- 点赞
- 收藏
- 关注作者
评论(0)