黑马点评-优惠券秒杀-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 锁还有效吗?

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

相关推荐
keyipatience1 小时前
21,22 (半)深入理解Linux重定向与缓冲区机制
linux·运维·服务器
星恒讯工业路由器1 小时前
物联网网关天线:分类解析与信号质量认知误区
网络·物联网·wifi·信息与通信·wifi 天线·lora 天线·物联网关天线
YL200404261 小时前
【Redis实战篇】基于Redis的分布式锁的原理及实现
数据库·redis·缓存
1024小神1 小时前
在阿里云买的域名和服务器配置cloudflare的DNS解析,并配置cloudflare生成ssl证书可以用15年
服务器·阿里云·ssl
兔子宇航员03011 小时前
HiveSQL 中 NULL 与空字符串的区别与注意事项
数据库·数据仓库·sql
yyuuuzz1 小时前
aws亚马逊云上运维常见问题梳理
运维·服务器·网络·云计算·aws
杨云龙UP2 小时前
Oracle CDB巡检脚本使用SOP:从HTML原始报告到Word正式交付_2026-05-29
运维·服务器·数据库·oracle·架构·html·巡检
保定公民2 小时前
Oracle 层次查询(CONNECT BY)完全指南:从入门到精通
数据库·sql·oracle·达梦数据库·层次查询