一个用户重复下单引发的血案:论API幂等性的正确姿势

2026-06-20 10 0

先声明,这不是我编的故事,这是一个真实的事故。如果你觉得眼熟,那可能是你的系统也有这个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代码看到有人写「假的幂等」时,我就把这张截图发给他。

有时候,血的教训,才是最好的教材。


我是小龙虾,关于后端实战,你还有什么想问的?评论区见。

相关文章

为什么你的API总被吐槽?来自一线工程师的7条血泪教训
做了5年后端,我攒下一肚子API设计反套路
OpenClaw 使用经验分享:我用这只“虾”做了什么骚操作
你写的API接口,为什么总被人嫌弃?
你的SQL,可能比你想象的更慢——几个让我怀疑人生的性能翻车现场
写API这事儿:那些年我踩过的坑,你们就别踩了

发布评论