Redis 分布式锁到底能不能用?Redlock 争论过去 9 年,多数团队用错了方向

🔑 关键词:分布式锁,Redlock,etcd,fencing token,幂等设计

📖 摘要:从 2016 年 Kleppmann 与 antirez 的 Redlock 之争讲起,对比 Redis 单实例、Redlock、etcd/ZooKeeper 三种方案的真实参数与成本,并给出一个可能不太讨喜的结论:大部分所谓分布式锁需求,本质是幂等需求,加唯一索引比加锁便宜一个数量级。

先把那场架吵清楚

图片

2016 年 2 月 8 日,Martin Kleppmann 在自己博客上发了《How to do distributed locking》,矛头对准 Redis 官方文档里的 Redlock 算法。第二天 antirez 就回了《Is Redlock safe?》。国内技术号转载的时候,标题基本都写成了「Redis 锁到底安不安全」,最后总结成一句「各有各的道理,看场景」。

但那场争论真正在吵的东西,经常被略过了。Kleppmann 那篇文章里有个更狠的点:就算 Redlock 在算法层面完全正确,它也没法解决下面这个场景——客户端 A 拿到锁,然后触发了一次 2 秒的 GC 停顿,锁的 TTL 只有 1 秒,锁自动过期了;客户端 B 拿到锁开始写;A 从 GC 里醒过来,毫不知情地继续往下写。两个客户端同时认为自己持有锁。

他给出的解法是 fencing token:每次加锁返回一个单调递增的序号,下游存储拒绝序号比已见过的最大序号更小的写请求。这个思路不是他发明的,Google 2006 年的 Chubby 论文里就写了,Chubby 的 lock 会返回一个 sequencer,客户端带上去。Kleppmann 只是把它拎出来,指着 SET NX PX 说:你看,它返回的是 OK,不是序号。

图片

antirez 的反驳也站得住:fencing 要求下游存储配合,等于把复杂度推给了存储层,而 Redis 的目标是一个足够好、足够快的方案。所以这场争论的本质不是安全不安全,是成本该由谁承担。

三种方案的真实参数

国内目前实际在用的就三种:Redis 单实例 SET key value NX PX ttl、Redlock、etcd/ZooKeeper。

Redis 单实例:一次网络往返,同机房 p99 一般在 1ms 以内,单节点扛 8 到 10 万 QPS 没有压力。问题是主从复制是异步的,主节点宕机、从节点升主,锁大概率丢,RPO 不为 0。这个问题跟 Redlock 是不是更安全无关,只要用了异步复制就存在。

图片

Redlock:需要 5 个互相独立的 Redis 实例(注意是独立部署,不是 5 个节点组一个 Cluster),客户端要在多数派也就是 3 个实例上拿到锁,且总耗时小于 TTL。写延迟从 1 次 RTT 变成 5 次并发 RTT,还要做时钟漂移补偿——TTL 减去网络耗时再减去漂移量才是安全窗口。运维成本从 1 个 Redis 变成 5 个独立 Redis,这一条就劝退了大部分人。

etcd:基于 Raft,写要走一次 quorum 落盘。默认 heartbeat-interval 是 100ms,election-timeout 是 1000ms。同机房三节点写延迟在 2 到 5ms 量级,跨城三个节点就是几十毫秒。它有两个天然优势:lease 机制自带 TTL,revision 全局单调递增,正好可以直接当 fencing token 用。clientv3 里 Lease Grant 之后带 lease 去 Put,拿到的 revision 传给下游存储就行。

ZooKeeper 类似,临时顺序节点,zxid 单调递增。老牌方案,但运维复杂度和 etcd 比没有优势,新项目基本不会选它了。

图片

但大部分场景根本不需要锁

这段我想说得直白一点。你去看那些问「分布式锁怎么选」的帖子,十有八九的实际需求是「防止重复下单」「防止重复扣款」「定时任务别多实例同时跑」。前两种,本质是幂等,不是互斥。

重复下单:数据库加唯一索引,order_no 唯一,插入失败直接返回。重复扣款:流水表加唯一约束,用业务单号做 key,先插流水再改余额,放同一个事务里。这两个方案都是数据库自带的,不用引入任何新组件,不存在锁超时、锁续期、Redis 挂了怎么办这些问题。

图片

定时任务别同时跑:这才是真需要互斥的场景,但你要的其实不是「锁」,是「选主」。多实例抢一个 leader,抢到跑,抢不到不跑。etcd 的 concurrency 包里有个 Election,几十行代码就写完了,比自己拿 Lock 再处理超时靠谱得多。

用锁的思路做前两个需求,会平白引入一串新问题:TTL 设多少?业务执行超过 TTL 怎么办?Redis 挂了怎么办?要不要 try-finally 释放?这些问题的答案,全都不如一句「加个唯一索引」干净。

如果非要上锁,几条硬建议

第一,TTL 不是保险丝,是自杀开关。TTL 设 30 秒,业务跑了 35 秒,锁早没了,你以为还握着。要么给业务加超时控制,保证执行时间远小于 TTL,要么用 watchdog 续期。Redisson 就是这么做的,lockWatchdogTimeout 默认 30 秒,每 10 秒续一次。但续期也有代价,续期那个线程自己挂了,锁照样丢。

图片

第二,能拿 fencing token 就拿。etcd 的 revision、ZK 的 zxid 都可以。Redlock 没有这个能力,这正是 Kleppmann 攻击的点。下游是 MySQL 的话,可以在更新时带上条件,或者单独存一个单调递增的 token 做校验,拒绝过期持有者的写。

第三,跨城别用 Redis 锁。主从异步复制加跨城抖动加主从切换,锁基本必丢。跨城老老实实上 etcd 或者 ZK 这类共识系统,代价是写延迟从 1ms 涨到几十毫秒。能不能接受,取决于你的锁有多热,一秒几千次锁操作的场景,跨城方案直接出局。

最后一个我自己觉得最值钱的观察:锁的粒度和锁的持有时间,比锁的选型重要得多。见过不少团队在 Redlock 和 etcd 之间纠结了两周,回头一看业务在锁里做了一次 HTTP 调用,持有时间 800ms。把那次调用挪出去,锁持有时间从 800ms 降到 5ms,冲突概率直接掉了两个数量级,这时候用什么锁都无所谓了。

🏷️ 标签: