Featured image of post Redis 分布式锁演进:六个版本的取舍与陷阱

Redis 分布式锁演进:六个版本的取舍与陷阱

从最朴素的 SETNX 一路演进到 Redlock 与 Redisson,把分布式锁里那些『看似能用其实不行』的坑一次讲透

为什么需要分布式锁

单机环境下用 synchronizedReentrantLock 就能搞定的事,一旦上了微服务、跑在多台机器上,就立刻变成另一个量级的问题。

举几个最典型的场景:

  • 下单扣库存:同一个 SKU 在多实例同时被下单,怎么保证库存不卖超?
  • 定时任务防重:每个微服务实例都跑了定时调度,怎么让"全局只有一个执行"?
  • 接口幂等:用户连点两次,怎么保证只处理一次?
  • 资源独占:同一个用户、同一个文件,同时只能由一个请求修改?

进程内的锁帮不了你,因为它们彼此根本不知道对方存在。要在跨进程、跨机器的环境里"约定一个谁能进谁不能进",就需要一个所有节点都信任的第三方——Redis 是最常见的选择。

但 Redis 分布式锁这件事,看着简单,做对极难。下面我们从最朴素的写法一路演进,把每个版本里"看似能用其实不行"的坑摊开。


姿势一:SETNX + EXPIRE(错误示范)

刚接触分布式锁的人,最常写出来的版本是这样:

1
2
3
4
5
6
7
8
9
// 反面教材
public boolean tryLock(String key, String value, long expireSeconds) {
    Long result = jedis.setnx(key, value);   // 只在 key 不存在时才设置
    if (result == 1) {
        jedis.expire(key, expireSeconds);    // 设置过期时间
        return true;
    }
    return false;
}

逻辑看起来很自然——抢到了就设过期时间防止死锁。但这里藏着一个致命缺陷

SETNXEXPIRE 是两条命令,不是原子操作。

如果在 SETNX 成功之后、EXPIRE 之前进程崩了、网络抖了、Redis 主从切换了,那这把锁就永远没有过期时间——所有后续请求都会被它挡住,直到运维手动删 key。


姿势二:SET key value NX EX 一条命令搞定

Redis 2.6.12 之后,SET 命令支持了原子的 NX + EX 选项,这就是真正可用的"上锁"操作:

1
2
3
4
public boolean tryLock(String key, String value, long expireSeconds) {
    String result = jedis.set(key, value, "NX", "EX", expireSeconds);
    return "OK".equals(result);
}

NX 保证只在 key 不存在时才生效,EX 同时设置过期时间,整个操作是原子的,没有了"SETNX 后 EXPIRE 前"那个空窗。

但这只是把"上锁"做对了,“解锁"还藏着一个更隐蔽的坑


姿势三:解锁的"误删别人锁"问题

一个看似无害的解锁实现:

1
2
3
4
// 反面教材
public void unlock(String key) {
    jedis.del(key);
}

设想一个时序:

时刻客户端 A客户端 B
t1加锁成功,过期时间 10s
t2业务执行卡住了 12s(GC、网络等)
t3锁已过期,B 加锁成功
t4A 醒来,调用 unlock 直接 DEL
t5B 还在执行,锁却没了

A 删掉了 B 的锁。后续的 C 又能进来,锁的互斥语义彻底失效。

正确做法:解锁前先验证 value

加锁时把 value 设成一个唯一标识(比如 UUID),解锁时验证 value 是自己的才删:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
String requestId = UUID.randomUUID().toString();
jedis.set(key, requestId, "NX", "EX", 10);

// 解锁
String script =
    "if redis.call('get', KEYS[1]) == ARGV[1] then " +
    "  return redis.call('del', KEYS[1]) " +
    "else " +
    "  return 0 " +
    "end";
jedis.eval(script, Collections.singletonList(key), Collections.singletonList(requestId));

注意:判断 + 删除必须用 Lua 脚本保证原子性。如果分两步——先 GET 再 DEL,又会回到"判断后 DEL 前锁过期了"的老问题。


姿势四:业务执行时间不可控——锁自动续期(看门狗)

即便解锁做对了,“业务执行时间超过锁过期时间"本身就是个隐患。永远把过期时间设很长?万一这个客户端进程真的死了,锁就长时间得不到释放。

更优雅的做法是自动续期:客户端在持有锁期间,启动一个后台线程定时把锁的过期时间往后顺。

伪代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 上锁成功后,启动一个守护线程
ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor();
watchdog.scheduleAtFixedRate(() -> {
    // 每 expireSeconds/3 触发一次续期
    String script =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "  return redis.call('expire', KEYS[1], ARGV[2]) " +
        "else return 0 end";
    jedis.eval(script, ..., requestId, expireSeconds);
}, expireSeconds / 3, expireSeconds / 3, TimeUnit.SECONDS);

业务结束后停掉 watchdog 并解锁。

这套机制如果自己手写很容易出错,所以——绝大多数生产项目直接用 Redisson


姿势五:Redisson——工业级实现

Redisson 是 Redis 的 Java 客户端,把分布式锁封装得相当成熟,自带可重入、自动续期、公平锁、读写锁等高级特性。

1
2
3
4
5
6
7
8
9
RedissonClient redisson = Redisson.create(config);
RLock lock = redisson.getLock("order:1001");

try {
    lock.lock();   // 默认 30s 过期,看门狗每 10s 自动续期一次
    // 业务逻辑
} finally {
    lock.unlock();
}

Redisson 内部干了什么:

  • 加锁用 Lua 脚本,原子完成"判断 + 设置 + 续期间隔记录”
  • 可重入通过 Hash 结构记录"持有者 + 重入次数”
  • 看门狗每 1/3 过期时间触发一次续期,业务不结束锁不释放
  • 解锁也用 Lua 脚本,安全地把重入计数减一直到删除

业务侧能做到"加锁就像 synchronized 一样自然",这正是它的价值。

⚠️ 看门狗的关键陷阱:看门狗只在你不显式指定 leaseTime 时才会启用——也就是 lock.lock() / lock.lock(-1, ...) / tryLock(waitTime, unit)。一旦你调用 lock(leaseTime, unit)tryLock(waitTime, leaseTime, unit) 显式传了 leaseTime看门狗会被关闭,锁到期后直接释放,不再续期。这是初学者最容易踩的坑——看着代码"用了 Redisson 应该有看门狗",实际锁早就过期了。


姿势六:Redlock 与"红锁争议"

前面所有方案都建立在一个假设上——Redis 单机或主从是可靠的。但现实中有两个隐患:

  1. 单机 Redis 是单点,挂了所有锁都丢
  2. Redis 主从有异步复制延迟。主刚加完锁还没复制到从,主挂了,从被选为新主,新主上没有这把锁——新的客户端就能再加一次

Redis 作者 antirez 为此提出了 Redlock 算法

部署 N 个互相独立的 Redis 实例(比如 5 个),客户端尝试在这 N 个实例上依次加锁。只有在 大多数实例(≥ N/2 + 1) 都加锁成功,且总耗时小于锁过期时间,才认为加锁成功。

Redisson 也实现了 Redlock:RedissonRedLock

Martin Kleppmann 的反驳

不过 Redlock 在学界一直有争议。著名分布式系统专家 Martin Kleppmann 写过一篇 “How to do distributed locking”,指出 Redlock 在以下场景下并不安全:

  • 客户端 GC 暂停——加锁后业务卡了几秒,锁早过期了,业务以为自己还持有锁继续操作
  • 时钟漂移——节点之间的系统时钟不一致,过期时间判断会失准

他的结论是:对正确性要求极高的场景,应该用 ZooKeeper / etcd 这类基于一致性协议的系统;Redis 锁更适合"性能优先、偶尔失误可接受"的场景。

工程上多数业务场景用 Redisson 单实例锁就足够——性能高、用法简单;只有对正确性极敏感的少数场景才上 ZooKeeper。


各姿势对比

姿势安全性复杂度推荐度
SETNX + EXPIRE别用
SET NX EX学习用,生产不够
加 value 校验解锁自己造轮子的最低底线
加 Watchdog 续期自己造轮子的上限
Redisson 单实例绝大多数业务的最佳选择
Redlock / RedissonRed◯+可用但有争议,慎用
ZooKeeper / etcd 锁中高强一致性要求场景

实战中的几条建议

  1. 永远用 tryLock 带超时,不要 lock() 死等
  2. 加锁粒度尽量细——别把整个订单创建过程锁起来,只锁库存扣减那一段
  3. 业务和锁解耦——业务方法里只关心 lock.tryLock() / lock.unlock(),不要散落 Redis 命令
  4. 过期时间 ≥ 业务最长执行时间 × 2,给极端情况留缓冲,再配合 watchdog
  5. 解锁放 finally——别让一段异常跳过解锁
  6. 日志里打出 requestId——锁出问题排查链路全靠它
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 生产推荐写法(依赖看门狗自动续期 → 不传 leaseTime)
RLock lock = redisson.getLock("stock:" + skuId);
boolean locked = false;
try {
    // 等 3 秒;获取后由看门狗每 10 秒续期一次,业务不结束锁不释放
    locked = lock.tryLock(3, TimeUnit.SECONDS);
    if (!locked) {
        throw new BusinessException("操作过于频繁,请稍后再试");
    }
    // 业务逻辑
} finally {
    if (locked && lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

isHeldByCurrentThread() 这个判断很重要——避免在某些异常路径下解了不属于自己的锁。

如果你确实需要"超时强制释放"作为兜底(防止业务永远卡死把锁吃住),就显式传 leaseTimelock.tryLock(3, 30, TimeUnit.SECONDS)。但要清楚这种写法没有看门狗——leaseTime 必须 ≥ 业务最长执行时间 × 2,否则中途锁掉的风险会回来。


小结

Redis 分布式锁这件事,入门一行代码,做对几十行细节

把这条演进路径压缩成一句话:

从 SETNX 到 Redisson,每一步演进都是在堵一个『看似能用其实不行』的坑。

工程上的选择反而很简单:

  • 新项目:直接上 Redisson,别造轮子
  • 极致正确性:上 ZooKeeper / etcd
  • 学习理解原理:把上面六种姿势从头写一遍

理解了这些演进,下次再有人问你"Redis 锁怎么用",你就不会只回一句 SET NX EX 了——你会先反问:“你的业务能容忍偶尔失效吗?锁持有时间可控吗?需要可重入吗?”

使用 Hugo 构建
主题 StackJimmy 设计