接口幂等性:从去重到状态机的完整设计 — editorial cover photo

接口幂等:为什么重复请求会重复扣款

重试、超时、重复点击——幂等是分布式系统的生存底线,不是可选项。

2025年5月26日·返回文章列表
文章大纲

有一次大促复盘,最贵的教训不是机器不够,是一个支付接口没做幂等。用户点了支付,网络慢,客户端超时自动重试,服务端其实第一次已经扣款成功,第二次又扣了一遍。客诉上来才发现,一个用户当晚被扣了三次款。事后排查,扣款逻辑本身没问题,问题在于接口没有任何防重保护,重试被当成了新请求。

分布式系统里,同一个请求被执行多次是常态而非意外。网络超时客户端会重试,MQ 消息会重复投递,用户会疯狂点击,网关有重试机制。这些都不是边界情况,是每天都会发生的常规流量。接口不幂等,重复执行就出乱子,重复扣款、重复发货、重复扣库存。幂等不是可选项,是分布式系统的生存底线。

图:幂等 = 去重键 + 状态机,重复请求返回同一结果

幂等到底指什么

先把这个定义钉死。幂等指的是同样的请求执行一次和执行多次,效果完全相同。注意是效果相同,不是返回相同。第一次扣款返回成功,第二次因为已经扣过返回已扣款,这两个返回不同,但效果都是只扣了一次,这就是幂等。

从 HTTP 语义看,GET 查询天然幂等,DELETE 删除也基本幂等,删一次和删两次结果都是那条记录没了。真正需要设计的是写操作,POST 创建、PUT 更新、库存扣减、转账支付,这些操作重复执行会产生副作用。幂等设计要解决的就是这些写操作的重复问题。

最可靠的兜底是数据库唯一索引

PlantUML
正在加载图表…
PlantUML diagram

图:唯一索引防重——重复插入触发唯一键冲突,捕获后转查已存在记录返回,DB 层兜底

利用数据库唯一索引防重,是最朴素也最稳的做法。业务 ID 作为唯一键,重复插入直接报错,应用层捕获这个错误,转去查询已存在的记录返回。

try {
orderMapper.insert(order); // order_no 上建了唯一索引
return success();
} catch (DuplicateKeyException e) {
// 唯一键冲突,说明是重复请求,查出已有订单返回
Order existing = orderMapper.selectByOrderNo(order.getOrderNo());
return success(existing);
}

这套写法的妙处在于,不管上游怎么重试、并发怎么打、网络怎么抖,DB 唯一索引是最后一道铁闸,数据库层面就拒绝了重复。再复杂的业务逻辑兜不住时,唯一索引能兜住。设计幂等接口时,我第一件事就是想清楚这个接口用什么做唯一键,让 DB 先兜底。

唯一索引的局限在于它只能挡住重复插入。如果是更新操作,比如把库存从 100 扣到 90,再来一次还是从 100 扣到 90,唯一索引挡不住,因为这不是插入冲突。这类场景要靠下面的状态机或乐观锁。

状态机是最自然的幂等约束

PlantUML
正在加载图表…
PlantUML diagram

图:订单状态机幂等——已支付状态自动挡掉重复支付请求,CAS 更新防并发

很多业务的天然形态就是状态机。订单有状态,待支付、已支付、已发货、已取消。这些状态之间的流转有严格规则,已支付不能跳回待支付,已发货不能变成已取消。把这套规则落到代码里,重复请求会被状态约束自动挡掉。

重复的支付请求进来,先看订单状态。如果已经是已支付,直接返回成功,不重复扣款。如果还在待支付,走正常扣款流程。如果已经是已发货或已取消这种不该支付的状态,直接拒绝。状态机让幂等变成了业务规则的副产品,不需要额外维护去重表,非常自然。

public PayResult pay(Order order) {
// 状态机校验,已支付则幂等返回
if (order.getStatus() == OrderStatus.PAID) {
return PayResult.success("已支付");
}
if (order.getStatus() != OrderStatus.WAITING_PAY) {
throw new BizException("订单状态不允许支付");
}
// CAS 更新,WHERE status = WAITING_PAY 防并发
int affected = orderMapper.updateStatus(
order.getId(), OrderStatus.PAID, OrderStatus.WAITING_PAY);
if (affected == 0) {
// 并发被其他请求改了状态,重新查询处理
return pay(orderMapper.selectById(order.getId()));
}
// 执行扣款
}

这段代码里有个细节值得展开。状态判断和状态更新之间有时间差,并发请求可能都读到待支付状态。所以更新时要用 CAS,带上 WHERE status = WAITING_PAY 的条件,只有当前状态确实是待支付时才更新成功,返回受影响行数为 1。如果返回 0,说明在这期间状态已被别的请求改了,重新查询再走一遍逻辑。这就是数据库层面的乐观锁。

分布式锁防的是并发不是重试

PlantUML
正在加载图表…
PlantUML diagram

图:分布式锁防并发——同业务键串行化,持锁后双重检查状态,防止连点重复扣款

状态机和唯一索引防的是重试,同一个请求隔几秒再来一次。但还有一种更隐蔽的重复是并发重复,用户连点两次支付,两个请求几乎同时到达,都读到待支付状态,都去扣款。CAS 能挡住其中一个,但如果业务逻辑里有外部调用,比如先调第三方支付网关再更新订单,两个请求可能都调了网关。

分布式锁解决的就是这种并发场景。同一业务键的请求用分布式锁串行化,保证同一时刻只有一个在执行。

String lockKey = "pay_lock:" + orderNo;
RLock lock = redisson.getLock(lockKey);
if (!lock.tryLock(3, TimeUnit.SECONDS)) {
return "请求处理中,请稍后";
}
try {
// 双重检查,拿到锁后再查一次状态
if (order.getStatus() == PAID) return success();
// 执行支付
} finally {
lock.unlock();
}

注意锁里的双重检查。拿到锁之后不能直接执行,要先再查一次状态,因为前一个持锁的请求可能已经把订单支付掉了。锁防并发,状态机和唯一索引防重试,三者分工不同,组合起来才严密。

Token 机制适合前端配合场景

还有一类场景是表单提交,用户点支付或下单,前端没法天然防住连点。Token 机制是前端和服务端配合的方案。前端提交前先向服务端申请一个一次性 token,提交时带上这个 token,服务端校验有效性,用后即焚。

// 申请 token
String token = UUID.randomUUID().toString();
redis.setex("pay_token:" + userId, 600, token);
return token;
// 提交支付,Lua 原子操作保证 token 只能用一次
String key = "pay_token_used:" + token;
if (redis.setnx(key, "1", 600) == 0) {
return "请勿重复提交";
}
// 执行支付逻辑

Token 机制的弱点是依赖前端配合,且要管理 token 的时效和清理。它适合无法用业务唯一键的场景,比如没有天然订单号的即时操作。

不同操作的幂等策略

把这些手段落到具体操作上,策略是有规律的。创建类操作用业务唯一索引,比如订单号。支付扣款用状态机加流水号唯一约束。库存扣减用流水表记录已处理的请求 ID。MQ 消费用消息 ID 去重表。配置更新用版本号乐观锁。每一类操作对应一种最贴合的幂等手段,不是所有接口都要上分布式锁。

幂等不是免费的

最后要清醒的是,幂等有成本。去重表占存储,状态机增加业务复杂度,分布式锁降低并发,乐观锁要在每次更新带上版本条件。不是所有接口都需要强幂等,日志记录、统计上报这类重复无害的操作,不必过度设计。

还有一个容易混淆的点,幂等保证的是结果一致,但不保证没有副作用。第一次执行的副作用已经发生了,短信已经发了,库存已经扣了,幂等只是保证第二次执行不再叠加这个副作用。所以设计幂等时要想清楚,哪些副作用是可接受的,哪些必须连第一次都要避免。

幂等设计的通用路径

识别业务唯一键,用唯一索引兜底,状态机约束合法流转,分布式锁防并发,拿锁后再查一次做双重检查。这套组合覆盖绝大多数写接口。写每个写接口时先问自己一句,这个接口被重复调用会怎样,把答案设计成重复无害,就是幂等设计。

Continue Reading

关联文档推荐

查看全部
订单系统:状态机、逆向流程与一致性 — vibrant cover for technical blog article分布式锁:Redis、Zookeeper 与 Redlock 的工程取舍 — editorial cover photo短链系统 — editorial cover photo
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。