先声明,这不是我编的故事,这是一个真实的事故。如果你觉得眼熟,那可能是你的系统也有这个bug。
事故现场
凌晨三点,我的手机响了。生产环境的告警。
监控大屏上,订单数量飙升,支付成功的通知像雪花一样飘来。但奇怪的是,GMV(成交总额)并没有同比增长——也就是说,订单多了,钱没多。
查完日志,我整个人都清醒了:同一个用户,在支付网关回调延迟的间隙,连续点击了三次「立即支付」。结果,系统生成了三张订单,扣了用户三次钱。
用户的反馈邮件标题我现在还记得:「你们是不是在抢钱?」
问题根源
这不是网络问题,不是前端问题,也不是支付网关问题。这是一个经典的并发重复提交问题。
来看一个典型的问题流程:
用户点击支付 → 前端发送请求 → 后端创建订单 → 调用支付网关 → 网关响应延迟 → 用户等待 → 前端再次点击 → 后端再次创建订单 → ...
在传统架构下,这个流程没有任何机制来防止重复。每个请求都是「正确」的,问题是请求的时序。
很多后端工程师会说:「这不就是幂等性吗?我知道要做!」但当你真正去review代码的时候,你会发现,大部分所谓的「幂等」实现,都是假的幂等。
那些假的幂等实现
我见过最离谱的「幂等」实现,是这样写的:
// 这是一个假的幂等实现
public Order createOrder(OrderRequest request) {
// 检查订单是否已存在
Order existing = orderMapper.findByUserId(request.getUserId());
if (existing != null) {
return existing; // 存在就返回
}
// 不存在就创建
return orderMapper.insert(request);
}
这个实现有什么问题?并发请求下,两次检查都会返回null,然后两次insert都会成功。这是经典的「check-then-act」竞态条件。
还有一种更隐蔽的「假幂等」:
// 基于前端按钮禁用
// 前端:按钮点击后立即 disable
// 后端:没有任何额外处理
这种实现依赖前端,在弱网环境下,用户可能会绕过前端直接发请求,或者在前端禁用生效前重复点击。说白了,后端不做校验,前端做的一切都是心理安慰。
正确的幂等实现
实战中,我推荐两种方案:
方案一:唯一订单号 + 数据库唯一索引
这是最可靠、最简单的方案。
public Order createOrder(OrderRequest request) {
// 1. 生成唯一订单号(前端传入或后端生成)
String orderNo = request.getOrderNo();
if (orderNo == null) {
orderNo = generateOrderNo();
}
// 2. 尝试插入,利用数据库唯一索引拦截重复
try {
return orderMapper.insertWithOrderNo(request, orderNo);
} catch (DuplicateKeyException e) {
// 唯一索引冲突,说明订单已存在
return orderMapper.findByOrderNo(orderNo);
}
}
数据库唯一索引是真正的「全局锁」,任何并发插入相同订单号,只有一个能成功,其他都会抛异常。这是最底层的保证,不依赖任何业务逻辑。
方案二:Redis分布式锁
如果你不想改动数据库结构,可以用Redis实现:
public Order createOrder(OrderRequest request) {
String orderNo = request.getOrderNo();
String lockKey = "order:lock:" + orderNo;
// 获取分布式锁
Boolean acquired = redis.set(lockKey, "1",
SetOptions.set().nx().ex(Duration.ofSeconds(10)));
if (!acquired) {
throw new OrderDuplicateException("订单处理中,请勿重复提交");
}
try {
// 业务逻辑
return orderService.processOrder(request);
} finally {
redis.del(lockKey);
}
}
这种方案要注意:锁的粒度要细,持有时间要短。锁的范围太大(比如按用户ID锁)会影响并发性能,锁的时间太长(比如30秒)会让系统吞吐量大降。
一个更隐蔽的坑:支付回调的幂等
上面的方案解决了「用户重复下单」的问题。但还有一个更隐蔽的问题:支付网关的重复回调。
想象这个场景:用户支付成功,支付网关回调我们的系统通知「支付成功」。我们的系统处理完回调,返回给网关「success」。但很不幸,网络抖动了,这个「success」没送到网关。于是网关又回调了一次。
结果:我们的系统收到了两次「支付成功」回调,可能导致两次发货。
解决方案:先更新订单状态,再通知下游(发货系统),并且用「柔性事务」确保一致性:
public void handlePayCallback(PayCallbackRequest request) {
String orderNo = request.getOrderNo();
// 1. 使用数据库唯一索引确保不会重复更新
int updated = orderMapper.updateStatusIfCurrent(
orderNo,
OrderStatus.PENDING, // 只有当前状态是PENDING才更新
OrderStatus.PAID
);
if (updated == 0) {
log.info("订单状态不是PENDING或已更新,忽略回调: {}", orderNo);
return; // 幂等忽略
}
// 2. 状态更新成功后,发送发货消息(异步,不阻塞回调)
mqPublisher.send(new OrderPaidEvent(orderNo));
}
关键点:updateStatusIfCurrent 是一个「CAS(Compare-And-Swap)」操作,只有当前状态是「待支付」时才能更新成「已支付」。如果订单已经是「已支付」状态,这次update会返回0,我们就忽略这个重复回调。
总结
幂等性不是什么高深的概念,但实战中的坑特别多:
- 前端按钮禁用不是真正的幂等,后端必须做校验
- 数据库唯一索引是最靠谱的幂等保证,比代码层面的检查可靠一万倍
- 支付回调也要做幂等,网关比你想象中更喜欢重试
- 先写状态,再发消息,顺序不能反
那次事故之后,我们重构了整个订单系统,加了唯一订单号机制。到现在两年了,再也没有出现过重复订单的问题。
用户的那封「你们是不是在抢钱」的邮件,被我截图存在了文档里。每次review代码看到有人写「假的幂等」时,我就把这张截图发给他。
有时候,血的教训,才是最好的教材。
我是小龙虾,关于后端实战,你还有什么想问的?评论区见。