上一篇博客写了优惠券管理和兑换码算法,这周继续往下做——用户领券。功能本身不复杂:查券、校验、插入用户券记录、扣减库存。但用 JMeter 一跑并发测试就炸了:100 个库存发了 109 张券,用户限领 2 张结果领了 4 张,加了锁没用,加了事务也没用。前前后后改了四轮,每一轮解决一个问题,同时引出下一个问题。整个过程像拆连环炸弹,剪了一根线结果旁边还藏着一根。但也正是这四个坑,让我对乐观锁、synchronized 锁对象、事务边界、Spring 代理机制的理解从"知道"变成了"真懂"。
一、第一颗炸弹:超卖——100 个库存发了 109 张券
领券的核心逻辑是三步走:查询库存 → 判断是否充足 → 更新已发放数量。单线程跑完全没问题,但并发场景下就出事了:
💾 数据库🧵 线程2🧵 线程1💾 数据库🧵 线程2🧵 线程1#mermaid-svg-pkzerSKqGCLWKhSd{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-pkzerSKqGCLWKhSd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pkzerSKqGCLWKhSd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pkzerSKqGCLWKhSd .error-icon{fill:#552222;}#mermaid-svg-pkzerSKqGCLWKhSd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pkzerSKqGCLWKhSd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pkzerSKqGCLWKhSd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pkzerSKqGCLWKhSd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pkzerSKqGCLWKhSd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pkzerSKqGCLWKhSd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pkzerSKqGCLWKhSd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pkzerSKqGCLWKhSd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pkzerSKqGCLWKhSd .marker.cross{stroke:#333333;}#mermaid-svg-pkzerSKqGCLWKhSd svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pkzerSKqGCLWKhSd p{margin:0;}#mermaid-svg-pkzerSKqGCLWKhSd .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-pkzerSKqGCLWKhSd text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-pkzerSKqGCLWKhSd .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-pkzerSKqGCLWKhSd .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-pkzerSKqGCLWKhSd .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-pkzerSKqGCLWKhSd .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-pkzerSKqGCLWKhSd #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-pkzerSKqGCLWKhSd .sequenceNumber{fill:white;}#mermaid-svg-pkzerSKqGCLWKhSd #sequencenumber{fill:#333;}#mermaid-svg-pkzerSKqGCLWKhSd #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-pkzerSKqGCLWKhSd .messageText{fill:#333;stroke:none;}#mermaid-svg-pkzerSKqGCLWKhSd .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-pkzerSKqGCLWKhSd .labelText,#mermaid-svg-pkzerSKqGCLWKhSd .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-pkzerSKqGCLWKhSd .loopText,#mermaid-svg-pkzerSKqGCLWKhSd .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-pkzerSKqGCLWKhSd .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-pkzerSKqGCLWKhSd .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-pkzerSKqGCLWKhSd .noteText,#mermaid-svg-pkzerSKqGCLWKhSd .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-pkzerSKqGCLWKhSd .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-pkzerSKqGCLWKhSd .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-pkzerSKqGCLWKhSd .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-pkzerSKqGCLWKhSd .actorPopupMenu{position:absolute;}#mermaid-svg-pkzerSKqGCLWKhSd .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-pkzerSKqGCLWKhSd .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-pkzerSKqGCLWKhSd .actor-man circle,#mermaid-svg-pkzerSKqGCLWKhSd line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-pkzerSKqGCLWKhSd :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}💥 超卖!库存100,发了101张SELECT issue_num → 99SELECT issue_num → 99判断 99 < 100 ✅判断 99 < 100 ✅UPDATE issue_num = issue_num + 1 → 100UPDATE issue_num = issue_num + 1 → 101
这张图说的是经典的"先查后改"问题:两个线程同时读到库存 99,都判断充足,都执行更新,结果超卖了。根本原因是查询、判断、更新这三步操作不具备原子性。
乐观锁的改进方案
最直接的方案是悲观锁(synchronized),但性能差。更好的思路是乐观锁——不加锁,在更新时做校验。
传统乐观锁是 CAS 思想:UPDATE SET issue_num = issue_num + 1 WHERE id = ? AND issue_num = 99,WHERE 后面跟的是之前查到的值。如果值变了,说明有别的线程改过了,更新失败。
但传统 CAS 有个问题:10 个线程并发,只有 1 个能成功,失败率太高。
项目里用了一个巧妙的改进:不判断 issue_num 是否等于旧值,只判断 issue_num 是否小于 total_num。这样只要库存还有,所有线程都能成功;库存没了,所有线程都失败。不存在"只有一个成功"的问题。
— 改进版乐观锁:WHERE 条件判断库存是否充足
UPDATE coupon SET issue_num = issue_num + 1
WHERE id = #{couponId} AND issue_num < total_num
int r = couponMapper.incrIssueNum(coupon.getId());
if (r == 0) {
throw new BizIllegalException("优惠券库存不足");
}
WHERE 条件不成立时不会报错,而是返回 0。判断返回值,为 0 就抛异常触发回滚。
| WHERE 条件 | issue_num = 旧值 | issue_num < total_num |
| 并发成功率 | N 个线程只有 1 个成功 | 只要有库存就能成功 |
| 安全性 | 严格一致 | 库存场景下等价 |
| 适用场景 | 通用 | 库存/限额类业务 |
二、第二颗炸弹:锁失效——Long.toString() 每次 new 一个新对象
超卖解决了,但用户限领数量的校验还有问题。库存可以用乐观锁,但限领数量不行——因为这里要先统计当前用户已领了多少张,再判断是否超限,统计的是 user_coupon 表的记录数,没法在 WHERE 条件里做判断。
只能上悲观锁。锁的范围不需要太大,只要锁住"同一个用户"就行。于是用了 synchronized 代码块,锁对象是用户 ID:
// 看似正确的写法
synchronized(userId.toString()) {
// 统计已领数量、判断、新增用户券…
}
JMeter 一跑,锁没生效。
问题出在 userId.toString() 上。Long 的 toString 源码是这样的:
// Long.toString() 的源码
public String toString() {
return String.valueOf(value); // 内部调用 new String()
}
每次调用都 new 了一个新的 String 对象。同一个用户 ID 是 100,两次 toString() 得到的是两个内容相同但地址不同的 String 对象——两把不同的锁。synchronized 锁的是对象引用,不是内容,所以根本锁不住。
intern() 的救场
String 的 intern() 方法会返回字符串常量池中的唯一引用。只要两个字符串内容相同,intern() 返回的就是同一个对象:
// 正确写法:加 intern()
synchronized(userId.toString().intern()) {
checkAndCreateUserCoupon(coupon, userId, null);
}
intern() 保证:内容相同的字符串,== 比较也返回 true。这样同一个用户 ID 永远锁的是同一个对象。
这个坑告诉我一个道理:synchronized 锁的是对象引用,不是值。用 String 做锁对象时,必须确保拿到的是常量池中的唯一引用。
三、第三颗炸弹:事务边界——先开事务再拿锁,锁白加了
锁对象修好了,JMeter 再跑,用户还是超领了。
这次的问题更隐蔽。整个领券方法上加了 @Transactional,而 synchronized 在方法内部。执行顺序变成了:
开启事务 → 获取锁 → 统计已领数量 → 判断 → 新增用户券 → 释放锁 → 提交事务
问题出在"释放锁"和"提交事务"的顺序上。线程 1 释放锁的时候,事务还没提交。线程 2 立刻拿到锁,开始统计已领数量——但线程 1 的数据还没提交,线程 2 读不到。于是线程 2 认为用户还没领过券,又插入了一条记录。
💾 数据库🧵 线程2🧵 线程1💾 数据库🧵 线程2🧵 线程1#mermaid-svg-X2LkTDnqx7NhOkvm{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-X2LkTDnqx7NhOkvm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-X2LkTDnqx7NhOkvm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-X2LkTDnqx7NhOkvm .error-icon{fill:#552222;}#mermaid-svg-X2LkTDnqx7NhOkvm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-X2LkTDnqx7NhOkvm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-X2LkTDnqx7NhOkvm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-X2LkTDnqx7NhOkvm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-X2LkTDnqx7NhOkvm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-X2LkTDnqx7NhOkvm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-X2LkTDnqx7NhOkvm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-X2LkTDnqx7NhOkvm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-X2LkTDnqx7NhOkvm .marker.cross{stroke:#333333;}#mermaid-svg-X2LkTDnqx7NhOkvm svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-X2LkTDnqx7NhOkvm p{margin:0;}#mermaid-svg-X2LkTDnqx7NhOkvm .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-X2LkTDnqx7NhOkvm text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-X2LkTDnqx7NhOkvm .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-X2LkTDnqx7NhOkvm .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-X2LkTDnqx7NhOkvm .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-X2LkTDnqx7NhOkvm .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-X2LkTDnqx7NhOkvm #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-X2LkTDnqx7NhOkvm .sequenceNumber{fill:white;}#mermaid-svg-X2LkTDnqx7NhOkvm #sequencenumber{fill:#333;}#mermaid-svg-X2LkTDnqx7NhOkvm #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-X2LkTDnqx7NhOkvm .messageText{fill:#333;stroke:none;}#mermaid-svg-X2LkTDnqx7NhOkvm .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-X2LkTDnqx7NhOkvm .labelText,#mermaid-svg-X2LkTDnqx7NhOkvm .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-X2LkTDnqx7NhOkvm .loopText,#mermaid-svg-X2LkTDnqx7NhOkvm .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-X2LkTDnqx7NhOkvm .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-X2LkTDnqx7NhOkvm .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-X2LkTDnqx7NhOkvm .noteText,#mermaid-svg-X2LkTDnqx7NhOkvm .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-X2LkTDnqx7NhOkvm .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-X2LkTDnqx7NhOkvm .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-X2LkTDnqx7NhOkvm .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-X2LkTDnqx7NhOkvm .actorPopupMenu{position:absolute;}#mermaid-svg-X2LkTDnqx7NhOkvm .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-X2LkTDnqx7NhOkvm .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-X2LkTDnqx7NhOkvm .actor-man circle,#mermaid-svg-X2LkTDnqx7NhOkvm line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-X2LkTDnqx7NhOkvm :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}线程2立刻获取锁💥 用户超领!开启事务获取锁 ✅统计已领数量 → 0INSERT 用户券记录释放锁事务尚未提交!统计已领数量 → 0(读不到未提交数据)INSERT 又一条用户券记录提交事务提交事务
这张图说的是事务边界和锁边界不一致导致的问题:锁在事务内部,释放锁时事务还没提交,下一个线程读不到未提交的数据。
解决方案:锁包事务,不是事务包锁
核心原则就一句话:先获取锁,再开启事务;先提交事务,再释放锁。
具体做法是把 @Transactional 从外层方法移到内层的 checkAndCreateUserCoupon 方法上:
// 外层方法:不加事务,先加锁
public void receiveCoupon(Long couponId) {
// … 查询优惠券、校验时间、校验库存 …
// 在事务之外加锁
synchronized(userId.toString().intern()) {
checkAndCreateUserCoupon(coupon, userId, null);
}
}
// 内层方法:加事务
@Transactional
public void checkAndCreateUserCoupon(Coupon coupon, Long userId, Integer serialNum) {
// 统计已领数量 → 判断 → 新增用户券
}
执行顺序变成了:
获取锁 → 开启事务 → 统计 → 判断 → 新增 → 提交事务 → 释放锁
事务在锁的保护范围内完成,提交后才释放锁,下一个线程一定能读到最新数据。
四、第四颗炸弹:事务失效——非事务方法调用事务方法
边界改好了,并发安全终于解决了。但测试时随手在业务末尾抛了个异常,发现库存和用户券都没回滚——事务失效了。
原因就藏在刚才的改造里:receiveCoupon 没有 @Transactional,它调用了有 @Transactional 的 checkAndCreateUserCoupon。Spring 的事务是基于 AOP 动态代理实现的,类内部的方法调用走的是 this,不经过代理对象,所以事务注解根本不会生效。
事务失效的六种常见原因
| 事务方法非 public | Spring 默认只代理 public 方法 |
| 非事务方法调用事务方法 | 类内部调用走 this,不经过代理对象 |
| 异常被 try-catch 吞了 | Spring 感知不到异常,不会回滚 |
| 异常类型不匹配 | 默认只回滚 RuntimeException,IOException 需要指定 rollbackFor |
| 传播行为配置错误 | REQUIRES_NEW 会创建独立事务,外部回滚不影响它 |
| Bean 未被 Spring 管理 | 忘了加 @Service,代理对象根本不存在 |
我们踩的是第二种。解决方案是让调用走代理对象,而不是 this。
AspectJ 暴露代理对象
// 1. 启动类加注解,暴露代理对象
@EnableAspectJAutoProxy(exposeProxy = true)
// 2. 在代码中获取代理对象来调用事务方法
public void receiveCoupon(Long couponId) {
// …
synchronized(userId.toString().intern()) {
// 不走 this,走 Spring 代理对象
IUserCouponService proxy = (IUserCouponService) AopContext.currentProxy();
proxy.checkAndCreateUserCoupon(coupon, userId, null);
}
}
AopContext.currentProxy() 返回的是 Spring 为当前 Bean 创建的代理对象。通过代理对象调用方法,@Transactional 的 AOP 增强才会生效。
五、BitMap 防重兑:兑换码场景的特殊处理
手动领券用乐观锁解决了超卖,但兑换码兑换还有一个额外的问题:怎么防止同一个兑换码被兑换两次?
项目里复用了之前签到博客讲过的 BitMap。兑换码的 ID 是自增序列号,每个 ID 对应 BitMap 中的一个 bit 位。兑换时调用 SETBIT,通过返回值判断之前是 0 还是 1:
// SETBIT 返回修改前的值
// 如果返回 true,说明之前已经是 1,已经兑换过了
boolean exchanged = redisTemplate.opsForValue()
.setBit(COUPON_CODE_MAP_KEY, serialNum, true);
if (exchanged) {
throw new BizIllegalException("兑换码已经被兑换过了");
}
如果后续业务出错(比如优惠券过期、库存不足),需要把 bit 位重置为 0:
try {
// … 查询兑换码、校验优惠券、生成用户券 …
} catch (Exception e) {
// 业务失败,重置兑换标记
redisTemplate.opsForValue().setBit(COUPON_CODE_MAP_KEY, serialNum, false);
throw e;
}
整个过程不查数据库,O(1) 判断兑换状态。
六、面试怎么说
面试官问"如何解决优惠券超发问题",建议分两层回答:先说库存超卖用乐观锁(WHERE issue_num < total_num),再说限领数量用悲观锁(synchronized + intern)。
如果面试官追问"加了锁但并发问题依然存在",就讲事务边界的故事:锁在事务内部,释放锁时事务还没提交,下一个线程读不到未提交数据。解决方案是锁包事务——先拿锁再开事务,先提交事务再释放锁。
如果继续追问"事务失效怎么排查",就把六种原因列出来,重点讲"非事务方法调用事务方法"这个场景和 AopContext.currentProxy() 的解决方案。能讲清楚这个链路,说明对 Spring AOP 的代理机制有真正理解。
七、这周的整体感受
四个坑拆下来,最大的感触是:并发安全和事务这两个东西,单独看都能理解,叠在一起就是另一回事了。乐观锁解决超卖、synchronized 解决限领,这些都不难。难的是发现"锁加了但没生效"、"事务加了但没回滚"这种隐蔽问题。
拆问题的过程让我养成了一个习惯:写完代码后,先画一下执行时序图,把线程的获取锁/释放锁、事务的开启/提交标在时间轴上,看看有没有交叉。如果锁的边界和事务的边界有交叉,就一定有问题。
另一个收获是对 Spring AOP 代理机制的理解。以前只知道 @Transactional 加了就有事务,现在知道了事务是通过代理对象实现的,类内部调用不走代理就会失效。这种"知道原理才能排查问题"的感觉,跟之前做兑换码算法时的感受是一样的。
下周准备做优惠券的叠加推荐和优惠金额计算,据说逻辑更复杂。继续加油。


