黑马点评-优惠券秒杀-04_one_user_one_order

黑马点评优惠券秒杀四:一人一单为什么只靠 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 锁还有效吗?

这就是下一篇要讲的集群并发问题。

相关推荐
Johny_Zhao6 分钟前
FreeRADIUS + 华为AC/AP + 802.1X/WPA2-Enterprise 无线认证对接部署完整指南
网络·网络安全·信息安全·ldap·radius·huawei·系统运维·centos8
笃行3509 分钟前
KingbaseES KSQL Developer 的连接管理、数据处理与对象管理
数据库
ha_lydms20 分钟前
MaxCompute中加密与解密函数
大数据·数据库·数据仓库·hadoop·dataworks·maxcompute·odps
lisanmengmeng39 分钟前
nacos nrpe 监控机及被监控机部署
服务器·nacos·nagios·nrpe
Shadow(⊙o⊙)42 分钟前
MySQL内置函数
数据库·mysql
天远API1 小时前
零信任架构实战:基于天远人企关联构建自动化图谱网关
网络·人工智能·架构·自动化
笃行3501 小时前
金仓 KingbaseES PL/SQL 开发全攻略:异常处理、数据类型、调试优化与过程语句
数据库
2301_808414382 小时前
Linux中动静态库的理解
linux·运维·服务器
快乐的木子李2 小时前
mysql-26.7.0-linux26.7.0下载安装
数据库·mysql
vipjx12 小时前
百度网盘限速怎么破?2026实测PanDownload多线程提速方案
网络·智能路由器