一、痛点:单机锁在分布式环境下为什么失效
业务刚起步时,扣库存、领优惠券、生成订单号这些临界区用一个 synchronized 或 ReentrantLock 就搞定了。但服务一水平扩容到多实例,问题立刻暴露:JVM 锁只能锁住本进程,两个实例上的线程可以同时进入同一个临界区——库存超卖、优惠券被并发领取、订单号重复,事故往往就在扩容后的第一次大促。
很多人第一反应是「上 Redis 锁」,但分布式锁的坑比想象中多。本文从手动 SETNX 实现讲起,一步步演进到 Redisson 的生产级方案,最后重点讲最容易忽略的锁粒度设计。
二、手动实现:从 SETNX 到 Lua 原子释放的三阶段演进
第一阶段:SETNX 加锁、EXPIRE 设过期时间,两条命令分开执行。看着没问题,但 SETNX 成功之后、EXPIRE 执行之前进程宕机,锁就永远不会过期,直接死锁。
第二阶段:用单条原子命令 SET key value NX EX seconds,加锁和过期一并完成,解决死锁问题。但释放锁时用 DEL 会误删:线程 A 的锁超时自动释放后,线程 B 拿到锁,此时 A 才执行完业务回来 DEL,把 B 的锁删掉了,第三个线程趁虚而入。
第三阶段:释放前先比对 value(每个线程持有一个唯一 token),用 Lua 脚本保证「比对 + 删除」的原子性:
// 加锁:token 必须是线程唯一标识,如 UUID
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent("lock:order:" + orderId, token, 30, TimeUnit.SECONDS);
// 释放:Lua 脚本原子校验并删除
String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then "
+ "return redis.call('del', KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(lua, Long.class),
Collections.singletonList("lock:order:" + orderId), token);
if (result != null && result == 1L) {
// 释放成功
}
到这里手动实现「能用」了,但仍有三座大山:可重入要自己用 ThreadLocal 计数实现、锁续期要自己起定时任务、主从切换会丢锁。与其造轮子,不如直接用 Redisson——它把这些全部内置了。
三、Redisson 核心机制:看门狗、可重入与 tryLock
Redisson 的 RLock 默认启用看门狗(Watchdog):锁默认有效期 30 秒,后台每 10 秒自动续期到 30 秒,只要业务线程还活着锁就不会过期;如果进程宕机,锁最多 30 秒后自动释放,不会死锁。这一机制直接消灭了「锁超时设多少合适」的千古难题。
生产代码示例:
@Configuration
public class RedissonConfig {
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://redis-host:6379")
.setPassword("******")
.setConnectionPoolSize(32);
return Redisson.create(config);
}
}
// 业务代码:库存扣减
RLock lock = redissonClient.getLock("lock:sku:" + skuId);
boolean locked = false;
try {
// waitTime 2 秒:拿不到锁最多等 2 秒
// 不传 leaseTime:启用看门狗自动续期
locked = lock.tryLock(2, TimeUnit.SECONDS);
if (!locked) {
throw new BizException("系统繁忙,请稍后重试");
}
// 临界区:查询库存、扣减、写流水
int stock = stockMapper.selectStock(skuId);
if (stock < buyCount) {
throw new BizException("库存不足");
}
stockMapper.decreaseStock(skuId, buyCount);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
重点提醒:tryLock(waitTime, leaseTime, unit) 一旦显式传入 leaseTime,看门狗就失效,锁到期直接释放。生产上要么不传 leaseTime 靠看门狗续期,要么评估业务最大耗时后传一个足够大的 leaseTime,两种策略二选一,不要混用,否则会出现「以为在看门狗保护,其实锁已经过期」的错觉。
四、锁粒度设计:别一把锁锁全库
锁 key 的设计直接决定并发上限,这是新手最容易翻车的地方。
- 全局锁:
lock:inventory。所有 SKU 共抢一把锁,库存操作全部串行,吞吐量断崖式下跌,大促必崩。 - 资源维度锁(推荐):
lock:sku:{skuId}、lock:user:{userId}、lock:coupon:{couponId}。不同 SKU 的扣减互不阻塞,并发度 = 资源数量。 - 业务组合锁:防止同一用户重复下单,用
lock:order:{userId}:{skuId}或幂等号维度,兼顾防重与并发。
另一个原则:锁内代码越短越好。锁内只放「读-判断-写」的必要操作,库存预检、权限校验放锁外;远程 RPC、慢 SQL 绝不放进临界区,否则锁被长事务拖住,整个资源维度都被阻塞。
五、踩坑记录
- 锁超时设太短:手动实现时 TTL 设 10 秒,结果临界区里有慢 SQL 跑了 15 秒,锁提前过期,两个线程同时进入,数据错乱。解法:Redisson 看门狗,或按 P99 耗时评估 leaseTime 并加锁内耗时监控。
- 误删他人锁:释放时不比对 token,A 的锁过期后 B 加锁,A 回来 DEL 把 B 的锁删了。解法:Lua 脚本比对唯一标识,Redisson 已内置,手动实现必须抄对。
- 主从切换丢锁:master 宕机时锁还没来得及同步到 slave,新 master 上无锁,另一实例可同时加锁。解法:绝大多数业务接受单节点 + 看门狗即可;强一致场景 RedLock 实现复杂且社区争议大,不如用数据库唯一索引 / 乐观锁版本号做最终兜底。
- waitTime 设置不当:设为 0 时拿不到锁立刻抛异常,前端疯狂重试,Redis 连接被打满。解法:waitTime 给 200~500ms,拿不到锁走「快速失败 + 提示重试」或丢进队列削峰。
- key 无规范无兜底:锁 key 不带业务前缀、不设 TTL,导致 key 膨胀或死锁。解法:统一
lock:{业务}:{id}命名规范,TTL 兜底,监控平台定期巡检残留锁。
六、最佳实践总结
- 首选 Redisson,别重复造轮子:可重入、看门狗、公平锁、读写锁开箱即用;
- 锁粒度按资源维度拆分,锁内代码最短化,锁外校验锁内写;
- leaseTime 策略明确二选一:看门狗续期 或 固定超时,并监控锁等待时长与锁内耗时;
- 拿不到锁必须有降级策略:快速失败、排队、限次重试,杜绝无限阻塞;
- 锁只是性能手段,数据库唯一索引 / 版本号才是最终防线,双保险才能扛住极端并发;
- 定期巡检:Redis 中残留锁 key、锁内耗时超过阈值的业务要重点重构。