Redis 分布式锁

Redis 分布式锁

目录

两个下单请求同时打到后端接口,都查到库存是 100,都判断"库存够,可以扣减",于是各扣了 1 件,写了两次 99 回数据库。实际只卖出了 1 件,库存却只少了 1 件,就发生了超卖事故。问题出在"查库存"和"扣库存"之间有一个时间窗口,两个请求在这个窗口里交错执行了。单机环境下,可以用 synchronizedReentrantLock 解决。但如果服务部署了三台机器,请求可能打到不同的 JVM 上,Java 自带的锁就管不住了。

所以需要一把"跨进程"的锁。这就是分布式锁。

分布式锁是什么

分布式锁就是在分布式环境下保证同一时刻只有一个进程能执行某段代码的机制。

打个比方,公司只有一把会议室钥匙,谁拿到钥匙谁用会议室,用完就放回前台,下一个人再去拿。这样不管公司有多少员工、有多少工位,同一时间会议室里只有一个人。分布式锁就是这把"钥匙",Redis 就是那个"前台"。

为什么是 Redis

Redis为什么可以做分布式锁?

速度快。 Redis 的操作是内存级别的,加锁解锁的耗时在亚毫秒级。

简单。 一个 SET 命令就能加锁,一个 DEL 命令就能解锁。对后端初学者来说,Redis 的学习成本最低。

生态成熟。 Redisson 这类客户端库把分布式锁的各种边界情况都封装好了,开箱即用。

最简单的实现:SETNX

Redis 提供了一个命令:SETNX(SET if Not eXists)。只有当 key 不存在时才能设置成功,否则返回 0。所以天然适合做锁。

bash 复制代码
# 尝试加锁
SETNX lock:order:1001 1
# 返回 1:成功,你拿到了锁
# 返回 0:失败,锁被别人占着

用 Java 写出来:

java 复制代码
// 加锁
Boolean locked = redisTemplate.opsForValue()
    .setIfAbsent("lock:order:1001", "1");

if (Boolean.TRUE.equals(locked)) {
    try {
        // 拿到锁,执行业务逻辑
        doBusiness();
    } finally {
        // 释放锁
        redisTemplate.delete("lock:order:1001");
    }
} else {
    // 没拿到,返回提示或重试
    return "系统繁忙,请稍后再试";
}

setnx的使用非常简单,但是这段代码至少还有三个致命问题。

加个过期时间

第一个问题:死锁。

如果拿到锁的那台机器在 doBusiness() 里挂了,进程被 kill或者服务器断电、发生了 OOM,finally 里的 delete 永远不会执行。这把锁就永远留在 Redis 里了。后续所有请求都拿不到锁,业务直接瘫痪。

解决办法:给锁加一个过期时间,让它自动释放。

bash 复制代码
# 加锁 + 设置过期时间(一条命令)
SET lock:order:1001 1 NX EX 30

NX 表示"不存在才设置"(等价于 SETNX),EX 30 表示 30 秒后自动过期。一条命令搞定加锁和过期,是原子操作。

Java 代码:

java 复制代码
Boolean locked = redisTemplate.opsForValue()
    .setIfAbsent("lock:order:1001", "1", 30, TimeUnit.SECONDS);

加了过期时间,即使机器挂了,30 秒后锁也会自动释放,不会死锁。

SETNX + 过期时间的原子性问题

但是刚才说的"一条命令搞定"是有前提的。如果用的不是 SET key value NX EX 这种写法,而是分开写:

java 复制代码
// 错误示范:两步操作不是原子的
Boolean locked = redisTemplate.opsForValue()
    .setIfAbsent("lock:order:1001", "1");  // 第一步:加锁
if (Boolean.TRUE.equals(locked)) {
    redisTemplate.expire("lock:order:1001", 30, TimeUnit.SECONDS);  // 第二步:设过期
}

这两步之间如果出了问题,比如第一步成功了,第二步执行前机器挂了,那么锁同样变成永不过期的了。

所以一定要用一条命令同时完成加锁和设置过期时间。 SET key value NX EX 30 是原子的,要么全成功,要么全失败,不存在中间状态。

释放锁:不能删别人的锁

过期时间解决了死锁,但引入了一个新问题。

场景:请求 A 拿到锁,设了 30 秒过期。但业务执行得慢,花了 35 秒。在第 30 秒时锁自动过期了,请求 B 拿到了锁开始执行。第 35 秒时,请求 A 执行完了,调用 DELETE lock:order:1001,把请求 B 的锁给删了。

请求 C 此时趁虚而入,拿到了锁。请求 B 和请求 C 就在并发执行了。分布式锁的意义荡然无存。

复制代码
时间线:
  0s    请求 A 加锁成功
  30s   锁过期自动释放
  30s   请求 B 加锁成功
  35s   请求 A 执行完,DELETE 锁(删的是 B 的锁!)
  35s   请求 C 加锁成功
        → B 和 C 同时持有锁,出问题了

问题的本质是:请求 A 不知道自己持有的锁已经过期了,它删锁的时候不检查这把锁还是不是自己的。

解决办法:加锁时存一个唯一标识,释放锁时先验证标识再删除。

java 复制代码
// 加锁时,value 存一个唯一标识(比如 UUID)
String requestId = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue()
    .setIfAbsent("lock:order:1001", requestId, 30, TimeUnit.SECONDS);

if (Boolean.TRUE.equals(locked)) {
    try {
        doBusiness();
    } finally {
        // 释放锁前,先检查是不是自己的锁
        String currentValue = redisTemplate.opsForValue()
            .get("lock:order:1001");
        if (requestId.equals(currentValue)) {
            redisTemplate.delete("lock:order:1001");
        }
    }
}

Redisson:生产级方案

自己手写分布式锁,要考虑加锁、解锁、可重入、过期时间等等,边界情况很多。生产环境推荐直接用 Redisson,它把这些都封装好了。

Redisson 的分布式锁有几个核心设计值得了解:

1. 看门狗机制(Watchdog)

设了 30 秒过期,但业务执行了 40 秒怎么办?Redisson 有一个后台线程,每隔 10 秒检查一次,如果锁还被持有,就自动把过期时间续到 30 秒。只要业务还在执行,锁就不会过期;业务执行完释放锁后,续期自动停止。

复制代码
加锁(30s)
  │
  ├── 10s 后:看门狗续期 → 30s
  ├── 20s 后:看门狗续期 → 30s
  ├── 30s 后:看门狗续期 → 30s
  │
  业务完成,释放锁,看门狗停止

2. 发布订阅通知

当锁被释放时,Redisson 不是让所有等待者轮询尝试加锁,而是通过 Redis 的发布订阅机制通知等待者。只有被通知的客户端才会去尝试加锁,减少了无效竞争。

3. 可重入

用 Redisson 加锁只需要几行代码:

java 复制代码
@Autowired
private RedissonClient redisson;

public void deductStock(String orderId) {
    RLock lock = redisson.getLock("lock:order:" + orderId);
    try {
        // 尝试加锁,最多等待 10 秒,自动续期
        boolean locked = lock.tryLock(10, TimeUnit.SECONDS);
        if (locked) {
            doBusiness();
        }
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

小结

Redis的SETNX 解决了基本互斥,过期时间解决了死锁,UUID 标识解决了误删别人的锁,Redisson 的看门狗解决了锁提前过期的问题。

生产环境中直接用 Redisson 就好,它帮开发者处理了绝大多数边界情况。但也不要把它当黑盒,只有理解了背后的原理,遇到问题时我们才能判断是锁本身的问题,还是业务逻辑的问题。