黑马点评优惠券秒杀四:一人一单为什么只靠 count() 还是会失效?
本文继续整理黑马点评 Redis 实战篇第 3 章「优惠券秒杀」。
上一篇解决了库存超卖:通过
stock > 0的条件更新,避免库存被扣成负数。但秒杀里还有另一个很重要的规则:同一个用户对同一张券只能下一单。问题是,即使我们写了count()查询订单记录,并发下它仍然可能失效。
1. 本文解决什么问题
这篇文章围绕"一人一单"展开:
text
1. 一人一单到底限制的是什么。
2. 为什么 count() 查询订单记录,在并发下仍然不可靠。
3. 为什么这个问题和库存超卖相似,但不是同一个问题。
4. 为什么要加锁,锁粒度为什么要按 userId 控制。
5. synchronized(userId.toString().intern()) 到底在锁什么。
6. 为什么还要考虑事务和代理对象。
先给结论:
一人一单失效的根源是"查询是否已下单"和"创建订单"不是原子操作。同一个用户的两个并发请求可能都查到 count = 0,然后都继续创建订单。所以需要按用户维度加锁,让同一个用户的下单逻辑串行执行。
2. 一人一单到底限制什么
一人一单不是说:
text
一个用户只能下一笔订单
而是:
text
同一个用户,对同一张秒杀券,只能成功下一单。
限制的是这个组合:
text
userId + voucherId
比如:
text
用户 10 可以买券 1
用户 10 也可以买券 2
但用户 10 不能买两次券 1
3. 初版一人一单代码
讲义中的初版思路是:
java
Long userId = UserHolder.getUser().getId();
int count = query()
.eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
if (count > 0) {
return Result.fail("用户已经购买过一次该优惠券!");
}
这段代码的意思是:
text
去订单表查当前用户是否已经买过当前券。
对应 SQL 思路:
sql
select count(*)
from tb_voucher_order
where user_id = ?
and voucher_id = ?
单线程下,这个逻辑完全正确。
4. 为什么 count() 在并发下还是会失效
假设:
text
用户 10 抢 voucherId = 1
他连点两次,发出两个并发请求
数据库中此时还没有该用户的订单记录
并发时序可能是这样:
数据库 请求2 请求1 数据库 请求2 请求1 #mermaid-svg-ir13U3HvFp9TLasA{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-ir13U3HvFp9TLasA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ir13U3HvFp9TLasA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ir13U3HvFp9TLasA .error-icon{fill:#552222;}#mermaid-svg-ir13U3HvFp9TLasA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ir13U3HvFp9TLasA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ir13U3HvFp9TLasA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ir13U3HvFp9TLasA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ir13U3HvFp9TLasA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ir13U3HvFp9TLasA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ir13U3HvFp9TLasA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ir13U3HvFp9TLasA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ir13U3HvFp9TLasA .marker.cross{stroke:#333333;}#mermaid-svg-ir13U3HvFp9TLasA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ir13U3HvFp9TLasA p{margin:0;}#mermaid-svg-ir13U3HvFp9TLasA .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ir13U3HvFp9TLasA text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ir13U3HvFp9TLasA .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ir13U3HvFp9TLasA .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ir13U3HvFp9TLasA .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ir13U3HvFp9TLasA .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ir13U3HvFp9TLasA #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ir13U3HvFp9TLasA .sequenceNumber{fill:white;}#mermaid-svg-ir13U3HvFp9TLasA #sequencenumber{fill:#333;}#mermaid-svg-ir13U3HvFp9TLasA #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ir13U3HvFp9TLasA .messageText{fill:#333;stroke:none;}#mermaid-svg-ir13U3HvFp9TLasA .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ir13U3HvFp9TLasA .labelText,#mermaid-svg-ir13U3HvFp9TLasA .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ir13U3HvFp9TLasA .loopText,#mermaid-svg-ir13U3HvFp9TLasA .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ir13U3HvFp9TLasA .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-ir13U3HvFp9TLasA .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ir13U3HvFp9TLasA .noteText,#mermaid-svg-ir13U3HvFp9TLasA .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ir13U3HvFp9TLasA .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ir13U3HvFp9TLasA .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ir13U3HvFp9TLasA .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ir13U3HvFp9TLasA .actorPopupMenu{position:absolute;}#mermaid-svg-ir13U3HvFp9TLasA .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-ir13U3HvFp9TLasA .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ir13U3HvFp9TLasA .actor-man circle,#mermaid-svg-ir13U3HvFp9TLasA line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ir13U3HvFp9TLasA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 查询订单 count(userId=10,voucherId=1)返回 0查询订单 count(userId=10,voucherId=1)返回 0扣库存成功扣库存成功保存订单保存订单
两个请求都查到 count = 0。
为什么?
因为它们查询时,彼此的订单都还没有真正保存成功。
所以两个请求都会判断:
text
我还没买过,可以继续下单。
最后就可能插入两条订单。
5. 它和超卖问题有什么相似之处
超卖的问题是:
text
判断库存和扣库存不是原子操作。
一人一单的问题是:
text
判断是否已下单和创建订单不是原子操作。
你会发现它们结构很像:
text
先查一个状态
根据状态做决定
再修改数据
并发下,多个线程可能同时看到同一个旧状态。
区别是:
text
超卖查的是库存。
一人一单查的是订单记录。
所以防住超卖,不代表防住重复下单。
6. 为什么插入订单更适合用悲观锁思路
库存扣减可以通过:
sql
update ... where stock > 0
把判断和扣减压进一次更新。
但一人一单这里是:
text
先查询订单是否存在
再插入订单
它是查询 + 插入。
课程这里采用的思路是:
text
给同一个用户的下单逻辑加锁,让同一个用户的请求排队执行。
这样请求 1 先执行完并插入订单,请求 2 再查时就能查到 count = 1。
7. 为什么不能直接 synchronized 整个方法
第一种直觉写法可能是:
java
public synchronized Result createVoucherOrder(Long voucherId) {
// 查订单
// 扣库存
// 创建订单
}
这样确实能让同一时刻只有一个线程进入方法。
但问题是锁粒度太粗。
这等于:
text
所有用户共用一把锁。
结果会变成:
text
用户 1 下单时,用户 2 要等。
用户 2 下单时,用户 3 要等。
但业务真正需要的是:
text
同一个用户的请求互斥。
不同用户的请求可以并发。
所以锁应该按 userId 维度细化。
8. 为什么锁 userId
讲义里会把代码调整成类似这样:
java
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
return createVoucherOrder(voucherId);
}
这表达的业务含义是:
text
同一个 userId 用同一把锁。
不同 userId 用不同锁。
比如:
text
用户 10 的两个请求 -> 锁 "10"
用户 20 的请求 -> 锁 "20"
这样:
text
用户 10 的请求互斥。
用户 10 和用户 20 不互斥。
9. intern() 到底是什么
如果直接写:
java
synchronized (userId.toString()) {
}
可能有问题。
因为 synchronized 锁的是对象,不是字符串内容。
两个内容都是 "10" 的字符串,如果是两个不同对象,也不是同一把锁。
intern() 的作用可以简单理解为:
text
去字符串常量池里拿这个内容唯一对应的字符串对象。
所以:
java
userId.toString().intern()
能保证同一个用户 ID 对应同一个锁对象。
示例:
text
请求 A:userId = 10 -> "10".intern()
请求 B:userId = 10 -> "10".intern()
它们拿到的是同一个常量池对象,所以能互斥。
10. 加锁后的执行流程
数据库 用户10请求2 用户10请求1 数据库 用户10请求2 用户10请求1 #mermaid-svg-9RI0jIQeBbi1XcRp{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-9RI0jIQeBbi1XcRp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9RI0jIQeBbi1XcRp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9RI0jIQeBbi1XcRp .error-icon{fill:#552222;}#mermaid-svg-9RI0jIQeBbi1XcRp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9RI0jIQeBbi1XcRp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9RI0jIQeBbi1XcRp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9RI0jIQeBbi1XcRp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9RI0jIQeBbi1XcRp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9RI0jIQeBbi1XcRp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9RI0jIQeBbi1XcRp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9RI0jIQeBbi1XcRp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9RI0jIQeBbi1XcRp .marker.cross{stroke:#333333;}#mermaid-svg-9RI0jIQeBbi1XcRp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9RI0jIQeBbi1XcRp p{margin:0;}#mermaid-svg-9RI0jIQeBbi1XcRp .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9RI0jIQeBbi1XcRp text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-9RI0jIQeBbi1XcRp .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9RI0jIQeBbi1XcRp .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-9RI0jIQeBbi1XcRp .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-9RI0jIQeBbi1XcRp .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-9RI0jIQeBbi1XcRp #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-9RI0jIQeBbi1XcRp .sequenceNumber{fill:white;}#mermaid-svg-9RI0jIQeBbi1XcRp #sequencenumber{fill:#333;}#mermaid-svg-9RI0jIQeBbi1XcRp #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-9RI0jIQeBbi1XcRp .messageText{fill:#333;stroke:none;}#mermaid-svg-9RI0jIQeBbi1XcRp .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9RI0jIQeBbi1XcRp .labelText,#mermaid-svg-9RI0jIQeBbi1XcRp .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-9RI0jIQeBbi1XcRp .loopText,#mermaid-svg-9RI0jIQeBbi1XcRp .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-9RI0jIQeBbi1XcRp .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-9RI0jIQeBbi1XcRp .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-9RI0jIQeBbi1XcRp .noteText,#mermaid-svg-9RI0jIQeBbi1XcRp .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-9RI0jIQeBbi1XcRp .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9RI0jIQeBbi1XcRp .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9RI0jIQeBbi1XcRp .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9RI0jIQeBbi1XcRp .actorPopupMenu{position:absolute;}#mermaid-svg-9RI0jIQeBbi1XcRp .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-9RI0jIQeBbi1XcRp .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9RI0jIQeBbi1XcRp .actor-man circle,#mermaid-svg-9RI0jIQeBbi1XcRp line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-9RI0jIQeBbi1XcRp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 获取锁 lock: userId=10等待锁释放查询订单 count = 0扣库存保存订单释放锁查询订单 count = 1返回不能重复下单
这样一人一单就能在单机环境下成立。
11. 为什么锁不能放在事务方法内部
这里还有一个很容易被忽略的问题:
如果写成:
java
@Transactional
public Result createVoucherOrder(Long voucherId) {
synchronized (userId.toString().intern()) {
// 查订单、扣库存、保存订单
}
}
可能出现:
text
锁已经释放了,但事务还没提交。
为什么?
因为 Spring 事务通常是通过代理在方法外层控制的。
大致顺序是:
text
开启事务
执行方法
方法返回
提交事务
如果锁只包住方法内部代码,那么方法内部同步块结束时锁就释放了。
但事务提交可能还在同步块之后。
这时第二个请求拿到锁进入查询,可能仍然看不到第一个事务尚未提交的订单。
所以更稳的方式是:
text
在外层先加锁,再通过代理对象调用事务方法。
这样锁释放时,事务方法已经完整执行完。
12. 为什么要用代理对象调用事务方法
Spring 的 @Transactional 依赖代理生效。
如果在同一个类里直接写:
java
this.createVoucherOrder(voucherId);
本质是对象内部自调用,不会经过 Spring 代理。
事务可能不生效。
所以讲义里会使用:
java
IVoucherOrderService proxy =
(IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
它的目的不是为了炫技,而是:
text
通过 Spring 代理对象调用带 @Transactional 的方法,让事务生效。
这也是第三章里非常容易漏掉的一个点。
13. 一人一单完整演进图
#mermaid-svg-DwCJF6H8Tbyv9oS2{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-DwCJF6H8Tbyv9oS2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .error-icon{fill:#552222;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .marker.cross{stroke:#333333;}#mermaid-svg-DwCJF6H8Tbyv9oS2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DwCJF6H8Tbyv9oS2 p{margin:0;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .cluster-label text{fill:#333;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .cluster-label span{color:#333;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .cluster-label span p{background-color:transparent;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .label text,#mermaid-svg-DwCJF6H8Tbyv9oS2 span{fill:#333;color:#333;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .node rect,#mermaid-svg-DwCJF6H8Tbyv9oS2 .node circle,#mermaid-svg-DwCJF6H8Tbyv9oS2 .node ellipse,#mermaid-svg-DwCJF6H8Tbyv9oS2 .node polygon,#mermaid-svg-DwCJF6H8Tbyv9oS2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .rough-node .label text,#mermaid-svg-DwCJF6H8Tbyv9oS2 .node .label text,#mermaid-svg-DwCJF6H8Tbyv9oS2 .image-shape .label,#mermaid-svg-DwCJF6H8Tbyv9oS2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .rough-node .label,#mermaid-svg-DwCJF6H8Tbyv9oS2 .node .label,#mermaid-svg-DwCJF6H8Tbyv9oS2 .image-shape .label,#mermaid-svg-DwCJF6H8Tbyv9oS2 .icon-shape .label{text-align:center;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .node.clickable{cursor:pointer;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .arrowheadPath{fill:#333333;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DwCJF6H8Tbyv9oS2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DwCJF6H8Tbyv9oS2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DwCJF6H8Tbyv9oS2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .cluster text{fill:#333;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .cluster span{color:#333;}#mermaid-svg-DwCJF6H8Tbyv9oS2 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-DwCJF6H8Tbyv9oS2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DwCJF6H8Tbyv9oS2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .icon-shape,#mermaid-svg-DwCJF6H8Tbyv9oS2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .icon-shape p,#mermaid-svg-DwCJF6H8Tbyv9oS2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .icon-shape .label rect,#mermaid-svg-DwCJF6H8Tbyv9oS2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DwCJF6H8Tbyv9oS2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DwCJF6H8Tbyv9oS2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DwCJF6H8Tbyv9oS2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
是
否
需求:同一用户同一券只能下一单
初版:count 查询订单
并发下两个请求都查到 0?
重复下单
加 synchronized
锁整个方法?
锁粒度太粗
按 userId 加锁
userId.toString().intern
同用户串行,不同用户并行
外层加锁,代理调用事务方法
14. 易错点
1. count() 本身没有查错
它能查到当前数据库已有订单。
但它看不到另一个并发请求"即将插入"的订单。
2. 一人一单和防超卖不是同一个问题
防超卖关注库存。
一人一单关注用户和券的购买关系。
3. 锁粒度不能太粗
锁整个方法会导致所有用户排队。
按 userId 加锁更符合业务。
4. intern() 是为了拿到同一个锁对象
synchronized 看的是对象身份,不是字符串内容。
5. 事务和锁的边界要考虑
锁释放太早,事务还没提交,也可能导致并发问题。
15. 面试怎么回答
如果面试官问:一人一单怎么实现?
可以回答:
一人一单本质是限制同一个
userId + voucherId组合只能有一条订单。初步可以在创建订单前查询订单表,判断是否已经存在该用户对该券的订单。但仅靠count()在并发下不安全,因为两个并发请求可能都查到 0。所以需要按用户维度加锁,让同一个用户的下单请求串行执行。
如果面试官问:为什么不直接锁整个方法?
可以回答:
锁整个方法粒度太粗,会导致所有用户下单都串行,性能很差。业务只要求同一个用户不能重复下单,不同用户之间没有必要互斥,所以应该按
userId维度加锁。
如果面试官问:为什么用 userId.toString().intern()?
可以回答:
synchronized锁的是对象。userId.toString()每次可能生成不同字符串对象,内容相同不代表锁对象相同。intern()可以从字符串常量池中获取相同内容对应的同一个字符串对象,从而保证同一个用户使用同一把锁。
如果面试官问:为什么要通过代理对象调用事务方法?
可以回答:
Spring 的声明式事务基于代理生效。如果在同一个类内部通过
this调用事务方法,不会经过代理,事务可能失效。通过AopContext.currentProxy()获取代理对象再调用,可以确保@Transactional生效。
16. 总结
一人一单的核心不是多写一条查询,而是要保证:
text
查询是否下过单
和
真正创建订单
这两件事在同一个用户维度上不能被并发打断。
初版 count() 能表达业务规则,但不能单独防住并发。
所以课程逐步引入:
text
synchronized
按 userId 控制锁粒度
intern 保证同用户同锁对象
事务方法通过代理调用
到这里,单机环境下的一人一单问题基本能守住。
但还有一个更大的问题:
text
如果项目部署多台 Tomcat,这把 synchronized 锁还有效吗?
这就是下一篇要讲的集群并发问题。