Redis 分布式锁的 7 个坑

AI 概述
本文剖析 Redis 分布式锁在生产中常见 7 大陷阱,区分效率类防重与正确性类强互斥场景。讲解 SETNX+EXPIRE 死锁、无 UUID 误删锁、锁过期业务未跑完、看门狗失效、主从丢锁、Redlock 部署误区等问题,介绍 fencing token 兜底思路。给出选型方案:防重可用 Redis 锁;强一致性场景需搭配数据库乐观锁,Redlock 落地条件严苛,多数业务无需部署。
目录
文章目录隐藏
  1. 7 个坑之前的共识:你要锁的是什么?
  2. 坑 1:SETNX + EXPIRE 是两步,中间崩溃 = 死锁
  3. 坑 2:不带 UUID 的 DEL,误删别人的锁
  4. 坑 3:锁过期但业务没跑完 — GC 暂停 / 网络抽风
  5. 坑 4:看门狗续期不是神药 — 主线程卡,续期也卡
  6. 坑 5:主从切换丢锁 — 异步复制的天然缺陷
  7. 坑 6:Redlock 的 5 独立节点假设,几乎没人真正满足
  8. 坑 7:时钟 + GC 同时发生时,Redlock 也会丢 — fencing token 才是终极防线
  9. 三方案决策表:Redis vs ZooKeeper vs etcd
  10. 我的判断:什么时候用什么
  11. 常见问题

2026 年的今天,我在 GitHub 上搜「Redis 分布式锁 setnx」,还能翻出一堆代码片段,其中九成跑在生产环境里依然是错的:要么忘了设过期时间,要么不带 UUID,要么把 Redlock 部署在同一台 Redis 上假装高可用。

今天把 11 年做电商与支付后台踩过的、读论文补过的、在团队 code review 里抓过的 7 个坑一次说清楚——同时引用了 Martin Kleppmann 亲手画的两张原理图(原文以 CC BY 3.0 发布),它们恰好画的就是本文最容易被讲糊的两个场景。

Redis 锁很简单:一行 SET 命令就能拿,但生产里它非常复杂。Martin Kleppmann 在 2016 年的《How to do distributed locking》里把这件事拆成了「效率」和「正确性」两类目标——前者防重复,后者防出错。大部分工程师把两者混在一起谈,所以越聊越乱。要避坑,先搞清楚你要锁住的到底是哪一种。

fig01 - Redis 分布式锁完整生命周期:同一把锁在 5 个阶段上的 7 种失效姿势
fig01 – Redis 分布式锁完整生命周期:同一把锁在 5 个阶段上的 7 种失效姿势

7 个坑之前的共识:你要锁的是什么?

Redis 锁能解决两类问题,优先级完全不同:

  • 效率类(efficiency):避免重复做同一件事。比如每天 8 点给所有会员发「昨日账单」邮件,如果因为分布式部署多跑了一遍,后果只是用户收到两封一样的邮件,业务损失可以忽略。这种场景,单节点 Redis 锁就够了,Redisson 默认实现已经非常成熟。
  • 正确性类(correctness):锁住共享资源,要求任何时刻只有一个写入者。比如库存扣减、银行账户扣款,如果锁失效一次,数据直接损坏。这种场景,Redis 锁只能做辅助,真正能兜底的是资源端的乐观锁(UPDATE … WHERE version = $old)。

下面的 7 个坑,1–4 偏向效率场景(影响可用性),5–7 偏向正确性场景(影响数据一致性)。

一句话:你拿 Redis 锁「防重」,可以;你拿 Redis 锁「严格互斥」,需要谨慎,且必须配合资源端的版本控制。

坑 1:SETNX + EXPIRE 是两步,中间崩溃 = 死锁

很多人第一次写分布式锁的代码长这样:

// 这段代码是错的,生产不能用
jedis.setnx("lock:order:42", "1");
jedis.expire("lock:order:42", 30);

两行命令,中间夹着网络传输。如果 setnx 成功后,客户端进程崩溃、网络断开、JVM Full GC 卡住几十秒,expire 没机会执行,这个 key 就会永久留在 Redis 里,直到你手动 DEL。后续所有请求 setnx 都返回 0,业务直接卡死。

Redis 官方早在 2013 年发布的 2.6.12 版本里就加了原子版的 SET 命令,一条命令搞定两件事:

SET lock:order:42 <uuid> NX PX 30000
  • NX:只有 key 不存在时才设值,实现互斥
  • PX 30000:同时设置 30000 毫秒后过期,实现死锁防护
  • <uuid>:每个客户端唯一标识,详见坑 2

这是单 Redis 节点锁的最小可行实现。把它包成 Lua 让 Redisson 用也行,自己写也行。**任何还在用 SETNX/SETNX + EXPIRE 的项目,都应该立刻迁移到 SET NX PX**。

顺带说一个最容易被忽略的细节:<uuid> 这个值不是随手填的。Redis 官方文档写得很明确——20 字节的 /dev/urandom 随机串。为什么强调长度?因为这个 value 是「所有权凭证」,太短、可预测,就可能被另一个客户端猜中并冒充持锁者去删锁。工程上 UUID v4(122 bit 随机)或者 clientId + threadId 已经够用,但绝对不要用 “1”、本机 IP、秒级时间戳这类可枚举的值。

看到 setnx 这个命令当作 deprecated 处理:它的全部价值已被 SET … NX … PX 取代。

坑 2:不带 UUID 的 DEL,误删别人的锁

上一段代码里 SET 命令的值是 <uuid>,意思是每次加锁写一个唯一标识——比如 UUID v4 或者 clientId + threadId 拼出来的字符串。释放的时候,必须先校验,再删除:

-- 经典释放脚本,Redis 官方 distributed-locks 文档原版
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

2026 年更新:上面这段 Lua 是「经典解法」。Redis 8.4 已经把这个动作变成一条原生命令——DELEX key IFEQ my_random_value,原子比较并删除,语义与脚本完全等价,却省掉了 EVAL 的开销,也省掉了「脚本要自己维护、自己测」的负担。官方 distributed-locks 文档如今主推的就是它,Lua 退居「旧版本兼容」。但有个现实约束:Redis Software 与 Redis Cloud 的标准版目前尚未支持 DELEX(Active-Active 同样不支持)。也就是说你手上托管的 Redis 大概率还停在 6.x / 7.x,Lua 版本仍然是必须掌握的。两个都记住,按实例版本二选一。

为什么不能先 GET 再 DEL?因为它俩不是原子的。 时间线是这样的:

时刻 客户端 A 客户端 B Redis 中的 lock
T1 SET lock uuid-A NX PX 30000 成功 uuid-A,剩余 30s
T2 业务卡住 同上
T3 SET lock uuid-B NX PX 30000 失败,因 key 存在 uuid-A,剩余 25s
T4 锁过期 已无
T5 SET lock uuid-B NX PX 30000 成功 uuid-B
T6 A 业务恢复,执行 DEL lock uuid-B 被 A 误删!
T7 业务执行中,以为持锁 实际上锁已被 A 删,B 在「裸奔」

你看到 T6 这一刀了:A 的 DEL 是无差别的删,没问它是不是自己的锁。如果 A 的业务恰好在 T6 这一秒踩到 B 已经拿到锁的资源,直接数据损坏。

解决办法就是上面那段 Lua:GET 一下确认是自己的(uuid-A),才 DEL。否则什么都不做,让 B 自己处理过期和续期。

Redisson 把这段 Lua、UUID 的生成、Hash 结构(可重入用)、看门狗定时器封装成了 RLock 接口,默认 lock.lock() 就已经是「UUID + Lua + 看门狗」三件套。不要自己造轮子,直接用 RLock。

坑 3:锁过期但业务没跑完 — GC 暂停 / 网络抽风

假设你「细心」按坑 1+2 写了 SET NX PX 30000,每把锁 30 秒。然后你的业务代码是这样:

RLock lock = redisson.getLock("order:42");
lock.lock();  // 默认 30s 看门狗锁
try {
    orderService.deductStock(orderId);  // 业务跑 35s
} finally {
    lock.unlock();
}

看起来没问题。但有一天,这个 35 秒的 deductStock 突然跑到了 35 秒外——因为里面有一个远程 RPC 慢了 8 秒,中间又有一次 4 秒的 Full GC。整体 42 秒才返回。这时候 Redis 上的锁已经 30 秒过期,被另一个实例的客户端拿走了。原始客户端恢复后,以为锁还在,继续 deductStock,但同一时刻另一个客户端也在做同一件事。

这是 Martin Kleppmann 在 2016 年那篇《How to do distributed locking》里反复强调的核心问题。他不点名地指出,这类问题在任何带过期时间的分布式锁里都存在——不管你用 Redis、ZooKeeper、etcd 还是 Consul。Antirez(Redis 作者)当年写了《Is Redlock safe?》回应,也承认这一点,只是强调「工程上能管得过来」。

Martin 原文里还给了个更狠的版本:

客户端 1 向 5 个 Redis 节点请求锁。就在 Redis 的响应飞回客户端 1 的路上,客户端 1 进入了 stop-the-world GC。等 GC 结束、客户端 1 恢复,它会从内核的网络缓冲区里读到那几个「加锁成功」的响应——它完全不知道,在自己暂停的这段时间里,所有节点上的锁都已经过期,并被客户端 2 重新拿走了。

这个场景用图看最清楚:

Martin Kleppmann 原始原理图:客户端 1 因 GC 暂停导致租约过期,客户端 2 接管锁并写入,客户端 1 恢复后同时写入,数据损坏
Martin Kleppmann 原始原理图:客户端 1 因 GC 暂停导致租约过期,客户端 2 接管锁并写入,客户端 1 恢复后同时写入,数据损坏

从上往下读:Client 1 拿到租约(ok),随即进入 stop-the-world GC 暂停(灰色条);暂停期间租约过期,Client 2 拿到租约并写入 Storage;Client 1 从 GC 里醒来,以为自己还持锁,也去写同一份数据——右下角那个爆炸符号,就是数据损坏。图源:Martin Kleppmann《How to do distributed locking》(martin.kleppmann.com,以 CC BY 3.0 发布)。

注意最后一步的细节:Client 1 不是「忘了检查锁」,而是它拿到的所有本地证据都显示锁还在——只是这些证据在它暂停的那一刻就已经过期了。所以「写入之前再查一次锁」根本补不上这个洞,因为检查动作本身也可能被下一次 GC 打断。

对纯效率类场景,锁过期引发的最坏后果是「多做了一次重复工作」(配合业务幂等就没事)。对正确性场景,这就是数据损坏的源头,处理方式见坑 7。

锁的 TTL 是「业务最坏耗时 × 1.5–2 倍」,但这只是让坑 3 概率变低,不能消除。

坑 4:看门狗续期不是神药 — 主线程卡,续期也卡

Redisson 的看门狗(WatchDog)是这个问题最常用的补丁。但大多数人只记得「默认 30 秒、每 10 秒续一次」,不清楚它到底怎么跑、什么时候不跑。把调用链拆成四步看,边界就清楚了。

第一步:先决定要不要启动看门狗。 只有「你没告诉 Redisson 这把锁要持有多久」时它才启动:

protected Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
    Long ttl = null;
    if (leaseTime != -1) {
        // 你显式传了 leaseTime —— 看门狗不介入,到期就释放
        ttl = tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    } else {
        // 没传 —— 拿 lockWatchdogTimeout 当初始 TTL,并注册续期任务
        ttl = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg()
                .getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
        scheduleExpirationRenewal(threadId);
    }
    return ttl;
}

第二步:把「这把锁需要续期」登记进全局表。 注意它是按「锁名」注册的,用 putIfAbsent 保证同一把锁只挂一个续期任务:

private void scheduleExpirationRenewal(long threadId) {
    ExpirationEntry entry = new ExpirationEntry();
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    if (oldEntry != null) {
        oldEntry.addThreadId(threadId);   // 可重入:同名锁复用同一个续期条目
    } else {
        entry.addThreadId(threadId);
        renewExpiration();                // 首次注册,开始排程
    }
}

第三步:排一个「每隔 1/3 TTL 执行一次」的定时任务。 定时器用的是 Netty 的 HashedWheelTimer(时间轮):

private void renewExpiration() {
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) { return; }
    Timeout task = getServiceManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) {
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) { return; }
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) { return; }
            CompletionStage<Boolean> future = renewExpirationAsync(threadId);
            future.whenComplete((res, e) -> {
                if (res) {
                    renewExpiration();                  // 续期成功,递归排下一次
                } else {
                    cancelExpirationRenewal(null);      // 锁已不在,停止续期
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);   // 默认 30/3 = 10 秒
    ee.setTimeout(task);
}

第四步:真正续期的那段 Lua。 这段脚本解释了为什么 Redisson 的锁值不是一个字符串,而是一个 Hash:

protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +   // 这把锁还是我的吗?
        "    redis.call('pexpire', KEYS[1], ARGV[1]); " +              // 是的话,重置为 30 秒
        "    return 1; " +
        "end; " +
        "return 0;",
        Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

hexists 查的是 {uuid:threadId:count} 这个 Hash 结构里的字段——续期之前先验证「这把锁是不是当前线程的」,别人的锁不会被误续;而 count 这个字段顺带把可重入也实现了。

关键参数(Config.java 源码注释原话):

Default is 30000 milliseconds

也就是 lockWatchdogTimeout = 30 * 1000:每 10 秒把 key 的 PEXPIRE 重置回 30 秒。只要 JVM 没死、续期任务能被时间轮轮到,锁就一直续下去。

现在看真正的问题。这个定时器跑在 Netty 的 HashedWheelTimer(时间轮)上,而时间轮默认与业务共享同一个 EventLoop 线程。如果你 lock.lock() 之后调用了一个阻塞 25 秒的同步 RPC(老的 HttpClient、老的 JDBC、老的 SOAP,随便哪个),EventLoop 就被占住了,看门狗的 TimerTask 排在那儿也执行不了。10 秒的续期点错过,30 秒后锁过期,别人拿走——坑 3 的剧本原样再演一遍。

更隐蔽的变种是主线程 GC 暂停(G1/CMS/ZGC 都有 STW 阶段),暂停超过 10 秒,后果完全相同。看门狗救不了业务线程,它只能证明「这个 JVM 还活着」——这两件事差得很远。

还有一个容易被搞反的参数:lockWatchdogTimeout。它既是锁的初始 TTL,也是每次续期重置的目标值,同时决定了续期频率(它的 1/3)。把它调大有得有失——好处是续期偶尔晚一拍不容易致命,坏处是客户端真的崩溃之后,锁要等更久才会被别人拿到。默认 30 秒是这两者之间的折中,除非有明确的理由,别动它。

正确做法:

  1. 业务里禁止阻塞调用——所有远程调用必须异步(CompletableFuture / Reactor / Coroutine)。
  2. 锁的 TTL 留够安全余量——业务 P99 耗时 × 2,这是硬指标。
  3. 核心业务加幂等——锁失效时的第二道防线是「资源端发现 version 冲突就拒绝」,不是「锁服务再撑一次」。

看门狗是「客户端还没挂」的证明,不是「业务还在正常跑」的证明。这两个是完全不同的判断。

坑 5:主从切换丢锁 — 异步复制的天然缺陷

坑 1–4 都在单 Redis 实例上讨论。但如果你的部署不是单实例,而是主从(Replication)、Sentinel 或 Cluster,异步复制会引入一个新问题:

时刻 事件 master 上的 lock slave 上的 lock
T1 客户端 A 在 master SET lock uuid-A NX PX 成功 uuid-A 尚未同步
T2 master 宕机,slave 升级为新 master
T3 客户端 B 在新 master SET lock uuid-B NX PX 成功 uuid-B
T4 客户端 A 醒来,以为锁还在(它只跟 master 握手过) uuid-B,互斥已破坏

这是 Redis 主从架构的固有缺陷——master 写成功了,slave 还没收到,master 挂了就丢了。Antirez 推出 Redlock 的根本动机就是解决它:用 5 个完全独立的 Redis 实例,过半写入成功才算拿到锁,这样丢一个 master、两个 master 都行,只要还有 ≥3 个节点承认就行。

听起来很美好,问题是:绝大多数团队的「Redis 主从」部署都在同一个机房、同一个机架、同一个 ToR 交换机、同一个供电回路,TrueTime 不存在,NTP 同源……也就是说,这 5 个节点在事故域上并不独立。它们同时故障、同时不可用的概率远高于 Redlock 论文里假设的「独立节点」。

「线上 Redis 是 Cluster 模式,Master × 3」 ≠ 「Redlock 的 5 独立节点」。前者是一份数据按 slot 切片的多主,后者是同一份 key 多副本的多写。这是两个完全不同的设计目标。

既然丢锁的根因是「异步复制 + 写入成功不等待副本」,那能不能让 master 必须等到副本确认才算写成功?可以——用 min-replicas-to-write:

# 至少 1 个副本确认,且副本延迟不超过 10 秒,master 才接受写
min-replicas-to-write 1
min-replicas-max-lag 10

打开之后,master 在「没有健康副本」时会直接拒绝写入——锁根本加不上,而不是「加上了但可能丢」。代价是可用性:副本全挂(或全部延迟超阈值)时,这个实例会退化成拒绝写。这就是典型的 CAP 取舍,你要 C 就得让出 A。实践建议:对「跑分布式锁的那个实例」开这两个参数,对纯缓存实例保持关闭。

坑 6:Redlock 的 5 独立节点假设,几乎没人真正满足

退一步,假设你真的部署了 5 个机房、5 个独立 NTP 时钟、5 个独立电源的 Redis 实例,都同步写入同一把锁的 key。Redlock 算法跑起来是这样:

T1 = now()
for node in [R1, R2, R3, R4, R5]:
    if node.SET(lock, uuid, NX, PX, timeout=50ms):
        success += 1
T2 = now()
if success >= 3 and (T2 - T1) < lock_ttl:
    # 拿到锁
else:
    # 在所有节点上释放可能的部分锁

过半数节点 + 总耗时小于 TTL 才行。5 节点、50ms 超时、TTL=30s, 看起来很严格的判断条件。

但是有两个隐藏假设,Martin 已经挑明过(同 F3):

  1. 「N 个 Redis 节点本地时钟同步到毫秒级」——任何节点时钟向前跳变(管理员手调、NTP 校正),导致那个节点认为锁已经过期,但其他节点还不知道,过半门槛被突破。
  2. 「客户端没有超过 TTL 的停顿」——Full GC、SIGSTOP、慢 IO、调度抢占,任一发生,客户端在恢复前还以为自己持锁,但服务端早已把锁给了别人。

这是 Redlock 安全性的两个致命点。前者属于运维,后者属于物理——Martin 的原文大意是:除非你能给进程暂停一个严格上界,否则这个洞没法从算法层面消除。

Antirez 在回应文章《Is Redlock safe?》里的辩护,比大多数人转述的要认真得多。他不是撂一句「工程上能管」就完事,而是逐条回应:

  • 网络延迟:他认为 Redlock 的算法步骤本身就绕开了无界延迟——「取时间 T1 → 依次加锁 → 再取一次时间 T2 → 检查是否超时」,所以「不管中间加多少网络延迟,只要总耗时超过 TTL,锁就会被判定为无效」。延迟只可能发生在 T2 之后,那就退回到「客户端暂停」这个共同问题了。
  • 时钟:他明确承认 Redis 应该改用单调时钟 API,并当场承诺「I’ll implement this in the next weeks」(写于 2016 年 7 月)。同时他强调 Redlock 需要的只是半同步模型——各节点「以大致相同的速度计时」就够,比如「计时 5 秒,误差不超过 10%」,并不需要绝对时钟的误差上界。
  • 进程暂停:他指出「获取锁过程中的暂停不影响算法正确性」,真正致命的是「拿到锁之后的暂停」——而这一点 ZooKeeper、etcd 的锁同样躲不掉。

这三条在技术上站得住。但把时间拉到今天,有两件事必须单独拎出来说。

第一,那个「几周内实现」的单调时钟,十年过去了一直没兑现。 直到 2026 年的官方 distributed-locks 文档,末尾仍然挂着这句免责声明:「Redis 不使用单调时钟来做 TTL 过期判定」,紧接着承认「墙上时钟的跳变,可能导致同一把锁被多个进程同时持有」。也就是说,Antirez 当年自己也认同、并亲口承诺修掉的问题,至今仍是官方文档里白纸黑字的已知风险。

第二,Redlock 真正依赖的不是「时钟准不准」,而是「进程不会长时间停顿 + 节点时钟不会跳变」。 这两条在任何带 GC 的语言、虚拟机或容器环境里,都只是「大概率成立」,没有任何硬保证。

所以工程上的结论不是「Antirez 错了」,而是:Antirez 的辩护在实践中成立的前提是「效率类锁」,Martin 的批评成立的前提是「正确性类锁」——两人各说各的场景,谁也没驳倒谁;但你的场景属于哪一个,得自己先想清楚。

90% 团队把 Redlock 装在「5 个 Redis 实例」上,但只部署了「1 个机房 + 1 个 NTP 源 + 1 个机柜」。Redlock 的算法假设与部署现实之间的鸿沟,是它最大的坑。

坑 7:时钟 + GC 同时发生时,Redlock 也会丢 — fencing token 才是终极防线

把坑 5(主从)+ 坑 6(时钟)+ 坑 3(GC)叠在一起,就是 Martin Kleppmann 的核心场景:

T1: Client1 在 R1、R2、R3 写入锁成功(T1 时刻)
T2: Client1 的工作线程 GC 暂停 6 秒
T3: R2 上的时钟被 NTP 向前跳了 2 秒
T4: 此时刻(T1+6)Client1 自己看起来锁还有 24 秒,但 R2 上的视角是 TTL 还剩 22 秒 → 也已过期
T5: Client2 在 R3、R4、R5 上成功写入锁(success=3,过半)
T6: Client1 GC 恢复,以为自己持锁
T7: Client1 和 Client2 都在写同一个共享资源 → 数据损坏

这是 Martin 自己的「冠军场景」,他提出的解药叫 fencing token(单调递增的令牌):

Client1: 成功 → 返回 token = 33
  → GC 暂停 6 秒
  → 醒来继续业务,带着 token=33 去写 DB
Client2: 成功 → 返回 token = 34
  → 顺利写 DB
DB 端校验:UPDATE table SET val = ? WHERE id = ? AND lock_token < 36
  → Client1 的 token=33 < 36,被拒
  → Client2 的 token=34 < 36,通过

这个机制用图看最直观——注意它和坑 3 那张图唯一的区别,就在 Storage 那一行:

Martin Kleppmann 原始原理图:fencing token 生效,Storage 拒绝了 Client 1 迟到的旧 token 33
Martin Kleppmann 原始原理图:fencing token 生效,Storage 拒绝了 Client 1 迟到的旧 token 33

两张图对照着看:Client 1 的停顿、锁的过期、Client 2 的接管,全都一模一样。唯一的差别是写入时带上了 token,而 Storage 记住了自己见过的最大 token——Client 2 写入的 token 34 被接受,Client 1 迟到写入的 token 33 被直接拒绝,右上角那个爆炸符号没有出现。图源:Martin Kleppmann《How to do distributed locking》(martin.kleppmann.com,CC BY 3.0)。

这里有个关键认识:真正兜住底的不是锁,而是资源端那句「只接受更大的 token」。锁只负责决定谁先来,能不能最终落地,由资源端说了算。

token 必须单调递增,由能保证不丢顺序的对象产生——典型选择是 etcd 的 Revision(每次写操作 +1,全集群唯一)、ZooKeeper 的 zxid,或者数据库本身的乐观锁列(version int)。

Redis 不天然支持 fencing token,因为它的 key 是单点(SET uuid NX PX),没有连续递增的全局序号。如果你想在 Redis 上模拟 fencing token,要么自建一个 INCR fencing_counter 的 key(Redis 是单线程命令,INCR 天然原子递增),但多个 Redis 实例之间这个 counter 不能保证严格递增。所以 Martin 的结论是:Redis 锁天生不适合严格互斥场景。

真正做到「严格互斥」,锁服务和资源服务必须共享一个递增序列。这个递增序列 Redis 给不了,etcd 的 Revision、ZooKeeper 的 zxid、MySQL 的 version 列都可以,选你能用的那个。

不过讲到这里,还得把 Antirez 当年对 fencing token 的反驳补上——这部分在很多转述里都被省掉了,但它恰恰决定了「这场争论到底谁对」:

  • 如果你的存储能按 token 大小决定接不接受写入,它本身就是一个线性化存储。」 推论是:既然存储已经能提供这种保证,它自己就能发递增 ID,你直接用它的自增序列就完了,Redlock 的意义被大幅稀释——「这等价于用数据库实现分布式锁」。
  • 多数情况下,互斥一旦被破坏,你就已经输了。」 他认为 Martin 的论证隐含一个前提:资源端还有别的手段兜住竞态。可现实里用分布式锁,恰恰是因为资源端没有别的控制手段。「假设你总能在资源端解掉竞态,这是一种很奇怪的推理方式。」
  • Redlock 本身已经返回了一个 20 字节的随机 token。」 官方规范写明这个值取自 /dev/urandom。你完全可以拿它做 Check-And-Set:把资源状态置成这个 token,写回时校验 token 是否还一致。
  • 很多锁根本不在事务性资源上。」 比如用锁去操作一台物理设备、去调一个外部 API——这些动作没有 version 列让你回滚,fencing token 那套假设直接不成立。

平心而论,这几条反驳都站得住,尤其第一条,它戳中了一个真实的尴尬:如果一个存储已经有能力按单调序号拒绝写入,那它本身就是个共识系统,你何必再在外面叠一层 Redlock。

但换个角度看,Martin 的诉求其实更朴素:在你必须保证「任何时刻只有一个写入者」的场景里,锁的可靠性不该是唯一的防线。Antirez 说「资源端有自增 ID 就不需要 Redlock」——这句话恰恰印证了同一个结论:防线应该在资源端,而不是把宝全押在锁上。

三方案决策表:Redis vs ZooKeeper vs etcd

维度 Redis(Redisson + 单节点) ZooKeeper / Curator etcd / concurrency
一致性模型 AP(可用 + 分区容错) CP(强一致) CP(强一致 + Raft)
互斥原语 SET key uuid NX PX + Lua 临时顺序节点 Lease + PUT if Not Exists
Fencing Token ❌ 需自建 INCR,跨节点不安全 ✅ zxid 自带 ✅ Revision 自带
主从切换丢锁 ⚠️ 有(异步复制) ✅ 无(ZAB 同步) ✅ 无(Raft)
时钟依赖 ⚠️ TTL 强依赖本地时钟 ✅ 无 ✅ 无
业务耗时超过 TTL 看门狗(在主线程 → 失效) Session 心跳(独立线程 → 健壮) Lease KeepAlive(独立线程 → 健壮)
QPS(典型) 10 万+(单实例) 万级 万级
部署门槛 已有,几乎零成本 需独立 ZK 集群 已有或随 K8s
何时用 效率类、防重复、缓存防击穿 强一致、跨数据中心、金融 K8s / 云原生、跨数据中心

简化的版本:你要防重复 → Redis;你要跨数据中心选主 → etcd 或 ZK;你在金融严正确性场景 → 数据库乐观锁 + Redis 锁双层(锁是优化,真正互斥在 DB)。

我的判断:什么时候用什么

我写代码用了 8 年 Redis 锁,其中 4 年是「什么场景都用 Redisson 默认设置」。回头看我接手的实际业务,大约 80% 的 Redlock 部署应该退回到单节点 Redisson——不是怕它不安全,而是大多数场景根本不需要它。

我目前的态度:

  1. 效率场景(去重、定时任务、缓存防击穿、防短时重复请求):**单 Redis 实例 + Redisson 默认 RLock**。TTL 设为业务 P99 耗时 × 2,看门狗自动续期。够用。
  2. 中等正确性场景(分布式任务调度、资源竞争不严重、数据有重试兜底):**Redisson MultiLock(主从多 Redis 抢同一把锁)**,不是 Redlock。前者是「一个客户端给 3 个 Redis 写」,后者是「5 个独立节点过半」。两者在解决不同问题。
  3. 强正确性场景(资金扣减、库存扣减、订单状态机):**RLock + 数据库 version 列乐观锁,双重兜底**。锁是 50% 防重复,DB 是 100% 防错。锁失效不会脏数据。
  4. 跨数据中心 / 容器编排场景:etcd 的 concurrency 包。天然 Revision + Lease + Raft,不用自己造计数。
  5. 完全不要用:Redlock 的「5 独立节点」真的部署完成本高(5 个独立机柜 + 独立 NTP + 独立电源),绝大多数团队的「5 个 Redis」不是这个意思,只是把现有的几个 Redis 拼一起。

落到具体动作上,如果你手上正好有一批老代码要改,建议按这个顺序动:先把 SETNX + EXPIRE 全部换成 SET key uuid NX PX;再检查释放逻辑有没有校验 uuid(实例支持 DELEX 就用 DELEX,不支持就用 Lua);最后才去评估「5 节点 Redlock 到底有没有必要」。前两步是纯收益,第三步十有八九的答案是「没必要」。

「Redis 锁不安全」不只是一句口号,但同时「升级到 Redlock」也不是万能解。真正靠谱的分布式锁,从来都是「锁服务 + 资源端的版本控制」两层叠在一起——前者省成本,后者兜底数据。这事儿 2016 年 Martin 讲过,2026 年的今天,生产里大多数人还在重复同样的错。

常见问题

Q1:SET NX PX 和 SETNX + EXPIRE 真有差别吗?一条命令真的会崩溃在两命令之间?

会。Redis 单线程串行执行命令,但两命令之间隔着网络往返。如果第一条 SETNX 成功后,你的客户端进程被操作系统 OOM Killer 杀掉,或者 Java 进程被 kill -9,或网络断了 ~30 秒,EXPIRE 那行就永远不会到 Redis。Redis 上的 key 没有过期时间,会永远存在,除非你手动 DEL。SET NX PX 单命令,过期间隔跟写入同步生效,绕过这个脆弱的两步组合。这条没有例外。

Q2:业务真的有 35 秒,我应该把 TTL 调到 35 秒吗?

不要。35 秒是你某次最坏耗时,你要按 P99 × 1.5–2 倍设,避免单次偶然慢请求直接让锁消失。更稳的做法是把所有远程调用异步化,业务耗时压到毫秒级。

Q3:Redisson 看门狗到底什么时候不工作?

两个边界:显式传了 leaseTime(比如 lock.lock(5, TimeUnit.SECONDS))——看门狗直接被关掉;业务主线程与 Netty 共享 EventLoop 时被同步 IO 阻塞时,续期任务压根不跑。这两条在官方文档的角落里有提,容易被忽略。

Q4:Redlock 我部署在「5 台 Redis」上,是不是就没坑 6 / 坑 7 了?

不是。坑 6 的本质是「5 个节点是否真的在事故域上独立」,同机房、同机柜、同供电、同 NTP 源,都不算独立。坑 7 的本质是「Redlock 算法本身依赖时钟同步,且假设客户端不会发生长时间 STW」,这两条工程上做不到 100% 保证。Martin 从 2016 年到今天从未收回这个判断,Redis 官方文档也至今保留着「不使用单调时钟」的免责声明——十年过去,两边的立场其实都没变。

Q5:etcd / ZK / Redis,生产怎么选?

如果你有 K8s,etcd 已经在,直接 etcd clientv3 concurrency.NewMutex,License 全免费;如果是传统金融/政企运维,ZK 更成熟;如果团队根本没有 ZK/etcd,别强上,就用 Redis 锁 + 数据库乐观锁,业务链路完全够。这不是降级,这是当前生产里 80% 工程师实际在用的方案,Martin 没反对这个。

文章来源:码哥跳动

以上关于Redis 分布式锁的 7 个坑的文章就介绍到这了,更多相关内容请搜索码云笔记以前的文章或继续浏览下面的相关文章,希望大家以后多多支持码云笔记。

「点点赞赏,手留余香」

22

给作者打赏,鼓励TA抓紧创作!

微信微信 支付宝支付宝

还没有人赞赏,快来当第一个赞赏的人吧!

声明:本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若内容造成侵权/违法违规/事实不符,请将相关资料发送至 admin@mybj123.com 进行投诉反馈,一经查实,立即处理!
重要:如软件存在付费、会员、充值等,均属软件开发者或所属公司行为,与本站无关,网友需自行判断
码云笔记 » Redis 分布式锁的 7 个坑

发表回复