如何避免用户重复下单

如何避免用户重复下单(多次下单未支付,占用库存)

场景回顾

在稀有商品抢购或秒杀场景中,一个用户多次下单未支付可能是恶意锁库存或损坏其他用户的权益,因此需要避免用户重复下单。

1)首先前端需要进行按钮控制

在用户点击“下单”按钮后,可以通过前端控制按钮状态,比如变为“处理中”或“请稍等”,避免用户在等待过程中多次点击按钮,导致重复下单请求发送到后端。但是前端的操作无法完全避免重复请求,因此需要与后端的幂等控制结合,以确保不会因多次点击创建多个订单。

2)在每次下单请求中生成一个唯一的requestIdtoken

用户进入订单页面前,前端会先请求后端生成这次请求的唯一的requestIdtoken,然后在每次下单请求中带上这个唯一的requestIdorderToken。这样后端可以通过检测该标识是否已存在,来决定是否创建新订单。这样,无论用户重复点击几次下单按钮,仅会创建一次订单。

以上两步能阻挡绝大部分用户正常操作下的重复下单行为,如果一些用户通过 api 请求,或者后退页面再次点击下单进入,还是可以重复下单,不过一般这种设计已经可以杜绝稀有商品抢购或秒杀场景的恶意锁库存了,因为后退页面让下单流程再走一遍还是比较费时的。

3)分布式锁 + 判断 + 下单

  1. 以用户维度,加上分布式锁,例如分布式锁的 key 中的内容可以是 xxx+UserId,这样同一个用户的操作会被锁定。
  2. 判断用户是否有在流程中未支付的订单。
  3. 如果没有则正常进行下单流程
  4. 如果有则直接返回,提醒前端您还有未支付的订单,请先支付后再继续下单!

这样就能保证用户无法重复下单(恶意占用库存锁单)。

扩展

唯一索引实现幂等的约束

上述第二步在实现幂等性时说到,可以为每个订单请求设置一个requestIdorderToken

具体是在订单数据库中保存该唯一标识,建立唯一索引。当新订单请求到达时,首先检查数据库中是否已有相同标识的订单记录,如果有则直接返回订单信息,避免重复创建。

而且有唯一索引兜底,即使因为并发导致读取判断没数据,但实际有数据的情况,也会因为唯一索引从而避免重复插入。

但是唯一索引来实现幂等还是有很多局限性。

1)业务逻辑需要依赖异常(DuplicateKeyException)

上述控制订单重复插入部分代码如下:

1
2
3
4
5
6
7
8
try {
// 尝试创建订单
return orderRepository.save(order);
} catch (DuplicateKeyException e) {
// 捕获重复键异常,说明订单已存在,返回现有订单
log.info("订单已存在,返回现有订单信息");
return orderRepository.findByRequestId(requestId);
}

《Effective Java》 提出一条建议:不要用异常去控制程序的流程。

主要因为以下几点:

性能开销:异常在 Java 中是重量级的操作!

  • 异常的创建成本高:当抛出异常时,JVM会捕获当前堆栈信息,用来构建异常栈追踪,这是一项耗时的操作。
  • 异常捕获过程耗时:异常抛出后需要经过堆栈的传递来找到匹配的catch块,尤其在异常频繁抛出的场景中,性能开销会更加显著。因此,用异常做流程控制会显著影响程序性能,尤其是在高并发、性能敏感的应用中。

代码可读性差:异常的存在往往意味着出现了非预期的情况,如果用异常来控制正常流程,会让开发者误解代码意图,难以分辨哪些是正常流程,哪些是错误处理

依赖底层类DuplicateKeyException 是 Spring 中的类,后续如果进行框架迁移不或者升级使得不报这个异常了,那就尴尬了。

2)数据库压力大

等于把所有的请求,即使是重复的请求都需要靠数据库来做唯一判断,把压力都给到数据库身上,在高并发情况下会产生比较大的压力。

3)业务局限

大部分的唯一索引防重都需要插入操作,即 insert 动作,部分 update 也可以用上唯一索引但是场景比较少。不过这种情况一般在业务上会增加一个流水表来创造一个 insert 语句来实现前置防重