黑马点评优惠券秒杀三:最朴素下单为什么会超卖?
本文继续整理黑马点评 Redis 实战篇第 3 章「优惠券秒杀」。
前两篇分别讲了订单 ID 和秒杀券表结构。这一篇开始进入真正的秒杀下单:用户点击抢购后,后端到底做了什么?为什么最朴素的代码看起来没问题,一并发就会超卖?
1. 本文解决什么问题
这篇文章主要讲清楚:
text
1. 秒杀下单的最朴素流程是什么。
2. 为什么要判断开始时间、结束时间和库存。
3. 超卖是怎么发生的。
4. 为什么 stock > 0 的条件更新能挡住超卖。
5. 防超卖和防重复下单为什么不是一回事。
先给结论:
超卖的根源是"判断库存"和"扣减库存"不是原子操作。解决思路是把库存判断条件放进数据库更新语句中,例如
stock = stock - 1 where voucher_id = ? and stock > 0,让数据库在扣库存时再次确认库存仍然大于 0。
2. 秒杀请求从哪里进来
用户点击抢购按钮后,请求进入 VoucherOrderController:
java
@PostMapping("/seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderService.seckillVoucher(voucherId);
}
这里的路径类似:
text
POST /voucher-order/seckill/1
其中:
text
1 = voucherId
也就是用户要抢购的优惠券 ID。
3. 最朴素秒杀流程
讲义里的初版秒杀逻辑大概是:
java
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
// 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.update();
if (!success) {
return Result.fail("库存不足!");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
这个流程非常符合直觉:
text
查秒杀券
判断时间
判断库存
扣库存
创建订单
返回订单 ID
单线程下看起来没有问题。
4. 时间判断到底在判断什么
4.1 是否尚未开始
java
voucher.getBeginTime().isAfter(LocalDateTime.now())
意思是:
text
秒杀开始时间是否在当前时间之后
如果返回 true,说明:
text
现在还没到开始时间
例如:
text
beginTime = 20:00
now = 19:30
beginTime.isAfter(now) 为 true,所以秒杀尚未开始。
4.2 是否已经结束
java
voucher.getEndTime().isBefore(LocalDateTime.now())
意思是:
text
秒杀结束时间是否已经早于当前时间
如果返回 true,说明:
text
活动已经结束
例如:
text
endTime = 18:00
now = 19:30
endTime.isBefore(now) 为 true,所以秒杀已经结束。
5. 库存判断为什么看起来没问题
朴素代码里有:
java
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
它的意思很简单:
text
如果库存小于 1,就不能卖。
在单线程下,这个判断完全正确。
比如:
text
stock = 0 -> 返回库存不足
stock = 10 -> 继续下单
问题不是这个 if 写错了。
问题在于:
text
判断库存和扣库存之间有时间差。
这个时间差在并发场景下非常危险。
6. 超卖是怎么发生的
假设现在只剩最后 1 张券:
text
stock = 1
两个用户几乎同时发起请求。
数据库 请求2 请求1 数据库 请求2 请求1 #mermaid-svg-kYTLdhOCszi7IBwx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kYTLdhOCszi7IBwx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kYTLdhOCszi7IBwx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kYTLdhOCszi7IBwx .error-icon{fill:#552222;}#mermaid-svg-kYTLdhOCszi7IBwx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kYTLdhOCszi7IBwx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kYTLdhOCszi7IBwx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kYTLdhOCszi7IBwx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kYTLdhOCszi7IBwx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kYTLdhOCszi7IBwx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kYTLdhOCszi7IBwx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kYTLdhOCszi7IBwx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kYTLdhOCszi7IBwx .marker.cross{stroke:#333333;}#mermaid-svg-kYTLdhOCszi7IBwx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kYTLdhOCszi7IBwx p{margin:0;}#mermaid-svg-kYTLdhOCszi7IBwx .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kYTLdhOCszi7IBwx text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-kYTLdhOCszi7IBwx .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-kYTLdhOCszi7IBwx .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-kYTLdhOCszi7IBwx .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-kYTLdhOCszi7IBwx .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-kYTLdhOCszi7IBwx #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-kYTLdhOCszi7IBwx .sequenceNumber{fill:white;}#mermaid-svg-kYTLdhOCszi7IBwx #sequencenumber{fill:#333;}#mermaid-svg-kYTLdhOCszi7IBwx #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-kYTLdhOCszi7IBwx .messageText{fill:#333;stroke:none;}#mermaid-svg-kYTLdhOCszi7IBwx .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kYTLdhOCszi7IBwx .labelText,#mermaid-svg-kYTLdhOCszi7IBwx .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-kYTLdhOCszi7IBwx .loopText,#mermaid-svg-kYTLdhOCszi7IBwx .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-kYTLdhOCszi7IBwx .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-kYTLdhOCszi7IBwx .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-kYTLdhOCszi7IBwx .noteText,#mermaid-svg-kYTLdhOCszi7IBwx .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-kYTLdhOCszi7IBwx .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kYTLdhOCszi7IBwx .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kYTLdhOCszi7IBwx .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kYTLdhOCszi7IBwx .actorPopupMenu{position:absolute;}#mermaid-svg-kYTLdhOCszi7IBwx .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-kYTLdhOCszi7IBwx .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kYTLdhOCszi7IBwx .actor-man circle,#mermaid-svg-kYTLdhOCszi7IBwx line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-kYTLdhOCszi7IBwx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 查询库存,读到 stock = 1查询库存,读到 stock = 1扣库存 stock = stock - 1扣库存 stock = stock - 1创建订单创建订单
关键在前两步:
text
两个请求都在扣库存前读到了 stock = 1。
所以两个请求都认为:
text
库存足够,我可以下单。
最后库存可能被扣成负数,或者虽然库存没有负数,但订单数量超过了库存数量。
这就是超卖。
7. 超卖的本质
超卖的本质不是:
text
程序员不会写 if
而是:
text
读取库存和扣减库存不是一个原子操作。
朴素流程里:
text
先查库存
再判断库存
再扣库存
这几步中间,其他线程可以插进来。
所以多个线程可能基于同一个旧库存做决定。
8. 悲观锁和乐观锁的思路
解决超卖通常有两类思路。
8.1 悲观锁
悲观锁的想法是:
text
我认为一定会有并发冲突,所以先加锁,让大家排队。
比如 Java 里的:
text
synchronized
Lock
优点是直观。
缺点是性能可能下降,因为并发被串行化。
8.2 乐观锁
乐观锁的想法是:
text
我先不加锁,更新时检查数据有没有被别人改过。
如果没被改过,就更新成功。
如果已经被改过,就更新失败。
库存扣减更适合用这种思路:
text
更新库存时带上条件。
条件满足才扣库存。
条件不满足说明库存已经变化,更新失败。
9. 第一种乐观锁:库存必须和之前查到的一样
讲义里先给过一种写法:
java
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.eq("stock", voucher.getStock())
.update();
翻译成 SQL 思路:
sql
update tb_seckill_voucher
set stock = stock - 1
where voucher_id = ?
and stock = 查询时读到的库存
它的意思是:
text
只有当数据库里的库存仍然等于我刚才查到的库存时,才扣减。
这能防超卖。
但成功率太低。
比如 100 个线程同时查到:
text
stock = 100
第一个线程扣成功后,库存变成 99。
其他 99 个线程再更新时,条件 stock = 100 都不成立。
明明还有 99 张库存,却大量失败。
所以这个方案太严格。
10. 第二种乐观锁:只要求库存大于 0
更适合秒杀的是:
java
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
这里的 .gt("stock", 0) 是 MyBatis-Plus 的条件构造方法。
gt 表示:
text
greater than,大于
所以它等价于 SQL 条件:
sql
stock > 0
整体翻译成 SQL 思路就是:
sql
update tb_seckill_voucher
set stock = stock - 1
where voucher_id = ?
and stock > 0
这句 SQL 的含义是:
只有数据库当前库存仍然大于 0,才允许扣减库存。
11. 为什么 stock > 0 能挡住超卖
还是最后 1 张券:
text
stock = 1
请求 1 执行:
sql
update ...
set stock = stock - 1
where voucher_id = 1 and stock > 0
此时库存是 1,满足 stock > 0,更新成功。
库存变成:
text
stock = 0
请求 2 再执行同样的 SQL。
这时库存已经是 0。
条件:
sql
stock > 0
不成立。
所以请求 2 更新失败。
结果就是:
text
最后一张券只能被一个请求扣掉。
12. 防超卖流程图
#mermaid-svg-EIJS4cs0rysZA6SR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EIJS4cs0rysZA6SR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EIJS4cs0rysZA6SR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EIJS4cs0rysZA6SR .error-icon{fill:#552222;}#mermaid-svg-EIJS4cs0rysZA6SR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EIJS4cs0rysZA6SR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EIJS4cs0rysZA6SR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EIJS4cs0rysZA6SR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EIJS4cs0rysZA6SR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EIJS4cs0rysZA6SR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EIJS4cs0rysZA6SR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EIJS4cs0rysZA6SR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EIJS4cs0rysZA6SR .marker.cross{stroke:#333333;}#mermaid-svg-EIJS4cs0rysZA6SR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EIJS4cs0rysZA6SR p{margin:0;}#mermaid-svg-EIJS4cs0rysZA6SR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-EIJS4cs0rysZA6SR .cluster-label text{fill:#333;}#mermaid-svg-EIJS4cs0rysZA6SR .cluster-label span{color:#333;}#mermaid-svg-EIJS4cs0rysZA6SR .cluster-label span p{background-color:transparent;}#mermaid-svg-EIJS4cs0rysZA6SR .label text,#mermaid-svg-EIJS4cs0rysZA6SR span{fill:#333;color:#333;}#mermaid-svg-EIJS4cs0rysZA6SR .node rect,#mermaid-svg-EIJS4cs0rysZA6SR .node circle,#mermaid-svg-EIJS4cs0rysZA6SR .node ellipse,#mermaid-svg-EIJS4cs0rysZA6SR .node polygon,#mermaid-svg-EIJS4cs0rysZA6SR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EIJS4cs0rysZA6SR .rough-node .label text,#mermaid-svg-EIJS4cs0rysZA6SR .node .label text,#mermaid-svg-EIJS4cs0rysZA6SR .image-shape .label,#mermaid-svg-EIJS4cs0rysZA6SR .icon-shape .label{text-anchor:middle;}#mermaid-svg-EIJS4cs0rysZA6SR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EIJS4cs0rysZA6SR .rough-node .label,#mermaid-svg-EIJS4cs0rysZA6SR .node .label,#mermaid-svg-EIJS4cs0rysZA6SR .image-shape .label,#mermaid-svg-EIJS4cs0rysZA6SR .icon-shape .label{text-align:center;}#mermaid-svg-EIJS4cs0rysZA6SR .node.clickable{cursor:pointer;}#mermaid-svg-EIJS4cs0rysZA6SR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EIJS4cs0rysZA6SR .arrowheadPath{fill:#333333;}#mermaid-svg-EIJS4cs0rysZA6SR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EIJS4cs0rysZA6SR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EIJS4cs0rysZA6SR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EIJS4cs0rysZA6SR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EIJS4cs0rysZA6SR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EIJS4cs0rysZA6SR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EIJS4cs0rysZA6SR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EIJS4cs0rysZA6SR .cluster text{fill:#333;}#mermaid-svg-EIJS4cs0rysZA6SR .cluster span{color:#333;}#mermaid-svg-EIJS4cs0rysZA6SR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-EIJS4cs0rysZA6SR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EIJS4cs0rysZA6SR rect.text{fill:none;stroke-width:0;}#mermaid-svg-EIJS4cs0rysZA6SR .icon-shape,#mermaid-svg-EIJS4cs0rysZA6SR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EIJS4cs0rysZA6SR .icon-shape p,#mermaid-svg-EIJS4cs0rysZA6SR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EIJS4cs0rysZA6SR .icon-shape .label rect,#mermaid-svg-EIJS4cs0rysZA6SR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EIJS4cs0rysZA6SR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EIJS4cs0rysZA6SR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EIJS4cs0rysZA6SR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
请求进入秒杀接口
查询秒杀券
时间是否合法?
返回失败
执行条件更新
update stock = stock - 1 where voucher_id = ? and stock > 0
更新是否成功?
返回库存不足
创建订单
返回订单 ID
13. 这段代码防住了什么,没防住什么
它防住的是:
text
库存超卖
也就是库存为 0 后不能继续扣减。
但它还没有防住:
text
同一个用户重复下单
因为一个用户可以发两次请求:
text
请求 1 扣库存成功,创建订单
请求 2 也可能扣库存成功,创建订单
只要库存还够,条件更新并不会关心是不是同一个用户。
所以:
防超卖和防一人一单是两个问题。
14. 易错点
1. 超卖不是库存判断写错了
stock < 1 在单线程下没问题。
并发下的问题是多个线程同时基于旧库存做判断。
2. stock > 0 是在数据库更新时判断
不是 Java 先判断完再更新。
关键是这条条件进入了 SQL:
sql
where stock > 0
3. 乐观锁不一定非要有 version 字段
经典乐观锁常见写法是 version。
但这里的核心思想是:
text
更新时带条件,条件不满足就说明发生了并发变化。
4. 防超卖不等于防重复下单
库存安全只是第一步。
一人一单还要额外处理。
15. 面试怎么回答
如果面试官问:秒杀为什么会出现超卖?
可以回答:
因为多个请求可能同时查询到相同的库存值,然后都认为库存充足并继续扣减。问题的根源是"判断库存"和"扣减库存"不是原子操作,中间存在并发窗口。
如果面试官问:怎么解决库存超卖?
可以回答:
可以使用乐观锁思想,把库存判断放进数据库更新语句中,例如
update tb_seckill_voucher set stock = stock - 1 where voucher_id = ? and stock > 0。这样只有库存仍然大于 0 时才扣减成功,最后一张券被扣掉后,后续请求由于条件不满足会更新失败。
如果面试官问:为什么不用 stock = 查询时库存?
可以回答:
这种方式虽然能防止并发修改,但在秒杀场景下成功率太低。大量线程可能同时读到同一个库存值,只有第一个线程能成功,其余线程即使库存仍然充足也会失败。改成
stock > 0更符合秒杀扣库存场景。
16. 总结
最朴素秒杀流程是:
text
查券 -> 判断时间 -> 判断库存 -> 扣库存 -> 建订单
它的问题在于:
text
判断库存和扣库存分离
高并发下,多个线程可能同时读到旧库存,导致超卖。
解决超卖的关键不是多写一个 if,而是把判断条件放进扣库存 SQL:
sql
stock = stock - 1
where voucher_id = ?
and stock > 0
到这里,我们解决了库存安全问题。
但下一篇还要继续解决另一个问题:
text
同一个用户为什么还能重复下单?