点评--day04--一人一单

4 阅读6分钟

可以,这一节要学的是 “一人一单”

你可以把它理解成一句话:

同一个优惠券,同一个用户只能下一次单。


一人一单

1. 这一节解决的是什么问题

前面你已经用乐观锁解决了超卖,也就是“库存不能被扣成负数”。
但这还不够,因为现在还可能出现:

  • 用户 A 抢到一次
  • 用户 A 又继续抢第二次
  • 同一个人拿到多张同样的秒杀券

所以课程才继续提出新需求:同一个优惠券,一个用户只能下一单

你要先分清这两个问题:

  • 超卖:总库存不能卖多
  • 一人一单:同一个用户不能重复下单

这两个不是一回事。


2. 一人一单的基本思路

资料里的思路很明确:

  1. 先判断秒杀时间
  2. 再判断库存
  3. 然后根据 voucherId + userId 查询订单
  4. 如果订单已经存在,直接返回失败
  5. 如果不存在,才允许继续扣减库存并创建订单。

核心代码思路就是:

Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
    return Result.fail("用户已经购买过一次!");
}

这段逻辑的意思非常简单:

先查这个用户是否已经买过这张券,买过就不让他再买。


3. 为什么这个方案还不安全

表面上看,这段代码已经能限制重复购买了。
但在并发场景下,它还是会出问题。

比如两个请求同时进来,都是同一个用户:

线程 1

  1. 查询订单
  2. 发现没有订单
  3. 准备创建订单

线程 2

  1. 查询订单
  2. 也发现没有订单
  3. 也准备创建订单

最后两个线程都插入成功,就变成了同一个用户下了两单
课件里专门把这个问题画成了“一人一单的并发安全问题”。

所以你一定要记住:

一人一单的问题,本质上不是“查没查订单”,而是“查订单和插入订单之间存在并发竞争”。


4. 为什么这里不能只靠乐观锁

因为乐观锁更适合处理更新问题,比如库存扣减:

set stock = stock - 1 where voucher_id = ? and stock > 0

这是更新同一条库存记录。
但“一人一单”这里主要矛盾是:

  • 先查订单是否存在
  • 然后插入新订单

这是插入场景,不是简单更新场景。
资料里明确说了:乐观锁更适合更新数据,而这里是插入数据,所以需要用悲观锁


5. 课程里的第一版解决方案:加 synchronized

资料里的第一版做法,是把创建订单的方法整体加锁:

@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
    ...
}

这样能保证同一时刻只有一个线程进入这个方法。

但这个方案有一个明显问题:

锁粒度太粗。

因为它把整个方法都锁住了,所有用户进来都要排队,性能会比较差。
资料里也明确强调了:控制锁粒度非常重要


6. 更合理的做法:按用户加锁

课程后面把锁粒度缩小成“按用户加锁”,代码思路是:

synchronized(userId.toString().intern()) {
    ...
}

意思是:

不是所有用户共用一把锁,而是每个用户自己对应一把锁。

这样有什么好处?

  • 用户 A 下单时,只锁住用户 A
  • 用户 B 下单时,不受影响
  • 不同用户之间仍然可以并发执行
  • 只有“同一个用户”的重复请求才会互斥

这就把锁粒度从“整个方法”缩小成了“单个用户”。


7. 为什么要用 intern()

资料里专门解释了这一点:

如果你直接写:

synchronized(userId.toString())

看起来像是同一个用户会拿到同一把锁,但实际上不一定。
因为 toString() 产生的对象可能不是同一个对象。

所以课程才要求你写成:

synchronized(userId.toString().intern())

intern() 的作用,就是从常量池中拿对象,保证同样内容的字符串对应的是同一把锁

这一点是面试里很容易被问到的细节。


8. 一人一单 + 库存扣减的完整思路

你现在应该把这段业务流程串起来理解:

  1. 查询秒杀券
  2. 判断是否开始
  3. 判断是否结束
  4. 判断库存是否充足
  5. 获取当前用户 id
  6. 对当前用户加锁
  7. 查询 user_id + voucher_id 是否已有订单
  8. 如果有,返回“已经购买过”
  9. 如果没有,执行库存扣减 stock = stock - 1 and stock > 0
  10. 创建订单并保存。

你会发现:

  • 一人一单 解决重复下单
  • 乐观锁扣库存 解决超卖

这两个要一起配合。


9. 这一版方案还有什么问题

这节还有一个很重要的结论:

单机情况下,按用户加锁基本能解决问题;但在集群环境下,这种 JVM 锁会失效。

因为如果服务部署了两台:

  • 请求 1 落到 JVM1
  • 请求 2 落到 JVM2

虽然锁代码一样,但它们不是同一个 JVM,不会共用同一把锁。
所以资料才会马上进入下一节:集群环境下的并发问题,然后继续讲分布式锁

这说明:

  • synchronized 只能解决单机并发
  • 不能解决分布式部署下的一人一单

10. 你这一节最该掌握的 4 个点

第一,需求是什么

同一个优惠券,同一个用户只能买一次。

第二,初步实现方式是什么

根据 user_id + voucher_id 查询订单是否存在。

第三,并发下为什么还会出问题

因为两个线程可能同时查到“订单不存在”,然后都去创建订单。

第四,单机如何解决

对同一个用户加悲观锁,例如 synchronized(userId.toString().intern())


11. 面试表达

你可以直接这样说:

一人一单的需求,是要求同一个用户对同一张秒杀券只能下一次单。最基础的实现方式是在下单前根据 userId + voucherId 查询订单是否已经存在,如果存在就直接返回失败。但这个方案在高并发下仍然有问题,因为两个线程可能同时查询到订单不存在,然后都去创建订单,导致重复下单。由于这里主要是“查订单 + 插入订单”的并发问题,不适合单纯用乐观锁,所以项目里在单机环境下采用了悲观锁,按用户维度加锁,例如 synchronized(userId.toString().intern()),这样同一个用户的请求会串行执行,不同用户之间仍然可以并发。这个方案能解决单机下的一人一单问题,但在集群环境下本地锁会失效,所以后续还需要用分布式锁。


12. 你现在怎么学最合适

你现在可以自己做这 3 件事:

  1. 先把 seckillVoucher() 里“一人一单”的判断代码顺一遍
  2. 自己画出两个线程同时下单导致重复插入的过程
  3. 再理解为什么要按 userId 加锁,而不是锁整个方法