从集群锁失效到异步领券:我用分布式锁 + Redisson + MQ 把领券接口 RT 从 200ms 压到 15ms

上一篇博客写了领券的并发安全问题,用 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); // 释放锁
}

看起来没问题。但 SETNXEXPIRE 是两条命令,如果 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 源码还没看,准备抽时间读一下,面试时被追问也能有话讲。

相关推荐
步行cgn37 分钟前
Spring 的模块体系详解
java·数据库·spring
xcl09251 小时前
智慧健身场馆系统开发实战:从架构设计到核心模块落地指南
java·spring boot
vx-程序开发1 小时前
【计算机毕设】基于Spring Boot的古城景区管理系统88564
java·数据库·spring boot·后端·spring·elasticsearch·课程设计
鹿角片ljp1 小时前
LeetCode 31:下一个排列|右找小,右找大,交换,右反转
java·数据结构·算法
ChampaignWolf1 小时前
abapgit-agent:用 Git 当中介,让 Claude Code 自己完成 ABAP 闭环开发
git·ai·abap·claude·abapgit·mcp
边境悍匪1 小时前
蜗牛学苑 Java 智能体学习 Day40|Vue 工程化与 ElementPlus 思维导图复盘
java·vue.js·学习
全栈技术负责人1 小时前
AI 效能工具建设:Agent 可观测性技能技术方案与实践
前端·ai·ai编程
霸道流氓气质2 小时前
通义千问 VL & Audio:图像、视频、语音多模态 Java 集成深度解析
java·开发语言·音视频
lemon_sjdk2 小时前
WCPoint、WebPageClient和Accessor类源码
java·安全·webkit·javafx