回答重点
这个其实就是幂等性的问题
在稀有商品抢购或秒杀场景中,一个用户多次下单未支付可能是恶意锁库存或损坏其他用户的权益,因此需要避免用户重复下单。
首先前端需要进行按钮控制:在用户点击“下单”按按钮后,可以通过前说控制技钮状态,比如变为“如理中”或“请稍等等”,造免用户在等待过程中多次点击波讯,导致重复下单清求发送到后说,但是的品的操作无法完全避免重复清求,因此需要与后端的憙等控制结合以确保不会因多次点击创建多个订单。
在每次下单请求中生成一个唯一的 requestId 或 token:
用户进入订单页面前,前端会先清求后端生成这次请求的唯一的requestId 或 token,然后在每次下单去请求中带上这个唯一的 requestId 或 token。这样后端可以通过检测该标识是否已存在,来决定是否创建新订单,这样,无论用户重复点击几次下单按钮,仅会创建一次订单。
以上两步能阻挡绝大部分用户正常操作下的重复下单行为,如果一些用户通过api请求,或者后退页面再次点击下单进入,还是可以重复下单,不过一般这种设计已经可以杜绝稀有商品抢购或秒杀场景的恶意锁库存了,因为后退页面让下单流程再走一遍还是比较费时的。
- 分布式锁 + 判断 +下单
- 以用户维度,加上分布式锁,例如分布式锁的 key 中的内容可以是 xxx+userd,这样同一个用户的操作会被锁定。
- 判断用户是否有在流程中未支付的订单。
- 如果没有则正常进行下单流程
- 如果有则直接返回,提醒前端您还有未支付的订单,请先支付后再继续下单!
这样就能保证用户无法重复下单(恶意占用库存锁单)
唯一索引实现幂等的约束
上述第二步在实现幂等性时说到,可以为每个订单请求设置一个requestId 或 orderToken 。具体是在订单数据库中保存该唯一标识,建立唯一索引。当新订单清求到达时,首先检查数据库中是否已有相同标识的订单记录,如果有则直接返回订单信息,避免重复创建而且有唯一索引兜底,即使因为并发导致读取判断没数据,但实际有数据的情况,也会因为唯一索引从而避免重复插入。但是唯一索引来实现幂等还是有很多局限性。
- 业务逻辑需要依赖异常(DuplicateKeyException)
try {
// 尝试创建订单
return orderRepository.save(order);
} catch (DuplicateKeyException e) {
// 捕获重复键异常,说明订单已存在,返回现有订单
log.info("订单已存在,返回现有订单信息");
return orderRepository.findByRequestId(requestId);
}
《Effective Java》 提出一条建议:不要用异常去控制程序的流程。
主要因为以下几点:
- 性能开销:异常在 Java 中是重量级的操作!
异常的创建成本高:当抛出异常时,JVM会捕获当前堆栈信息,用来构建异常栈追踪,这是一项耗时的操作。 - 异常捕获过程耗时:异常抛出后需要经过堆栈的传递来找到匹配的catch块,尤其在异常频繁抛出的场景中,性能开销会更加显著。因此,用异常做流程控制会显著影响程序性能,尤其是在高并发、性感的应用中。
- 代码可读性差:异常的存在往往意味着出现了非预期的情况,如果用异常来控制正常流程,会让开发者误解代码意图,难以分辨哪些是正常流程,哪些是错误处理。
- 依赖底层类: DuplicateKeyException 是 Sping 中的类,后续如果进行框架迁移不或者升级使得不报这个异常了,那就尴尬了。(面试时候说可以这样说,但是我觉得问题不大。。)
- 数据库压力大
等于把所有的请求,即使是重复的请求都需要靠数据库来做唯一判断,把压力都给到数据库身上,在高并发情况下会产生比较大的压力。 - 业务局限
大部分的准一索引防重都需要插入操作,即 insert动作,部分update也可以用上唯一案引但是场景比较少。不过这种情况一般我们在业务上会增加一个流水表来创造一个 insert 语句来实现前置防重(话是这么说,但流水表不是主要为了创造一个 insert,本身流水还是追溯和审计等意义的)。