可以,这一节要学的是 “一人一单” 。
你可以把它理解成一句话:
同一个优惠券,同一个用户只能下一次单。
一人一单
1. 这一节解决的是什么问题
前面你已经用乐观锁解决了超卖,也就是“库存不能被扣成负数”。
但这还不够,因为现在还可能出现:
- 用户 A 抢到一次
- 用户 A 又继续抢第二次
- 同一个人拿到多张同样的秒杀券
所以课程才继续提出新需求:同一个优惠券,一个用户只能下一单。
你要先分清这两个问题:
- 超卖:总库存不能卖多
- 一人一单:同一个用户不能重复下单
这两个不是一回事。
2. 一人一单的基本思路
资料里的思路很明确:
- 先判断秒杀时间
- 再判断库存
- 然后根据 voucherId + userId 查询订单
- 如果订单已经存在,直接返回失败
- 如果不存在,才允许继续扣减库存并创建订单。
核心代码思路就是:
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
- 查询订单
- 发现没有订单
- 准备创建订单
线程 2
- 查询订单
- 也发现没有订单
- 也准备创建订单
最后两个线程都插入成功,就变成了同一个用户下了两单。
课件里专门把这个问题画成了“一人一单的并发安全问题”。
所以你一定要记住:
一人一单的问题,本质上不是“查没查订单”,而是“查订单和插入订单之间存在并发竞争”。
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. 一人一单 + 库存扣减的完整思路
你现在应该把这段业务流程串起来理解:
- 查询秒杀券
- 判断是否开始
- 判断是否结束
- 判断库存是否充足
- 获取当前用户 id
- 对当前用户加锁
- 查询
user_id + voucher_id是否已有订单 - 如果有,返回“已经购买过”
- 如果没有,执行库存扣减
stock = stock - 1 and stock > 0 - 创建订单并保存。
你会发现:
- 一人一单 解决重复下单
- 乐观锁扣库存 解决超卖
这两个要一起配合。
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 件事:
- 先把
seckillVoucher()里“一人一单”的判断代码顺一遍 - 自己画出两个线程同时下单导致重复插入的过程
- 再理解为什么要按
userId加锁,而不是锁整个方法