分布式锁的五个版本:从 SETNX 到业务幂等

每一版都修掉上一版的漏洞,但最后一版才触及根本:锁只是优化,幂等才是正确性。附 Redlock 争议与 ZooKeeper 的适用边界。

分布式锁看似简单:加锁、执行、解锁。但真正在线上跑起来,会遇到一连串问题——锁过期了业务还没跑完、解锁时删了别人的锁、Redis 主从切换导致两个客户端同时持锁。

这篇文章从最朴素的 SETNX 讲起,逐步叠加「过期时间 → 唯一标识 → 续期 → 自动续命」,最后讨论「Redlock 到底靠不靠谱」和「什么时候该换 ZooKeeper」。

一、第一版:SETNX

SETNX lock:order:100 1

SETNX = SET if Not eXists。返回 1 表示加锁成功,0 表示已被占用。

问题 1:锁没有过期时间。 业务代码抛异常没走到 DEL,这把锁就永久存在了。上线后第一天就锁死了。

# 修复:加过期时间
SETNX lock:order:100 1
EXPIRE lock:order:100 10

问题 2(更隐蔽):SETNX 和 EXPIRE 不是原子的。 两条命令之间如果客户端崩溃,锁没有过期时间——又锁死了。

二、第二版:SET NX EX

Redis 2.6.12 起支持:

SET lock:order:100 <token> NX EX 10

一条命令完成「不存在则设置 + 设置过期时间」,原子执行。

public boolean tryLock(String key, String token, int ttlSeconds) {
    return Boolean.TRUE.equals(
        redis.opsForValue().setIfAbsent(key, token, Duration.ofSeconds(ttlSeconds))
    );
}

token 是调用方生成的唯一标识(UUID)。这是下一步的关键。

三、第三版:安全释放(Lua)

用 token 判断「这把锁是不是我加的」:

// 错误写法:判断和删除是两条命令,中间可能被插队
if (token.equals(redis.get(key))) {
    redis.del(key);      // ← 此时锁可能已过期并被别人拿走,删了别人的锁
}

正确做法是用 Lua 保证原子性:

if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end
private static final RedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>(
    "if redis.call('get', KEYS[1]) == ARGV[1] then " +
    "  return redis.call('del', KEYS[1]) " +
    "else return 0 end", Long.class);

public boolean unlock(String key, String token) {
    Long r = redis.execute(UNLOCK_SCRIPT, Collections.singletonList(key), token);
    return r != null && r > 0;
}

为什么必须用 Lua? Redis 执行 Lua 脚本期间不会插入其他命令,get + del 成为一个原子操作。这是防「删别人锁」的唯一正确方式。

四、锁过期了业务还没跑完

这是分布式锁最本质的难题。假设 TTL 10 秒,业务跑了 12 秒:

t=0   A 加锁,TTL=10
t=10  锁自动过期
t=11  B 加锁成功,开始执行
t=12  A 执行完,调用 unlock → 删掉了 B 的锁(如果没校验 token 就会删)
      → C 也能加锁 → B 和 C 并发执行

只要锁有 TTL,就一定有「锁失效但业务未完成」的窗口。

三种应对:

方案 A:把 TTL 设得足够长

// 按 P99 耗时的 3 倍设 TTL
int ttl = estimatedP99Seconds * 3;

简单但不可靠——GC 停顿、网络抖动、下游变慢都会突破估算。

更好的做法是「拿不到锁就快速失败」,而不是等很久。 TTL 设长一点(比如 30 秒),但业务超时后主动释放。

方案 B:看门狗自动续期(Redisson 的做法)

Redisson 的 RLock 在加锁后会启动一个后台任务,每 TTL/3 续期一次:

RLock lock = redisson.getLock("lock:order:100");
lock.lock();                 // 不传 leaseTime 就启用看门狗
try {
    doBusiness();            // 跑多久都行,看门狗一直续
} finally {
    lock.unlock();
}

注意:只有不指定 leaseTime 时看门狗才生效。

lock.lock(10, TimeUnit.SECONDS);   // 指定了 leaseTime → 看门狗不启动 → 10 秒后必过期

这是 Redisson 使用中最容易踩的坑。很多人为了「防止死锁」显式传了 leaseTime,结果失去了续期能力。

看门狗的致命缺陷:如果 JVM 长时间 Full GC 或线程被挂起,看门狗也停了,锁照样会过期。所以 Redisson 的方案能覆盖「业务慢」但覆盖不了「进程假死」。

方案 C:业务幂等 + 版本号(根本解法)

不管锁怎么做,业务本身必须是幂等的。锁只是减少并发冲突,不是正确性的唯一保证。

// 用版本号做乐观锁,即使锁失效也不会写脏
int updated = jdbc.update(
    "UPDATE stock SET count = count - 1, version = version + 1 " +
    "WHERE sku_id = ? AND version = ? AND count > 0",
    skuId, expectedVersion);
if (updated == 0) {
    throw new OptimisticLockException("并发冲突,重试");
}

核心认知:分布式锁不能替代数据库的并发控制。 它是性能优化手段(减少无效竞争),不是正确性保障。

五、Redlock:争议与真相

Redis 作者 antirez 提出的 Redlock 算法,目的是解决「单点 Redis 主从切换丢锁」的问题。

主从切换丢锁的场景

t=0   A 在 master 加锁成功
t=1   master 宕机,锁数据还没同步到 slave
t=2   slave 提升为新 master(没有这把锁)
t=3   B 在新 master 加锁成功
      → A 和 B 同时持锁

这是真实存在的风险,尤其在异步复制的 Redis 上。

Redlock 的做法

部署 5 个独立的 Redis 主节点(不是主从,不是 Cluster),客户端:

  1. 记录开始时间 T1
  2. 依次向 5 个节点发送 SET NX EX
  3. 如果在多数派(≥3) 上成功,且总耗时 < TTL,视为加锁成功
  4. 如果失败,向所有节点发送解锁

Martin Kleppmann 的质疑

他在《How to do distributed locking》里指出 Redlock 有三个问题:

1. 依赖时钟。 Redlock 的正确性假设「各节点的时钟漂移有限」。如果某个节点的时钟跳变(NTP 校时、虚拟机迁移),TTL 判断就错了,锁可能提前失效。

2. GC 停顿问题无法解决。 客户端 A 拿到锁后发生 10 秒 Full GC,锁过期,B 拿到锁开始执行,A 恢复后继续执行——两个客户端并发。Redlock 对此无能为力,任何基于超时的锁都有这个问题。

3. 它不能提供「fencing token」。 正确做法是每次加锁返回一个单调递增的序号,下游服务拒绝序号更小的请求:

A 拿到 token=33,GC 停顿
锁过期,B 拿到 token=34,写入成功
A 恢复,带着 token=33 写入 → 存储层发现 33 < 34,拒绝

这才是真正解决问题的方案,而 Redlock 不提供。

结论:什么时候用 Redlock

我的看法和业界主流一致:

  • 不要用 Redlock 做「强一致」的保证。 它不解决问题,只是把单点风险分散了。
  • 如果只是「减少重复执行」(比如定时任务防重、缓存重建互斥),单机 Redis 锁完全够用。
  • 如果业务不能容忍并发执行(比如扣款),用 ZooKeeper/etcd 的临时顺序节点,或者数据库唯一约束。

六、ZooKeeper 方案

ZooKeeper 的锁基于临时顺序节点:

/locks/order-100/
  ├── lock-0000000001   ← 客户端 A
  ├── lock-0000000002   ← 客户端 B
  └── lock-0000000003   ← 客户端 C

客户端创建临时顺序节点,如果自己是最小的那个就拿到锁;否则监听前一个节点的删除事件。

与 Redis 的对比

Redis ZooKeeper
一致性 AP(异步复制,可能丢锁) CP(ZAB 协议,强一致)
锁释放 依赖 TTL,可能提前/延后 会话断开即释放,无需 TTL
性能 高(10 万 QPS 级) 中(1 万 QPS 级)
惊群 需自己处理 只监听前一个节点,无惊群
阻塞等待 需轮询或 Pub/Sub 原生支持 Watch 通知
运维 简单 需 3 节点集群

ZooKeeper 天然不会有「锁失效但业务还在跑」的问题,因为锁的生命周期绑定在会话上——进程死了会话就断,锁立即释放。而 Redis 必须靠 TTL,就有了那个窗口。

代价是性能低一个数量级,且运维复杂。

用 Curator 实现

CuratorFramework client = CuratorFrameworkFactory.newClient(
    "zk1:2181,zk2:2181,zk3:2181", new ExponentialBackoffRetry(1000, 3));
client.start();

InterProcessMutex lock = new InterProcessMutex(client, "/locks/order-100");
try {
    if (lock.acquire(5, TimeUnit.SECONDS)) {      // 最多等 5 秒
        doBusiness();
    } else {
        throw new BizException("获取锁超时");
    }
} finally {
    lock.release();
}

七、选型决策树

需要跨进程互斥?
├─ 否 → 用 JVM 内的锁(synchronized / ReentrantLock)
└─ 是
   ├─ 只是"减少重复执行",能容忍极小概率的并发?
   │  └─ Redis 单机锁(SET NX EX + Lua 释放)✅ 最简单
   ├─ 需要阻塞等待 + 公平 + 强一致?
   │  └─ ZooKeeper / etcd
   ├─ 数据在数据库里,冲突概率不高?
   │  └─ 数据库唯一约束 / 乐观锁(最可靠,无需额外组件)
   └─ 高并发 + 可续期 + 需要重入?
      └─ Redisson(但注意 leaseTime 的坑)

优先顺序建议:数据库唯一约束 > Redis 锁 > ZooKeeper。

数据库方案最可靠(事务保证),只是性能受限于 DB。Redis 方案性能好但有理论缺陷。ZooKeeper 最严谨但成本高。

八、一份生产可用的 Redis 锁实现

如果不想引入 Redisson,这是一个精简但正确的实现:

@Component
public class RedisLock {
    private final StringRedisTemplate redis;
    private static final RedisScript<Long> UNLOCK = new DefaultRedisScript<>(
        "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
        Long.class);

    /** 尝试加锁,成功返回 token,失败返回 null */
    public String tryLock(String key, long ttlSeconds) {
        String token = UUID.randomUUID().toString();
        Boolean ok = redis.opsForValue()
            .setIfAbsent(key, token, Duration.ofSeconds(ttlSeconds));
        return Boolean.TRUE.equals(ok) ? token : null;
    }

    /** 带超时的阻塞式加锁 */
    public String lock(String key, long ttlSeconds, long waitMillis) {
        long deadline = System.currentTimeMillis() + waitMillis;
        while (System.currentTimeMillis() < deadline) {
            String token = tryLock(key, ttlSeconds);
            if (token != null) return token;
            try {
                Thread.sleep(50 + ThreadLocalRandom.current().nextInt(50));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return null;
            }
        }
        return null;
    }

    public boolean unlock(String key, String token) {
        Long r = redis.execute(UNLOCK, Collections.singletonList(key), token);
        return r != null && r > 0;
    }
}

使用:

String token = redisLock.lock("lock:order:" + orderId, 30, 3000);
if (token == null) {
    throw new BizException("操作太频繁,请稍后重试");
}
try {
    doBusiness(orderId);
} finally {
    redisLock.unlock("lock:order:" + orderId, token);
}

Thread.sleep 里加随机抖动是为了避免多个等待者同时醒来形成惊群。

九、几个必须注意的点

1. key 的粒度

"lock:order:100"        ✓ 按订单粒度,并发度高
"lock:order"            ✗ 全局一把锁,完全串行

2. TTL 要大于业务 P99 耗时

监控加锁成功率和锁等待时间。如果经常出现「拿到锁但业务超时」,说明 TTL 太短或者业务有问题。

3. 不要在锁内做远程调用

lock(...)
try {
    httpClient.call();        // 下游超时 5 秒,锁被占用 5 秒
    db.update();
} finally { unlock(); }

远程调用放在锁外,先拿到结果再进锁。

4. 加锁失败的降级策略

不要简单抛异常。要考虑:等待重试、返回友好提示、或者直接放行(如果业务能容忍)。

5. 一定要监控

metrics.counter("lock.acquire", "result", token != null ? "success" : "fail").increment();

加锁失败率突增,说明有长事务或者死锁。

小结

分布式锁的演进路径:

版本 命令 解决了什么 还差什么
v1 SETNX 互斥 死锁(无 TTL)
v2 SET NX EX 死锁 删了别人的锁
v3 + Lua 校验 token 误删 锁提前过期
v4 + 看门狗续期 业务慢 JVM 假死
v5 + 业务幂等 全部 —

最后一步才是根本:不管锁怎么做,业务本身必须幂等。锁是优化,幂等是正确性。

Redlock 不要用于强一致场景。真要强一致,ZooKeeper 或者数据库唯一约束,别在 Redis 上硬撑。

查看「中间件」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

分布式锁的五个版本:从 SETNX 到业务幂等 - 起航社区