分布式锁笔记(Redisson + 注解式 AOP)

分布式锁笔记(Redisson + 注解式 AOP)

本项目原本没有任何分布式锁(全局 grep 无 Redisson、无 tryLock、无 setIfAbsent)。本次按生产标准引入 Redisson,并封装成一个 @DistributedLock 注解,由 AOP 切面统一加锁/解锁,并落地到「重复下单、支付并发、加购读改写」三个真实场景。秒杀热点库存则坚持用 Lua 原子脚本而非锁。本文讲清”是什么、为什么、怎么用、和 Lua 的取舍”。


一、为什么需要分布式锁

1.1 synchronized 只在单 JVM 内有效

单机里用 synchronized / ReentrantLock 就能让同一时刻只有一个线程进临界区。但微服务是多实例、多进程、多台机器:

实例A(order-service:8088):synchronized ──只锁A自己──┐
                                                     ├──► 同一用户并发提交两单
实例B(order-service:8088):synchronized ──只锁B自己──┘     或支付通知与查单同时改状态

JVM 内的锁管不到另一台机器。分布式锁 = 所有实例都能看到的同一把”公共锁”,谁先抢到谁执行,把跨实例的并发操作在逻辑上串行化。

1.2 本项目三个不串行就会出错的场景

场景 不加锁的后果 锁粒度
普通下单 OrderServiceImpl.add 同一用户快速双击/并发提交,生成两笔订单 按用户
支付 paySuccess / syncPayStatus 支付宝异步通知和前端主动查单同时把未支付改已支付 按订单号
加购 CartServiceImpl.add “查购物车有无同款→累加/插入”读改写,并发下插两条或数量丢失更新 按用户+商品

1.3 一把合格分布式锁的要求(面试必背)

要求 含义 Redisson 的做法
互斥 同一时刻只有一个客户端持有 SET NX
防死锁 持锁进程崩了也要能释放 锁带过期时间 leaseTime
可重入 同线程可重复获取同一把锁 Hash 结构记重入次数
防误删 只能解自己的锁 value 存”客户端+线程”唯一标识
防业务超时 业务没执行完锁就过期 看门狗 watchdog 自动续期
高可用 锁不能成单点 集群/红锁(教学版用单机 Redis)

二、依赖接入(关键:只引核心包,不引 starter)

父 pom.xml 统一锁版本:

<properties>
    <redisson.version>3.27.2</redisson.version>
</properties>

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.redisson</groupId>
      <artifactId>redisson</artifactId>
      <version>${redisson.version}</version>
    </dependency>
  </dependencies>
</dependencyManagement>

mall-common/pom.xml 引入(不写版本)。所有业务服务依赖 mall-common,自动获得锁能力;网关不依赖 mall-common,不受影响。

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
</dependency>

为什么引 redisson 而不是 redisson-spring-boot-starter?starter 会自动装配一个 RedissonConnectionFactory,替换掉项目现有的 Lettuce 连接工厂,可能影响 StringRedisTemplate/Spring Cache 行为。引核心包则各用各的连接,互不干扰,最稳。

RedissonClient 配置(复用现有 spring.data.redis)

mall-common/.../config/RedissonConfig.java:

@Configuration
public class RedissonConfig {

    @Bean(destroyMethod = "shutdown")
    @ConditionalOnMissingBean(RedissonClient.class)   // 允许某服务自定义覆盖
    public RedissonClient redissonClient(RedisProperties properties) {
        Config config = new Config();
        // 按 单机/哨兵/集群 自动适配
        if (sentinel) { config.useSentinelServers()... }
        else if (cluster) { config.useClusterServers()... }
        else {
            config.useSingleServer()
                  .setAddress("redis://" + properties.getHost() + ":" + properties.getPort())
                  .setDatabase(properties.getDatabase())
                  .setPassword(hasText(properties.getPassword()) ? properties.getPassword() : null);
        }
        return Redisson.create(config);
    }
}

直接读 Spring Boot 自带的 RedisProperties(即 spring.data.redis.host/port/password/database),不需要再写一套 redisson 配置,单机/哨兵/集群都能切。停机时调用 shutdown() 释放连接。


三、注解 + AOP:一行注解加锁

直接在业务里写 tryLock/finally unlock 啰嗦且容易忘释放。项目封装成注解,业务方法上贴一个即可。

3.1 注解定义 @DistributedLock

mall-common/.../annotation/DistributedLock.java:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String prefix() default "lock";                 // 锁前缀
    String key() default "";                        // SpEL:#orderNo / #userId / #cart.productId
    long waitTime() default 3L;                     // 最多等多久拿不到就失败
    long leaseTime() default 30L;                   // 持锁最长时间,到点自动释放
    TimeUnit unit() default TimeUnit.SECONDS;
    String message() default "操作太频繁,请稍后再试"; // 拿不到锁的提示
}

3.2 切面 DistributedLockAspect

mall-common/.../aspect/DistributedLockAspect.java 核心逻辑:

@Slf4j
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)          // 关键:锁切面包在 @Transactional 外面
public class DistributedLockAspect {

    private final RedissonClient redissonClient;
    private final SpelExpressionParser parser = new SpelExpressionParser();
    private final DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();

    @Around("@annotation(distributedLock)")
    public Object around(ProceedingJoinPoint pjp, DistributedLock distributedLock) throws Throwable {
        String lockKey = buildKey(pjp, distributedLock);   // 解析 SpEL,如 lock:pay:123
        RLock lock = redissonClient.getLock(lockKey);

        boolean acquired;
        try {
            acquired = lock.tryLock(distributedLock.waitTime(),
                                    distributedLock.leaseTime(), distributedLock.unit());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new ServiceException("加锁被中断,请重试");
        }
        if (!acquired) {
            throw new ServiceException(distributedLock.message());   // 快速失败
        }
        try {
            return pjp.proceed();                          // 执行业务(含事务)
        } finally {
            if (lock.isHeldByCurrentThread()) {            // 只解自己的锁
                lock.unlock();
            }
        }
    }
}

key 用 SpEL 拼业务标识,并内置 #userId(当前登录用户,取自 LoginContext):

// 方法参数按名绑定:#orderNo、#cart.productId
// 内置当前登录用户:#userId
Expression expression = parser.parseExpression(annotation.key());
return prefix + ":" + expression.getValue(context);

3.3 锁与事务的先后顺序(高频面试点)

切面标了 @Order(HIGHEST_PRECEDENCE),执行顺序是:

加锁 → 开事务(@Transactional) → 执行业务 → 提交事务 → 释放锁

如果反过来(事务切面包在锁外),会变成”业务改完、锁先释放、事务还没提交”,另一个实例拿到锁读到的仍是旧数据,锁等于白加。所以锁必须在事务外层。

两个注解都基于 Spring AOP 代理,@Order 决定代理嵌套顺序,数字越小越外层。


四、三个落地场景

4.1 防重复下单(按用户,waitTime=0 不等待)

mall-order/.../impl/OrderServiceImpl.java:

@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
@DistributedLock(prefix = "lock:order:create", key = "#userId",
                 waitTime = 0, leaseTime = 30,
                 message = "正在处理订单,请勿重复提交")
@Override
public void add(Order order) { ... }

同一用户并发双击,第二个请求立刻失败(不等待),从入口挡掉重复订单。

4.2 支付并发(按订单号,等 3 秒)

@DistributedLock(prefix = "lock:pay", key = "#orderNo", waitTime = 3, leaseTime = 30,
                 message = "支付结果正在处理,请勿重复操作")
public void paySuccess(Long orderNo) {                 // 支付宝异步通知走这里
    if (订单已是终态) return;                            // 幂等
    update 为已支付;
}

@DistributedLock(prefix = "lock:pay", key = "#orderNo", waitTime = 3, leaseTime = 30, ...)
public Integer syncPayStatus(Long orderNo, Long userId) {  // 前端主动查单走这里
    ... paySuccess(orderNo); ...
}

两条路径可能同时到达(异步通知 + 用户点”我已支付完成”主动查单)。同一把 lock:pay:{orderNo} 把”查状态→改已支付”串行化,配合方法内的状态判断,双重保证不会重复流转。

注意自调用:syncPayStatus 内部 this.paySuccess() 不走代理。所以给两个对外入口都加同一把锁(可重入,不会死锁),而不是只加在内部方法上。

4.3 加购读改写(按用户+商品,等 2 秒让连点正确累加)

mall-cart/.../impl/CartServiceImpl.java:

@DistributedLock(prefix = "lock:cart:add",
                 key = "#userId + ':' + #cart.productId",
                 waitTime = 2, leaseTime = 10,
                 message = "操作太快,请稍后再试")
@Transactional(rollbackFor = Exception.class)
@Override
public void add(Cart cart) {
    // 查该用户购物车是否已有同款 → 有则 count 累加,无则 insert
}

“先 select 再 update/insert”是典型读改写。多实例/双击下不加锁会:两人都查到无记录→插两条;或后写覆盖先写(丢失更新)。这里等 2 秒而不是 0,是为了让用户快速连点”加入购物车”时第二次能排队拿到锁、正确累加到同一行,而不是报错。


五、为什么秒杀热点库存不用分布式锁

这是本项目最重要的取舍。秒杀 execute() 面对的是瞬时数万请求,用锁会让所有请求排队等锁,锁成为瓶颈。项目用 Redis Lua 脚本的天然原子性替代锁:

-- resources/lua/seckill.lua:整段在 Redis 单线程内原子执行
if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then return 2 end  -- 一人一单
local stock = tonumber(redis.call('get', KEYS[1]))
if stock == nil or stock <= 0 then return 0 end                      -- 售罄
if redis.call('decr', KEYS[1]) < 0 then
    redis.call('incr', KEYS[1]); return 0
end
redis.call('sadd', KEYS[2], ARGV[1])
return 1

Redis 执行 Lua 期间不会穿插其他客户端命令,所以”判一人一单 + 判库存 + 扣减”一气呵成,无需加锁、无等待、无死锁,吞吐远高于分布式锁。DB 层再用乐观锁 update product set stock=stock-1 where id=? and stock>=1 兜底防超卖。

方案 适合 本项目
Lua 原子脚本 超高并发、逻辑简单的热点(库存扣减) 秒杀
分布式锁 跨实例、低频、逻辑复杂、必须串行的临界区 下单/支付/加购
DB 乐观锁 最终一致性兜底 商品真实库存

六、手写 SETNX 的坑(Redisson 已替你解决)

若不用 Redisson、自己用 SETNX,面试常问的四个坑:

  1. 没设过期时间:持锁进程崩溃 → 永久死锁。必须一条命令 SET key uuid NX EX 30。
  2. 误删别人的锁:A 的锁超时释放,B 拿到锁,A 执行完却 DEL 掉了 B 的锁。value 要存 UUID,删除前 GET 比对 + Lua 原子删。
  3. 业务没跑完锁先过期:需要后台线程定时续期 → 即 Redisson 的看门狗 watchdog。
  4. SETNX 与 EXPIRE 分两步非原子:中间崩溃则锁无过期。必须合并成一条 SET ... NX EX。

Redisson 用 Hash 记”客户端id:线程id → 重入次数”,天然支持可重入、防误删,看门狗默认每 10s 检查并把锁续到 30s(注:显式指定 leaseTime 后看门狗不再自动续期,到期必释放,这正是我们想要的”防死锁兜底”)。


七、本项目锁清单与生产延伸

已落地(教学版,单机 Redis)

Redis key 位置 waitTime leaseTime
lock:order:create:{userId} 普通下单 0 30s
lock:pay:{orderNo} 支付通知/查单 3s 30s
lock:cart:add:{userId}:{productId} 加购 2s 10s

生产环境还应补强(面试可主动说)

  • Redis 哨兵/集群高可用,极端要求用 Redisson 红锁 RedLock(多数节点加锁成功才算成功)。
  • 锁不是万能:锁内尽量只做必要操作、缩短持锁时间;能靠数据库唯一约束、乐观锁、幂等键解决的,优先用这些无锁手段。
  • 秒杀真正的生产链路是「Lua 预减 + MQ 削峰 + DB 乐观锁」,分布式锁只画龙点睛在低频关键路径。
上一篇
下一篇