用 Redis + Redisson 实现分布式锁:从踩坑到生产可用
单机部署时,一个 synchronized 就能锁住临界区。但服务一旦扩到多实例,JVM 级别的锁就完全失效了------两个实例各自持有自己的锁,谁也拦不住谁,超卖、重复下单立刻就来了。这时候就需要一把所有实例都认的「分布式锁」。
Redis 是最常见的实现载体。很多人上手直接写 SETNX,结果在生产上锁死、锁被别人释放、锁提前过期各种问题轮番上演。这篇我们从最朴素的写法一路踩坑,最后落到用 Redisson 的生产级方案。
朴素写法:SETNX 为什么不够
先看最容易想到的写法:
java
// 反例:问题一大堆,别在生产用
public boolean tryLock(String key, String value) {
// setIfAbsent 就是 SETNX
Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, value);
return Boolean.TRUE.equals(ok);
}
public void unlock(String key) {
redisTemplate.delete(key);
}
这段代码至少有三个致命问题:
- 没有过期时间:如果加锁的实例在释放前崩溃了,这个 key 永远留在 Redis 里,后面所有人都拿不到锁------死锁。
- 加锁和设过期不是原子的 :就算你补一句
expire,setIfAbsent成功后、expire执行前进程挂掉,还是死锁。 - 误删别人的锁 :A 的锁因为业务超时自动过期了,B 拿到了锁;这时 A 的业务执行完调
unlock,把 B 的锁删掉了。
修正第一步:原子加锁 + 校验持有者
前两个问题好解决,SET key value NX PX 一条命令原子完成加锁和设过期:
java
public boolean tryLock(String key, String value, long expireMs) {
// NX + 过期时间一步到位,不会出现加锁成功但没设过期的窗口
Boolean ok = redisTemplate.opsForValue()
.setIfAbsent(key, value, Duration.ofMillis(expireMs));
return Boolean.TRUE.equals(ok);
}
第三个问题(误删)要靠「value 存唯一标识 + 释放时校验」。但校验和删除必须原子,否则「判断是我的锁」和「删除」之间锁又过期了,还是会误删。用 Lua 脚本保证原子性:
java
private static final String UNLOCK_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
public boolean unlock(String key, String value) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_LUA, Long.class);
Long res = redisTemplate.execute(
script, Collections.singletonList(key), value);
return Long.valueOf(1).equals(res);
}
value 用 UUID + 线程 ID 之类的唯一值。现在锁基本能用了,但还有个绕不开的坑:锁过期时间怎么定?
设短了,业务没跑完锁就没了;设长了,实例崩溃后别人要等很久。这就是「锁续期」问题------手写很难做好,该上轮子了。
生产方案:Redisson
Redisson 把上面这些坑全填了,还自带看门狗(watchdog)自动续期:只要业务没结束,后台线程每隔 1/3 过期时间就把锁续到满,业务跑多久锁就撑多久;一旦持锁进程挂掉,续期停止,锁到期自动释放。
引入依赖:
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.27.2</version>
</dependency>
配置一个客户端:
java
@Configuration
public class RedissonConfig {
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setDatabase(0);
return Redisson.create(config);
}
}
用起来就非常干净了:
java
@Service
public class OrderService {
@Autowired
private RedissonClient redisson;
public void deductStock(long skuId) {
RLock lock = redisson.getLock("stock:lock:" + skuId);
// 不传租约时间 -> 启用看门狗自动续期(默认 30s,每 10s 续一次)
// waitTime=5s:拿不到锁最多等 5 秒
try {
boolean locked = lock.tryLock(5, TimeUnit.SECONDS);
if (!locked) {
throw new RuntimeException("系统繁忙,请重试");
}
// ---- 临界区:真正的扣减库存逻辑 ----
doDeduct(skuId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取锁被中断", e);
} finally {
// isHeldByCurrentThread 防止误释放别的线程/续期后的锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
几个关键点讲清楚:
tryLock(waitTime, unit)不传 leaseTime ,才会启用看门狗。如果你写成tryLock(5, 30, TimeUnit.SECONDS)(带 leaseTime),看门狗不生效,30 秒后锁硬过期,长事务会中招。这是最常见的误用。finally里判断isHeldByCurrentThread():避免锁已因异常提前释放,又 unlock 一次报错。- Redisson 的锁是可重入 的,底层用 Hash 存重入次数,同一线程多次
lock不会自锁死。
一个容易忽略的坑:主从切换丢锁
单机 Redis 用上面的方案已经很稳。但如果是主从架构,存在一个理论风险:客户端在 master 上加锁成功,数据还没同步到 slave,master 就宕机了,slave 提升为新 master------这把锁就凭空消失了,另一个客户端能再次加锁,互斥被打破。
对一致性要求极高的场景(如金融扣款),Redisson 提供 RedLock 思路的联锁,或直接换用 getFairLock 配合多独立 Redis 节点。但 RedLock 本身在业界有争议(Martin Kleppmann 与 antirez 的著名辩论),复杂度也高。多数业务场景下,单机/哨兵 + Redisson 看门狗已经够用,不要为极小概率的边界过度设计。
如果确实要求强一致,更稳妥的其实是用支持共识的存储(如 ZooKeeper、etcd)做锁,而不是硬磕 Redis 的边界。
小结
SETNX手写锁的三大坑:无过期(死锁)、加锁与过期非原子、释放误删别人的锁。- 修正方向:
SET NX PX原子加锁 + value 存唯一标识 + Lua 脚本原子校验删除。 - 锁续期手写很难,生产直接上 Redisson :
tryLock(waitTime)不带 leaseTime 触发看门狗自动续期,业务跑多久锁撑多久,进程挂了自动释放。 finally里必须isHeldByCurrentThread()再unlock;别误用带 leaseTime 的重载把看门狗关掉。- 主从切换丢锁是理论风险,一般业务不必过度设计;强一致场景考虑 etcd/ZooKeeper。
一句话记忆:手写锁重点是「原子」,生产用锁重点是「续期」------前者靠 Lua,后者靠 Redisson 看门狗。