上一篇博客写了领券的并发安全问题,用 Synchronized 乐观锁解决了超卖。但那只解决了单机场景------服务一旦部署多个实例,JVM 级别的锁就形同虚设。这篇记录我是怎么一步步从"集群锁失效"走到 Redisson 分布式锁,再用手写注解 + AOP + 设计模式搭了一套通用锁组件,最后用 Redis 缓存 + LUA 脚本 + MQ 异步写把领券接口性能拉上来的全过程。
一、集群环境下,JVM 锁为什么失效
上一篇我用 synchronized(userId + "") 解决了单机下的并发安全。核心原理是:同一个用户 id 经过 String.intern() 后指向常量池里同一个对象,所有线程抢的是同一把锁(Monitor)。
但服务上线集群后,问题来了。
每个服务实例是一个独立的 JVM 进程,各自有自己的 Monitor。用户王俊翰同时发了两个请求,一个打到实例 A,一个打到实例 B。实例 A 里有一个线程拿到了锁,实例 B 里也有一个线程拿到了锁------两把不同的锁,互斥效果直接消失。
#mermaid-svg-DkK0nHWbd6DgqSrq{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-DkK0nHWbd6DgqSrq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DkK0nHWbd6DgqSrq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DkK0nHWbd6DgqSrq .error-icon{fill:#552222;}#mermaid-svg-DkK0nHWbd6DgqSrq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DkK0nHWbd6DgqSrq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DkK0nHWbd6DgqSrq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DkK0nHWbd6DgqSrq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DkK0nHWbd6DgqSrq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DkK0nHWbd6DgqSrq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DkK0nHWbd6DgqSrq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DkK0nHWbd6DgqSrq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DkK0nHWbd6DgqSrq .marker.cross{stroke:#333333;}#mermaid-svg-DkK0nHWbd6DgqSrq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DkK0nHWbd6DgqSrq p{margin:0;}#mermaid-svg-DkK0nHWbd6DgqSrq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DkK0nHWbd6DgqSrq .cluster-label text{fill:#333;}#mermaid-svg-DkK0nHWbd6DgqSrq .cluster-label span{color:#333;}#mermaid-svg-DkK0nHWbd6DgqSrq .cluster-label span p{background-color:transparent;}#mermaid-svg-DkK0nHWbd6DgqSrq .label text,#mermaid-svg-DkK0nHWbd6DgqSrq span{fill:#333;color:#333;}#mermaid-svg-DkK0nHWbd6DgqSrq .node rect,#mermaid-svg-DkK0nHWbd6DgqSrq .node circle,#mermaid-svg-DkK0nHWbd6DgqSrq .node ellipse,#mermaid-svg-DkK0nHWbd6DgqSrq .node polygon,#mermaid-svg-DkK0nHWbd6DgqSrq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DkK0nHWbd6DgqSrq .rough-node .label text,#mermaid-svg-DkK0nHWbd6DgqSrq .node .label text,#mermaid-svg-DkK0nHWbd6DgqSrq .image-shape .label,#mermaid-svg-DkK0nHWbd6DgqSrq .icon-shape .label{text-anchor:middle;}#mermaid-svg-DkK0nHWbd6DgqSrq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DkK0nHWbd6DgqSrq .rough-node .label,#mermaid-svg-DkK0nHWbd6DgqSrq .node .label,#mermaid-svg-DkK0nHWbd6DgqSrq .image-shape .label,#mermaid-svg-DkK0nHWbd6DgqSrq .icon-shape .label{text-align:center;}#mermaid-svg-DkK0nHWbd6DgqSrq .node.clickable{cursor:pointer;}#mermaid-svg-DkK0nHWbd6DgqSrq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DkK0nHWbd6DgqSrq .arrowheadPath{fill:#333333;}#mermaid-svg-DkK0nHWbd6DgqSrq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DkK0nHWbd6DgqSrq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DkK0nHWbd6DgqSrq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DkK0nHWbd6DgqSrq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DkK0nHWbd6DgqSrq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DkK0nHWbd6DgqSrq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DkK0nHWbd6DgqSrq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DkK0nHWbd6DgqSrq .cluster text{fill:#333;}#mermaid-svg-DkK0nHWbd6DgqSrq .cluster span{color:#333;}#mermaid-svg-DkK0nHWbd6DgqSrq 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-DkK0nHWbd6DgqSrq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DkK0nHWbd6DgqSrq rect.text{fill:none;stroke-width:0;}#mermaid-svg-DkK0nHWbd6DgqSrq .icon-shape,#mermaid-svg-DkK0nHWbd6DgqSrq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DkK0nHWbd6DgqSrq .icon-shape p,#mermaid-svg-DkK0nHWbd6DgqSrq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DkK0nHWbd6DgqSrq .icon-shape .label rect,#mermaid-svg-DkK0nHWbd6DgqSrq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DkK0nHWbd6DgqSrq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DkK0nHWbd6DgqSrq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DkK0nHWbd6DgqSrq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 实例 B (JVM-2)
线程2 lock(userId)
Monitor-2
_owner = 线程2
线程4 lock(userId)
等待 Monitor-2...
实例 A (JVM-1)
线程1 lock(userId)
Monitor-1
_owner = 线程1
线程3 lock(userId)
等待 Monitor-1...
两个 JVM 各自有 Monitor,线程 1 和线程 2 同时拿到各自的锁,并发安全问题再次出现。
解决思路很明确:不能让每个实例用自己的 JVM 内部锁,得找一个所有实例都能访问的"外部锁"。Redis 天然满足这个条件------所有实例共享同一个 Redis,而 Redis 的 SETNX 命令具备互斥性(key 不存在才能写入成功)。
二、从 SETNX 到分布式锁的三次进化
2.1 第一版:SETNX + EXPIRE
最直觉的方案:用 SETNX 抢锁,用 DEL 释放锁,再加一个过期时间防止宕机导致死锁。
java
// 获取锁
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, threadName, 20, TimeUnit.SECONDS);
if (!success) {
return; // 获取锁失败
}
try {
// 执行业务...
} finally {
redisTemplate.delete(key); // 释放锁
}
看起来没问题。但 SETNX 和 EXPIRE 是两条命令,如果 SETNX 执行成功后服务宕机,EXPIRE 来不及执行,锁就永远不会过期。所以必须用 SET key value NX EX 20 一条命令搞定原子性。
2.2 第二版:加锁标识防误删
第一版还有一个更隐蔽的 bug。
线程 A 拿到锁,执行业务太慢,锁超时自动释放了。这时线程 B 拿到锁。线程 A 终于执行完,在 finally 里执行 DEL key------把线程 B 的锁删了。
#mermaid-svg-LwxYP2JHpSKYpeDm{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-LwxYP2JHpSKYpeDm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LwxYP2JHpSKYpeDm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LwxYP2JHpSKYpeDm .error-icon{fill:#552222;}#mermaid-svg-LwxYP2JHpSKYpeDm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LwxYP2JHpSKYpeDm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LwxYP2JHpSKYpeDm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LwxYP2JHpSKYpeDm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LwxYP2JHpSKYpeDm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LwxYP2JHpSKYpeDm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LwxYP2JHpSKYpeDm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LwxYP2JHpSKYpeDm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LwxYP2JHpSKYpeDm .marker.cross{stroke:#333333;}#mermaid-svg-LwxYP2JHpSKYpeDm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LwxYP2JHpSKYpeDm p{margin:0;}#mermaid-svg-LwxYP2JHpSKYpeDm .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-LwxYP2JHpSKYpeDm .cluster-label text{fill:#333;}#mermaid-svg-LwxYP2JHpSKYpeDm .cluster-label span{color:#333;}#mermaid-svg-LwxYP2JHpSKYpeDm .cluster-label span p{background-color:transparent;}#mermaid-svg-LwxYP2JHpSKYpeDm .label text,#mermaid-svg-LwxYP2JHpSKYpeDm span{fill:#333;color:#333;}#mermaid-svg-LwxYP2JHpSKYpeDm .node rect,#mermaid-svg-LwxYP2JHpSKYpeDm .node circle,#mermaid-svg-LwxYP2JHpSKYpeDm .node ellipse,#mermaid-svg-LwxYP2JHpSKYpeDm .node polygon,#mermaid-svg-LwxYP2JHpSKYpeDm .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-LwxYP2JHpSKYpeDm .rough-node .label text,#mermaid-svg-LwxYP2JHpSKYpeDm .node .label text,#mermaid-svg-LwxYP2JHpSKYpeDm .image-shape .label,#mermaid-svg-LwxYP2JHpSKYpeDm .icon-shape .label{text-anchor:middle;}#mermaid-svg-LwxYP2JHpSKYpeDm .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-LwxYP2JHpSKYpeDm .rough-node .label,#mermaid-svg-LwxYP2JHpSKYpeDm .node .label,#mermaid-svg-LwxYP2JHpSKYpeDm .image-shape .label,#mermaid-svg-LwxYP2JHpSKYpeDm .icon-shape .label{text-align:center;}#mermaid-svg-LwxYP2JHpSKYpeDm .node.clickable{cursor:pointer;}#mermaid-svg-LwxYP2JHpSKYpeDm .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-LwxYP2JHpSKYpeDm .arrowheadPath{fill:#333333;}#mermaid-svg-LwxYP2JHpSKYpeDm .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-LwxYP2JHpSKYpeDm .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-LwxYP2JHpSKYpeDm .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LwxYP2JHpSKYpeDm .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-LwxYP2JHpSKYpeDm .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LwxYP2JHpSKYpeDm .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-LwxYP2JHpSKYpeDm .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-LwxYP2JHpSKYpeDm .cluster text{fill:#333;}#mermaid-svg-LwxYP2JHpSKYpeDm .cluster span{color:#333;}#mermaid-svg-LwxYP2JHpSKYpeDm 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-LwxYP2JHpSKYpeDm .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-LwxYP2JHpSKYpeDm rect.text{fill:none;stroke-width:0;}#mermaid-svg-LwxYP2JHpSKYpeDm .icon-shape,#mermaid-svg-LwxYP2JHpSKYpeDm .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LwxYP2JHpSKYpeDm .icon-shape p,#mermaid-svg-LwxYP2JHpSKYpeDm .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-LwxYP2JHpSKYpeDm .icon-shape .label rect,#mermaid-svg-LwxYP2JHpSKYpeDm .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LwxYP2JHpSKYpeDm .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-LwxYP2JHpSKYpeDm .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-LwxYP2JHpSKYpeDm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 🔒 线程A: SETNX lock
⏰ 锁超时自动释放
🔓 线程B: SETNX lock 成功
💥 线程A: DEL lock
误删了线程B的锁!
线程 A 的 DEL 操作把线程 B 的锁删掉了,后续所有线程都能拿到锁,并发安全再次崩塌。
修复方案:在 SETNX 时存入线程标识,DEL 之前先判断锁是不是自己的。
java
public void unlock() {
// 先判断锁标识是否是自己
String value = redisTemplate.opsForValue().get(key);
if (Thread.currentThread().getName().equals(value)) {
redisTemplate.delete(key);
}
}
但这里又有一个极端情况:判断标识和 DEL 是两步操作,如果判断成功后线程阻塞,等锁超时释放、其他线程拿到锁之后再执行 DEL,还是会误删。根本原因是判断和删除不是原子操作。
2.3 第三版:LUA 脚本保证原子性
Redis 执行 LUA 脚本是原子性的,脚本执行期间不会被其他命令插入。把"判断 + 删除"写进一个 LUA 脚本,彻底解决误删问题:
lua
-- 判断锁标识是否是当前线程
if redis.call('get', KEYS[1]) == ARGV[1] then
-- 是,删除锁
return redis.call('del', KEYS[1])
else
-- 不是,不做任何操作
return 0
end
在 Java 中调用:
java
public void unlock() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(UNLOCK_SCRIPT);
script.setResultType(Long.class);
redisTemplate.execute(script, List.of(key), threadName);
}
到这里,一个能用的分布式锁就写完了。但还有锁重入、获取失败重试、Redis 主从一致性等问题没解决。自己一个个写太麻烦,好在有现成的轮子------Redisson。
三、Redisson:一行代码搞定分布式锁
Redisson 是一个基于 Redis 的 Java 工具包,把 JDK 里的锁、队列、Map 等都做了分布式版本。项目里引入依赖后,用法非常简单:
java
@Autowired
private RedissonClient redissonClient;
public void claimCoupon(Long couponId) {
// 1.获取锁对象
RLock lock = redissonClient.getLock("lock:coupon:" + couponId);
try {
// 2.尝试获取锁(waitTime=1s, leaseTime=10s)
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (!isLock) {
throw new BizException("请求太频繁");
}
// 3.执行业务
doClaimCoupon(couponId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 4.释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
Redisson 帮我们解决了什么?看下面的对比:
| 问题 | 手写版 | Redisson |
|---|---|---|
| 锁误删 | 需要自己加线程标识 + LUA | 内部自动处理 |
| 超时释放 | 需要自己设 EXPIRE | WatchDog 自动续期(默认 30s,每 10s 续一次) |
| 锁重入 | 需要用 Hash 结构记录重入次数 | getLock() 天然可重入 |
| 获取失败重试 | 需要自己写循环 | tryLock(waitTime) 内置重试 |
| 公平锁 | 需要自己实现排队逻辑 | getFairLock() 一行搞定 |
这里有个细节:leaseTime 默认是 -1,会触发 WatchDog 自动续期。如果你手动传了 leaseTime,WatchDog 就失效了。面试时这个点经常被追问。
项目里已经配好了 Redisson,直接注入 RedissonClient 就能用。改造后的领券代码:
java
@Override
@Transactional
public void receiveCoupon(Long couponId) {
RLock lock = redissonClient.getLock("lock:coupon:user:" + userId);
try {
if (!lock.tryLock(1, 30, TimeUnit.SECONDS)) {
throw new BizIllegalException("请求太频繁,请稍后再试");
}
// 校验 + 领券逻辑...
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
功能上没问题,但每个需要加锁的方法都要写这一堆 try-catch-finally,对业务代码侵入太大。能不能更优雅?
四、设计模式实战:打造通用分布式锁组件
这是我觉得整个学习过程中设计模式运用最密集的一段。最终效果是在方法上加一个注解就完事:
java
@MyLock(name = "'claim:' + #userId",
type = MyLockType.RE_ENTRANT_LOCK,
strategy = MyLockStrategy.FAIL_FAST)
public void receiveCoupon(Long couponId) {
// 纯业务代码,零锁逻辑
}
怎么从"一堆 try-catch"演进到这个效果?分四步走。
4.1 第一步:自定义注解 + AOP 消除重复代码
思路很直接:锁的获取和释放是固定模板代码,只有中间的业务逻辑不同。这不就是 AOP 环绕增强的经典场景吗?
先定义注解,把锁的参数都放到注解里:
java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MyLock {
String name(); // 锁名称
long waitTime() default 1; // 获取锁等待时间
long leaseTime() default -1; // 锁超时时间,-1触发WatchDog
TimeUnit unit() default TimeUnit.SECONDS;
}
然后写切面,用 @Around 把锁的获取和释放包在外面:
java
@Component
@Aspect
@RequiredArgsConstructor
public class MyLockAspect implements Ordered {
private final RedissonClient redissonClient;
@Around("@annotation(myLock)")
public Object tryLock(ProceedingJoinPoint pjp, MyLock myLock) throws Throwable {
RLock lock = redissonClient.getLock(myLock.name());
boolean isLock = lock.tryLock(myLock.waitTime(), myLock.leaseTime(), myLock.unit());
if (!isLock) {
throw new BizIllegalException("请求太频繁");
}
try {
return pjp.proceed();
} finally {
lock.unlock();
}
}
@Override
public int getOrder() {
return 0; // 必须在事务AOP之前执行
}
}
这里有一个关键细节:切面的 Order 必须小于事务的 Order 。Spring 事务 AOP 的 Order 是 Integer.MAX_VALUE(最低优先级),我们的锁切面设为 0,确保先加锁再开事务。如果反过来,事务先开启,锁释放了但事务还没提交,其他线程拿到锁后读到的还是旧数据。
💼 Service 📦 事务AOP(Order=MAX) 🔒 锁切面(Order=0) 客户端 💼 Service 📦 事务AOP(Order=MAX) 🔒 锁切面(Order=0) 客户端 #mermaid-svg-UdojNyXgafbaLA1e{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-UdojNyXgafbaLA1e .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UdojNyXgafbaLA1e .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UdojNyXgafbaLA1e .error-icon{fill:#552222;}#mermaid-svg-UdojNyXgafbaLA1e .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UdojNyXgafbaLA1e .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UdojNyXgafbaLA1e .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UdojNyXgafbaLA1e .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UdojNyXgafbaLA1e .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UdojNyXgafbaLA1e .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UdojNyXgafbaLA1e .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UdojNyXgafbaLA1e .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UdojNyXgafbaLA1e .marker.cross{stroke:#333333;}#mermaid-svg-UdojNyXgafbaLA1e svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UdojNyXgafbaLA1e p{margin:0;}#mermaid-svg-UdojNyXgafbaLA1e .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UdojNyXgafbaLA1e text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-UdojNyXgafbaLA1e .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-UdojNyXgafbaLA1e .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-UdojNyXgafbaLA1e .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-UdojNyXgafbaLA1e .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-UdojNyXgafbaLA1e #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-UdojNyXgafbaLA1e .sequenceNumber{fill:white;}#mermaid-svg-UdojNyXgafbaLA1e #sequencenumber{fill:#333;}#mermaid-svg-UdojNyXgafbaLA1e #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-UdojNyXgafbaLA1e .messageText{fill:#333;stroke:none;}#mermaid-svg-UdojNyXgafbaLA1e .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UdojNyXgafbaLA1e .labelText,#mermaid-svg-UdojNyXgafbaLA1e .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-UdojNyXgafbaLA1e .loopText,#mermaid-svg-UdojNyXgafbaLA1e .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-UdojNyXgafbaLA1e .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-UdojNyXgafbaLA1e .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-UdojNyXgafbaLA1e .noteText,#mermaid-svg-UdojNyXgafbaLA1e .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-UdojNyXgafbaLA1e .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UdojNyXgafbaLA1e .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UdojNyXgafbaLA1e .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UdojNyXgafbaLA1e .actorPopupMenu{position:absolute;}#mermaid-svg-UdojNyXgafbaLA1e .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-UdojNyXgafbaLA1e .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UdojNyXgafbaLA1e .actor-man circle,#mermaid-svg-UdojNyXgafbaLA1e line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-UdojNyXgafbaLA1e :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 调用方法tryLock()获取锁成功,继续开启事务执行业务方法返回结果提交事务返回unlock()响应
锁切面在最外层包裹事务,保证"先加锁后开事务,先提交事务后释放锁"。
4.2 第二步:工厂模式切换锁类型
Redisson 提供了很多锁类型:可重入锁、公平锁、读锁、写锁。但上面的切面把锁类型写死了(getLock 就是可重入锁)。怎么让用户自己选?
用枚举定义锁类型,再用工厂根据枚举创建对应的锁对象:
java
public enum MyLockType {
RE_ENTRANT_LOCK, // 可重入锁
FAIR_LOCK, // 公平锁
READ_LOCK, // 读锁
WRITE_LOCK, // 写锁
}
工厂类用 EnumMap 存储每种类型对应的创建函数:
java
@Component
public class MyLockFactory {
private final Map<MyLockType, Function<String, RLock>> lockHandlers;
public MyLockFactory(RedissonClient redissonClient) {
this.lockHandlers = new EnumMap<>(MyLockType.class);
this.lockHandlers.put(RE_ENTRANT_LOCK, redissonClient::getLock);
this.lockHandlers.put(FAIR_LOCK, redissonClient::getFairLock);
this.lockHandlers.put(READ_LOCK,
name -> redissonClient.getReadWriteLock(name).readLock());
this.lockHandlers.put(WRITE_LOCK,
name -> redissonClient.getReadWriteLock(name).writeLock());
}
public RLock getLock(MyLockType type, String name) {
return lockHandlers.get(type).apply(name);
}
}
这里用 EnumMap 而不是 HashMap 是一个小优化。EnumMap 底层是数组,枚举项的 ordinal 就是数组下标,定位速度比 HashMap 的哈希计算更快。
这里其实就是一个简单工厂模式:传入锁类型,工厂返回对应的锁对象。比用 if-else 判断优雅得多。
4.3 第三步:策略模式处理锁失败策略
获取锁失败后怎么办?之前的代码是直接抛异常。但不同业务场景需要不同策略:有的需要快速失败,有的需要重试后失败,有的需要一直等待。
一共 5 种策略,用枚举 + 抽象方法实现简化版策略模式:
java
public enum MyLockStrategy {
SKIP_FAST { // 不重试,失败直接返回false
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
return lock.tryLock(0, prop.leaseTime(), prop.unit());
}
},
FAIL_FAST { // 不重试,失败抛异常
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
boolean isLock = lock.tryLock(0, prop.leaseTime(), prop.unit());
if (!isLock) throw new BizIllegalException("请求太频繁");
return true;
}
},
KEEP_TRYING { // 无限重试
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
lock.lock(prop.leaseTime(), prop.unit());
return true;
}
},
SKIP_AFTER_RETRY_TIMEOUT { // 重试到超时,失败返回false
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
return lock.tryLock(prop.waitTime(), prop.leaseTime(), prop.unit());
}
},
FAIL_AFTER_RETRY_TIMEOUT { // 重试到超时,失败抛异常
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
boolean isLock = lock.tryLock(prop.waitTime(), prop.leaseTime(), prop.unit());
if (!isLock) throw new BizIllegalException("请求太频繁");
return true;
}
};
public abstract boolean tryLock(RLock lock, MyLock prop) throws InterruptedException;
}
每个枚举项重写了 tryLock 方法,调用时直接 strategy.tryLock(lock, prop) 就行,不需要 switch-case。这就是策略模式的精髓------把分支逻辑下沉到枚举常量本身。
4.4 第四步:SpEL 表达式实现动态锁名
之前锁名是写死的字符串。但领券场景需要按用户 id 加锁,锁名里要包含方法参数。怎么办?
Spring 的 SpEL 表达式可以解析方法参数。在注解里写 "#userId" 或 "T(com.example.UserContext).getUser()",切面解析后替换成实际值:
java
// 用法示例
@MyLock(name = "'claim:' + #userId")
public void receiveCoupon(Long userId) { ... }
@MyLock(name = "'claim:' + T(com.example.UserContext).getUser()")
public void receiveCoupon(Long couponId) { ... }
解析的核心逻辑:
java
private String getLockName(String name, ProceedingJoinPoint pjp) {
if (!name.contains("#")) return name; // 没有SpEL表达式,直接返回
// 构建上下文:把方法参数注入到SpEL环境中
EvaluationContext context = new MethodBasedEvaluationContext(
TypedValue.NULL, resolveMethod(pjp), pjp.getArgs(), parameterNameDiscoverer);
ExpressionParser parser = new SpelExpressionParser();
Matcher matcher = pattern.matcher(name);
while (matcher.find()) {
String group = matcher.group(1);
Expression expression = parser.parseExpression(
group.charAt(0) == 'T' ? group : "#" + group);
Object value = expression.getValue(context);
name = name.replace(matcher.group(), ObjectUtils.nullSafeToString(value));
}
return name;
}
最终,把注解、切面、工厂、策略、SpEL 组装在一起,整个架构是这样的:
#mermaid-svg-rb9r3AyRe9MApYxE{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-rb9r3AyRe9MApYxE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rb9r3AyRe9MApYxE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rb9r3AyRe9MApYxE .error-icon{fill:#552222;}#mermaid-svg-rb9r3AyRe9MApYxE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rb9r3AyRe9MApYxE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rb9r3AyRe9MApYxE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rb9r3AyRe9MApYxE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rb9r3AyRe9MApYxE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rb9r3AyRe9MApYxE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rb9r3AyRe9MApYxE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rb9r3AyRe9MApYxE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rb9r3AyRe9MApYxE .marker.cross{stroke:#333333;}#mermaid-svg-rb9r3AyRe9MApYxE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rb9r3AyRe9MApYxE p{margin:0;}#mermaid-svg-rb9r3AyRe9MApYxE .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rb9r3AyRe9MApYxE .cluster-label text{fill:#333;}#mermaid-svg-rb9r3AyRe9MApYxE .cluster-label span{color:#333;}#mermaid-svg-rb9r3AyRe9MApYxE .cluster-label span p{background-color:transparent;}#mermaid-svg-rb9r3AyRe9MApYxE .label text,#mermaid-svg-rb9r3AyRe9MApYxE span{fill:#333;color:#333;}#mermaid-svg-rb9r3AyRe9MApYxE .node rect,#mermaid-svg-rb9r3AyRe9MApYxE .node circle,#mermaid-svg-rb9r3AyRe9MApYxE .node ellipse,#mermaid-svg-rb9r3AyRe9MApYxE .node polygon,#mermaid-svg-rb9r3AyRe9MApYxE .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rb9r3AyRe9MApYxE .rough-node .label text,#mermaid-svg-rb9r3AyRe9MApYxE .node .label text,#mermaid-svg-rb9r3AyRe9MApYxE .image-shape .label,#mermaid-svg-rb9r3AyRe9MApYxE .icon-shape .label{text-anchor:middle;}#mermaid-svg-rb9r3AyRe9MApYxE .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rb9r3AyRe9MApYxE .rough-node .label,#mermaid-svg-rb9r3AyRe9MApYxE .node .label,#mermaid-svg-rb9r3AyRe9MApYxE .image-shape .label,#mermaid-svg-rb9r3AyRe9MApYxE .icon-shape .label{text-align:center;}#mermaid-svg-rb9r3AyRe9MApYxE .node.clickable{cursor:pointer;}#mermaid-svg-rb9r3AyRe9MApYxE .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rb9r3AyRe9MApYxE .arrowheadPath{fill:#333333;}#mermaid-svg-rb9r3AyRe9MApYxE .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rb9r3AyRe9MApYxE .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rb9r3AyRe9MApYxE .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rb9r3AyRe9MApYxE .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rb9r3AyRe9MApYxE .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rb9r3AyRe9MApYxE .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rb9r3AyRe9MApYxE .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rb9r3AyRe9MApYxE .cluster text{fill:#333;}#mermaid-svg-rb9r3AyRe9MApYxE .cluster span{color:#333;}#mermaid-svg-rb9r3AyRe9MApYxE 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-rb9r3AyRe9MApYxE .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rb9r3AyRe9MApYxE rect.text{fill:none;stroke-width:0;}#mermaid-svg-rb9r3AyRe9MApYxE .icon-shape,#mermaid-svg-rb9r3AyRe9MApYxE .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rb9r3AyRe9MApYxE .icon-shape p,#mermaid-svg-rb9r3AyRe9MApYxE .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rb9r3AyRe9MApYxE .icon-shape .label rect,#mermaid-svg-rb9r3AyRe9MApYxE .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rb9r3AyRe9MApYxE .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rb9r3AyRe9MApYxE .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rb9r3AyRe9MApYxE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 运行时流程
解析后锁名
RLock
tryLock结果
📝 @MyLock 注解
name / type / strategy / waitTime / leaseTime
🔍 AOP 切面
MyLockAspect
🏭 锁工厂
MyLockFactory
🎯 失败策略
MyLockStrategy
🔧 SpEL 解析器
动态锁名称
🔒 RedissonClient
创建锁对象
5种策略枚举
SKIP_FAST / FAIL_FAST / ...
业务方法调用
这张图说的是:业务方法被调用后,AOP 切面先通过 SpEL 解析器拿到动态锁名,再向锁工厂要对应类型的 RLock,同时根据用户选的策略执行 tryLock,最后决定是放行还是快速失败。整套流程对业务代码完全透明。
这套组件定义好之后,任何业务方法加一个 @MyLock 注解就有分布式锁了,零侵入、可配置、可扩展。
五、异步领券:把 RT 从 200ms 压到 15ms
分布式锁解决了并发安全问题,但引入了新的性能问题。领券流程中,加锁后要做一系列数据库操作:查优惠券、校验库存、校验限领、写入用户券、更新发放数量。整个流程串行执行,加上锁的等待时间,接口 RT 轻松超过 200ms。
高并发写场景有两个经典优化方向:异步写 和合并写。领券流程逻辑长、链路复杂,适合异步写方案。
5.1 思路:校验前置 + 异步落库
核心想法是把领券拆成两步:
第一步(同步,快):在 Redis 中完成所有校验,通过就发一条消息到 MQ,立刻返回用户"领取成功"。
第二步(异步,慢):消费者从 MQ 取消息,慢慢写数据库。
#mermaid-svg-KLipjcgHwX2h0NVz{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-KLipjcgHwX2h0NVz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KLipjcgHwX2h0NVz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KLipjcgHwX2h0NVz .error-icon{fill:#552222;}#mermaid-svg-KLipjcgHwX2h0NVz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KLipjcgHwX2h0NVz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KLipjcgHwX2h0NVz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KLipjcgHwX2h0NVz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KLipjcgHwX2h0NVz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KLipjcgHwX2h0NVz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KLipjcgHwX2h0NVz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KLipjcgHwX2h0NVz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KLipjcgHwX2h0NVz .marker.cross{stroke:#333333;}#mermaid-svg-KLipjcgHwX2h0NVz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KLipjcgHwX2h0NVz p{margin:0;}#mermaid-svg-KLipjcgHwX2h0NVz .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-KLipjcgHwX2h0NVz .cluster-label text{fill:#333;}#mermaid-svg-KLipjcgHwX2h0NVz .cluster-label span{color:#333;}#mermaid-svg-KLipjcgHwX2h0NVz .cluster-label span p{background-color:transparent;}#mermaid-svg-KLipjcgHwX2h0NVz .label text,#mermaid-svg-KLipjcgHwX2h0NVz span{fill:#333;color:#333;}#mermaid-svg-KLipjcgHwX2h0NVz .node rect,#mermaid-svg-KLipjcgHwX2h0NVz .node circle,#mermaid-svg-KLipjcgHwX2h0NVz .node ellipse,#mermaid-svg-KLipjcgHwX2h0NVz .node polygon,#mermaid-svg-KLipjcgHwX2h0NVz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KLipjcgHwX2h0NVz .rough-node .label text,#mermaid-svg-KLipjcgHwX2h0NVz .node .label text,#mermaid-svg-KLipjcgHwX2h0NVz .image-shape .label,#mermaid-svg-KLipjcgHwX2h0NVz .icon-shape .label{text-anchor:middle;}#mermaid-svg-KLipjcgHwX2h0NVz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KLipjcgHwX2h0NVz .rough-node .label,#mermaid-svg-KLipjcgHwX2h0NVz .node .label,#mermaid-svg-KLipjcgHwX2h0NVz .image-shape .label,#mermaid-svg-KLipjcgHwX2h0NVz .icon-shape .label{text-align:center;}#mermaid-svg-KLipjcgHwX2h0NVz .node.clickable{cursor:pointer;}#mermaid-svg-KLipjcgHwX2h0NVz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KLipjcgHwX2h0NVz .arrowheadPath{fill:#333333;}#mermaid-svg-KLipjcgHwX2h0NVz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KLipjcgHwX2h0NVz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KLipjcgHwX2h0NVz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KLipjcgHwX2h0NVz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KLipjcgHwX2h0NVz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KLipjcgHwX2h0NVz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KLipjcgHwX2h0NVz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KLipjcgHwX2h0NVz .cluster text{fill:#333;}#mermaid-svg-KLipjcgHwX2h0NVz .cluster span{color:#333;}#mermaid-svg-KLipjcgHwX2h0NVz 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-KLipjcgHwX2h0NVz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KLipjcgHwX2h0NVz rect.text{fill:none;stroke-width:0;}#mermaid-svg-KLipjcgHwX2h0NVz .icon-shape,#mermaid-svg-KLipjcgHwX2h0NVz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KLipjcgHwX2h0NVz .icon-shape p,#mermaid-svg-KLipjcgHwX2h0NVz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KLipjcgHwX2h0NVz .icon-shape .label rect,#mermaid-svg-KLipjcgHwX2h0NVz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KLipjcgHwX2h0NVz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KLipjcgHwX2h0NVz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KLipjcgHwX2h0NVz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 校验通过
校验失败
👤 用户请求领券
📋 Redis Hash
读取优惠券信息
⚡ LUA 脚本
原子校验库存+限领
📨 发送MQ消息
✅ 立即返回用户
🔄 消费者异步写库
💾 插入用户券
💾 更新发放数量
❌ 返回失败原因
同步链路只做 Redis 操作(读缓存 + LUA 校验 + 发消息),RT 从 200ms 级别降到 15ms 以内。数据库写入交给 MQ 消费者异步处理。
5.2 Redis Hash 缓存优惠券信息
领券的第一步是查优惠券信息(库存、限领数量等)。每次都查数据库太慢,用 Redis Hash 缓存起来:
java
// 缓存结构:
// Key: coupon:info:{couponId}
// Field: totalNum / issueNum / userLimit / ...
// Value: 对应的值
// 发放优惠券时预热缓存
public void warmUpCouponCache(Coupon coupon) {
String key = "coupon:info:" + coupon.getId();
Map<String, String> cacheMap = new HashMap<>();
cacheMap.put("totalNum", coupon.getTotalNum().toString());
cacheMap.put("issueNum", coupon.getIssueNum().toString());
cacheMap.put("userLimit", coupon.getUserLimit().toString());
// ...
redisTemplate.opsForHash().putAll(key, cacheMap);
}
5.3 LUA 脚本:把多步校验合成一次 Redis 调用
校验逻辑涉及多个步骤:读取库存、判断是否充足、读取用户已领数量、判断是否超限、库存 +1、用户已领 +1。如果在 Java 里一步步做,需要多次 Redis 往返,而且中间可能被其他线程插入。
用 LUA 脚本把这些操作合成一次调用,既减少网络往返,又保证原子性:
lua
-- KEYS[1]: 优惠券缓存key
-- ARGV[1]: 用户id
-- 返回值: 1=成功, 0=库存不足, -1=超出限领
-- 1.读取库存和限领数量
local totalNum = tonumber(redis.call('hget', KEYS[1], 'totalNum'))
local issueNum = tonumber(redis.call('hget', KEYS[1], 'issueNum'))
local userLimit = tonumber(redis.call('hget', KEYS[1], 'userLimit'))
-- 2.校验库存
if issueNum >= totalNum then
return 0
end
-- 3.校验用户限领
local userKey = KEYS[1] .. ':user:' .. ARGV[1]
local userCount = tonumber(redis.call('get', userKey)) or 0
if userCount >= userLimit then
return -1
end
-- 4.库存+1
redis.call('hincrby', KEYS[1], 'issueNum', 1)
-- 5.用户已领+1
redis.call('incr', userKey)
return 1
在 Java 中调用:
java
public int claimCouponCheck(Long couponId, Long userId) {
String key = "coupon:info:" + couponId;
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(CLAIM_COUPON_SCRIPT);
script.setResultType(Long.class);
Long result = redisTemplate.execute(script, List.of(key), userId.toString());
return result == null ? -1 : result.intValue();
}
一个 LUA 脚本搞定"读库存 → 校验库存 → 读限领 → 校验限领 → 扣库存 → 记用户领取数"六步操作。相比 Java 里六次 Redis 调用,网络往返从 6 次降到 1 次,而且天然原子性,不需要额外加锁。
5.4 性能对比
| 方案 | 链路 | RT | 并发安全 |
|---|---|---|---|
| 最初版(纯数据库) | DB 查询 + DB 写入 | ~200ms | 不安全 |
| Synchronized 版 | JVM 锁 + DB 操作 | ~250ms(串行化) | 安全(单机) |
| Redisson 分布式锁版 | 分布式锁 + DB 操作 | ~300ms(锁竞争) | 安全(集群) |
| Redis 缓存 + LUA + MQ | Redis 校验 + MQ + 异步落库 | ~15ms | 安全(最终一致) |
最后一行用了"最终一致"------用户端立刻看到领取成功,数据库里过一会儿才写入。对于领券这种场景,短暂的不一致完全可以接受。
六、整体感受
这篇内容是我在整个优惠券模块中学到东西最多的一段。从最底层的分布式锁原理,到 Redisson 的工程实践,再到用手写注解 + AOP + 四个设计模式搭通用组件,最后到异步写 + LUA 脚本的性能优化,一条线串下来。
最大的感受是:设计模式不是背概念,是遇到问题后的自然选择。需要按类型创建不同对象,自然就写出了工厂模式;需要按策略处理失败,自然就写出了策略模式。之前学设计模式总觉得抽象,这次算是真正体会到了"需求驱动设计"。
另一个收获是对 LUA 脚本的认识。以前觉得 Redis 就是 get/set,没想到还能用 LUA 把复杂逻辑下沉到 Redis 端执行。这个思路在很多场景都能用:秒杀扣库存、限流计数、分布式 ID 生成。
下一步打算把这套分布式锁组件和 LUA 脚本的思路用到项目的其他模块里。另外 Redisson 的 WatchDog 和 RedLock 源码还没看,准备抽时间读一下,面试时被追问也能有话讲。