分布式锁笔记(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,面试常问的四个坑:
- 没设过期时间:持锁进程崩溃 → 永久死锁。必须一条命令
SET key uuid NX EX 30。 - 误删别人的锁:A 的锁超时释放,B 拿到锁,A 执行完却
DEL掉了 B 的锁。value 要存 UUID,删除前 GET 比对 + Lua 原子删。 - 业务没跑完锁先过期:需要后台线程定时续期 → 即 Redisson 的看门狗 watchdog。
- 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 乐观锁」,分布式锁只画龙点睛在低频关键路径。