分布式锁选型:一个扣库存场景,五种方案,以及那个不需要锁的做法

目录


一、选型的第一问

搜「分布式锁选型」,你会看到大量这样的文章:标题写着「Redis vs ZooKeeper」,正文列一张对比表,然后给一个结论。

这类文章漏掉了最重要的一步。

分布式锁这个题目上,方案之间的差距不在性能——Redis 和 ZooKeeper 的加锁延迟差不了几毫秒,而你的业务代码可能跑几百毫秒。差距在”锁失效时会发生什么”。

所以选型的第一问应该是:

锁失效了,后果是什么?

这个问题一分开,整个选型就塌缩了。Martin Kleppmann 在那篇著名的《How to do distributed locking》里,第一件事就是做这个切分:

用途他在原文里的定义失效后果他的建议
efficiency(图省事)“Taking a lock saves you from unnecessarily doing the same work twice”代价很小单实例 Redis 就够,五节点集群是浪费
correctness(要正确)“Taking a lock prevents concurrent processes from stepping on each others’ toes”“a corrupted file, data loss, permanent inconsistency”两种方案都不安全

回想一下你上次想加分布式锁,是为了哪一类?

这里有个值得警惕的模式:需求是按第二类的语气提出来的(“这里不加锁肯定出问题”),但真正的失效后果其实是第一类(多跑了一遍任务、多查了一次数据库)。于是团队按第二类的标准去选了型,上了一个 ZK 集群。

本文不声称这是普遍现象——它只是一个值得你自己核对的问题。§9 会回到这里,告诉你核对完该怎么办。

这篇文章用第二类(后果最重的那类)来贯穿,因为只有它才逼得出真正的方案差异。文章最后会回到第一类,那时你会发现结论完全不同。


二、贯穿全文的场景:扣库存

一个再普通不过的电商接口:

用户下单 → 检查库存 → 库存充足 → 扣减库存 → 创建订单

问题:如果同一个商品同时来了两个请求,两个都读到”库存 = 1”,两个都判断”充足”,两个都执行 库存 = 库存 - 1——最后库存变成 -1,卖出去两件。

这就是并发写入导致的丢失更新。单机上,synchronized 或 ReentrantLock 就解决了。但订单服务部署了多个实例,JVM 内的锁管不到另一个 JVM。

所以你想起了分布式锁。

为什么选这个场景:因为它的失效后果属于上面说的第二类——钱会错、货会少、用户会投诉。它是所有场景里最严苛的那一档,五个方案都得拿出真本事。末尾要讲的 fencing token,也只有在这种场景下才有意义。


三、方案一:Redis 单实例

3.1 正确的写法

Redis 官方的 SET 命令文档给了一个简单模式:

SET resource-name anystring NX EX max-lock-time

两个选项的含义(官方原文):

  • NX:“Only set the key if it does not already exist.”
  • PX milliseconds:“Set the specified expire time, in milliseconds”

关键在于:一条命令同时完成了”判断是否存在”和”设置过期时间”两件事。 这是 Redis 单实例方案的全部基础。

3.2 三种经典的错法

⚠️ 下面三段错误代码是为教学构造的示例,不是从任何真实项目里摘的。 它们太常见了,所以值得单独列出来。

错法一:分成两步

# ✗ 两条命令,中间可能被打断
SETNX lock:stock:1001 "1"
EXPIRE lock:stock:1001 10

如果进程在两条命令之间挂了,锁永远不会过期——deadlock。

正确做法:用一条 SET ... NX PX。这不是风格问题,是原子性问题。

错法二:释放锁不校验持有者

# ✗ 直接删,不管这把锁是不是自己加的
DEL lock:stock:1001

场景:A 拿到锁,但业务跑了 15 秒(锁只有 10 秒 TTL),锁自动过期了;B 拿到锁开始干活;这时 A 跑完了,执行 DEL——把 B 的锁删了。C 又能进来。

正确做法:写入一个只有自己知道的随机值当 token,释放时先比对。Redis 官方文档给的 Lua 脚本,逐字如下:

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

官方对这段的解释是:“This avoids that a client will try to release the lock after the expire time deleting the key created by another client that acquired the lock later.”

调用方式官方也给了:EVAL ...script... 1 resource-name token-value

(用 Lua 是因为”读 + 比 + 删”三步必须在服务端原子完成。换成客户端先 GET 再 DEL 就退回了错法一。)

错法三:以为过期时间够长就安全

“我把 TTL 设成 30 秒,我的业务只要 200 毫秒,不会超时。”

——这条错法最隐蔽,因为它依赖一个你无法保证的假设:你的进程不会被暂停。

会被暂停。GC 会停、操作系统会换页、虚拟机可能被迁移、CPU 可能被别的进程抢光。这是下一节要展开的核心问题。

3.3 单实例的硬伤:主从切换丢锁

就算你写了完全正确的 SET NX PX 和 Lua 释放,还有一个问题绕不过去:

Redis 主从复制是异步的。所以:

  1. 客户端 A 在主节点拿到了锁
  2. 主节点还没把这次写入同步给从节点,就挂了
  3. 从节点升为主节点——它不知道有一把锁存在
  4. 客户端 B 来加锁,成功了

A 和 B 同时认为自己持有锁。

这不是配置问题,是异步复制的固有性质。 想解决它,就得换一个思路——这就引出了 Redlock。

3.4 一个耐人寻味的官方立场

在结束这一节之前,必须提一句 Redis 官方文档里的态度。它在介绍完上面那个简单模式之后,加了一句:

“discouraged in favor of the Redlock algorithm which is only a bit more complex to implement, but offers better guarantees and is fault tolerant.”

Redis 官方自己在推荐 Redlock。

记住这句话,因为下一节你会看到,有人说别用。


四、方案二:Redlock

4.1 机制:多数派投票

Redlock 的思路是:不信任单个节点,改成向 N 个独立的 Redis 实例同时申请。

大致流程:

  1. 记录当前时间
  2. 依次向 N 个实例发 SET ... NX PX
  3. 如果超过半数成功,且总耗时小于锁的有效期 → 认为加锁成功
  4. 加锁成功后再读一次时间,确认还没超时
  5. 释放时向所有实例发删除

关键在”独立”:这 N 个实例之间不做主从复制、不互相协调。这样单个节点挂掉就不会带走”锁存在过”这个信息。

4.2 Kleppmann 的批评

Kleppmann 那篇文章的核心论点不是”Redlock 实现得不好”,而是”任何基于过期的锁都不能保证正确性”。他的论证分三层:

第一层:进程会被暂停,这跟锁服务好不好无关。

“even if you have a perfect lock service, the code above is broken”

为什么?因为客户端自己会卡住:

“Your processes will get paused.”

他列的原因:GC(“GC can pause a running thread at any point”)、page fault、EBS 延迟、CPU 争抢、SIGSTOP。他还举了一个真实事故——GitHub 遇到过网络包被延迟大约 90 秒的情况:

“packets were delayed in the network for approximately 90 seconds”

结论:

“You simply cannot make any assumptions about timing, which is why the code above is fundamentally unsafe”

第二层:唯一的解法是 fencing token,而 Redlock 没有。

fencing token 的定义(他的原话):

“a fencing token is simply a number that increases”

每次授予锁时递增。关键在于使用方式:客户端每次写存储时都带上这个号,存储服务拒绝任何 token 比它见过的更小的写入。

于是即使 A 的锁过期了、B 拿到了锁,A 之后写的任何一个字节都会被存储拒绝——因为 A 手里的 token 是旧的。

而 Redlock 的死穴(他的原话):

“it does not have any facility for generating fencing tokens”

为什么不能用它返回的随机值?因为

“The unique random value it uses does not provide the required monotonicity”

(随机数没有”递增”这个性质。)

第三层:Redlock 的安全性依赖时钟。

“Its safety depends on a lot of timing assumptions”

Redis 取时间用的 gettimeofday:

“the time it returns is subject to discontinuous jumps in system time”

他的总结:

“Redlock works correctly only if you assume a synchronous system model”

也就是:网络延迟有界、进程暂停有界、时钟误差有界。而

“as soon as those timing assumptions are broken, Redlock may violate its safety properties”

最后一句很重:

“If you’re depending on your lock for correctness, ‘most of the time’ is not enough.”

他的结论是:Redlock “neither fish nor fowl”(非驴非马)——做效率锁太重太贵,做正确性锁又不够安全。所以:

“please don’t use Redlock”、“please use a proper consensus system such as ZooKeeper”

4.3 antirez 的反驳

Redis 作者 antirez 写了一篇回应。他开头就不接受结论:

“However I don’t agree with the analysis.”

反驳一:fencing token 是把因果搞反了。

他的核心论点是:Martin 预设了”锁失效之后还有一个机制能补救”——可锁存在的意义恰恰是防止这种情况发生:

“when this property is violated you already lost”

他还补了一个技术观察:一个能拒绝倒退 token 的存储,本身必须是线性一致的——如果已经有那样的存储,直接用它生成自增 ID 就够了,还要锁干什么。

而现实中,“锁”保护的东西常常根本不是数据库写入——物理设备、外部 API、非事务性的更新,那些地方没有地方让你”带上 token 让服务端拒绝”。他还说,实践中可以用锁的随机唯一标识达到同样的目的:

“a unique identifier for each lock can be used for the same goals”

反驳二:时钟要求比 Martin 说的弱得多。

他说进程需要的不是”有界的消息延迟”,而仅仅是:

“they need to do is just, for example, to be able to count 5 seconds with a maximum of 10% error”

至于时钟跳变,他认为主要来自管理员改时间或 ntpd 步进,是可以避免的。

反驳三(最关键):acquire 阶段会再读一次时间。

这是他对”进程暂停”的直接回应。Redlock 的加锁流程在拿到多数派之后,会再检查一次是否超时:

“after acquiring the majority we *check again* that we are not out of time”

由此他主张:

“Redlock looks completely immune from messages that have unbound delays between processes”

“Pauses during the process of acquiring the lock don’t have effects on the algorithm’s correctness”

那”检查之后”发生的延迟呢? 他承认那会出问题,但认为这退化成所有基于过期的锁共有的自动释放问题——不是 Redlock 独有的缺陷。

唯一的让步:他承认应该改用单调时钟(monotonic clock),并承诺:

“I’ll implement this in the next weeks”

收尾时他说:

“I can’t identify other points of the analysis affecting Redlock safety”

4.4 这场争论该怎么读

本文不站队,因为两边都没有算错——他们假设的系统模型不同:

Kleppmann 假设antirez 假设
进程暂停可以任意长,且无法预先排除在工程上可控,且 acquire 阶段有二次检查兜底
时钟可能发生不连续跳变误差可控制在百分比内
系统模型异步(asynchronous)半同步(部分假设成立)
由此得出锁不能保证正确性 → 需要 fencing锁在工程意义上是安全的

Kleppmann 那句”even if you have a perfect lock service, the code above is broken”是整场争论的钥匙——他讨论的是理论上能不能保证。而 antirez 讨论的是工程上够不够用。

这两个问题没有共同答案。 所以对你的选型来说,真正有用的不是”谁赢了”,而是:

你愿意把系统建立在”进程不会被长时间暂停”这个假设上吗?

  • 如果你不能(比如你的服务跑在有 stop-the-world 的 GC、或者跨可用区、或者对正确性有硬要求)→ 走 Kleppmann 那条路:要 fencing token,或者用真正有共识能力的系统
  • 如果你能(比如这就是个内部定时任务,最坏结果是多跑一次)→ Redis 那套完全够用,而且更简单

注意:Kleppmann 在文末专门说了,他的批评不减损 Redis 在其设计用途上的价值——他反对的是把 Redlock 用在正确性场景,不是反对 Redis。


五、方案三:ZooKeeper

5.1 机制:排队,而不是抢

ZooKeeper 的做法和 Redis 完全不同——它不是”大家去抢一把锁”,而是”大家排一个队”。

官方 Recipes 文档的 Locks 一节给了完整流程:

  1. 客户端在锁节点下创建一个子节点,同时设置 sequence 和 ephemeral 两个标志
  2. 调用 getChildren() 拿到所有子节点——注意,不带 watch,文档说这”important to avoid the herd effect”
  3. 判断自己是不是最小的那个序号:

“If the pathname created in step 1 has the lowest sequence number suffix, the client has the lock.”

  1. 如果不是,就找到紧挨在自己前面的那个节点,用 exists() 监听它
  2. 等它消失,回到第 2 步

5.2 三个设计细节

① ephemeral(临时节点):会话断开就自动删。

这是 ZooKeeper 相对 Redis 最大的结构性优势——你不需要设置 TTL,也不需要依赖系统时钟。客户端进程挂了、网络断了,ZooKeeper 检测到会话失效,节点自动消失,锁自动释放。

对比一下 Redis:Redis 靠 PX 给一个绝对时间,无论客户端死活,到点就放。一个是”检测到客户端没了才放”,一个是”到点就放”。

② sequence(顺序节点):天然排除了惊群。

文档解释了为什么这很重要:

“The removal of a node will only cause one client to wake up since each node is watched by exactly one client.”

每个客户端只盯着自己前面那一个。 队首走了,只有一个客户端被唤醒——它就是新的队首。

③ 不需要轮询。

因为用的是 watch 通知而不是轮询,文档明确说这种方式 “no polling or timeouts”。

5.3 它在扣库存场景里的表现

ZooKeeper 是三个中间件方案里唯一不依赖时钟的(etcd 的 TTL 由集群自己维持,也不依赖客户端时钟)。它的锁释放条件是”会话失效”,这个判断由 ZK 集群自己做,不需要信任任何客户端的计时器。

代价:多一个组件要运维。而且 ZooKeeper 的写性能不如 Redis——但加锁本来就是低频操作,这个差异通常不是瓶颈。


六、方案四:etcd

6.1 机制:lease + 事务比较

etcd 的做法结合了前两者的特点。看官方 API 文档:

lease(租约) —— 它被定义为 “a mechanism for detecting client liveness”。关键语义是:

“A lease expires if the etcd cluster does not receive a keepAlive within a given TTL period.”

“When a lease expires or is revoked, all keys attached to that lease will be deleted.”

也就是说:锁的自动释放 = 租约到期 = 键被删除。 而且和 ZooKeeper 一样,到期判断由集群做,不靠客户端时钟。

(续期走双向流 LeaseKeepAlive。这和 Redis 的”到点就放”是不同的模型——etcd 支持真正的续约。)

txn(事务) —— 这是加锁的原子性来源:

“A transaction is an atomic If/Then/Else construct over the key-value store.”

比较的字段可以选 VERSION、CREATE(创建版本)、MOD(修改版本)或 VALUE。官方列出的用途里明确包含:

“building compare-and-swap operations”

所以 etcd 的加锁就是:lease 提供自动释放,txn 比较 CREATE 版本提供”这个键还没被创建过”的原子判断。

6.2 一个额外的能力:revision 天然接近 fencing

etcd 的每个写入都会让全局 revision 递增,而且事务只会让 revision 加一次:

“the store’s revision is incremented only once for the transaction”

这意味着你可以拿到一个单调递增的版本号。

回到 Kleppmann 那节:他说 Redlock 的死穴是”没有生成 fencing token 的手段”,因为随机值不单调。而 etcd 的 revision 恰好是单调递增的——虽然 etcd 官方并不把自己宣传成”分布式锁服务”,但它的数据结构天然更接近 Kleppmann 要求的那套。

(注意别过度解读:这不等于”用 etcd 就自动安全了”。fencing 要在存储侧强制执行才有意义——你的 MySQL 得真的去拒绝落后的 token。能力存在,不等于你用上了。)


七、方案五:数据库

这是被讨论得最少、但在扣库存这个场景里最强的一个方案。

7.1 先看一条你可能一直没注意的 SQL

UPDATE stock SET qty = qty - 1 WHERE id = 1001 AND qty >= 1

就这一条。

它做了三件事,而且是原子的:

做了什么在哪一部分
检查库存够不够WHERE qty >= 1
扣减SET qty = qty - 1
判断这次操作有没有生效影响行数:1 = 扣成功了,0 = 库存不够

它不需要锁。 为什么?因为**“检查”和”扣减”发生在同一条语句里,由数据库保证原子性**。两个请求同时来:

  • 第一个执行完,qty 从 1 变 0,影响 1 行
  • 第二个的 WHERE qty >= 1 不成立,影响 0 行 → 业务代码按”库存不足”处理

冲突没有发生,因为根本没有”先读后写”这个窗口。

⚠️ 这是全文最该记住的一处。 你在”扣库存”这个场景里加分布式锁,很多时候是先 SELECT 出来判断、再 UPDATE 造成的——那个”检查与写入分开”的写法,本身才是问题的来源。

7.2 另外两种数据库做法

数据库这个方案实际上有三种,适用条件不同:

做法怎么用适用条件
① 原子 UPDATEWHERE 里带上业务条件减数、状态流转这类场景,首选
② 乐观锁版本号表里加 version 字段,UPDATE ... WHERE id=? AND version=?;影响 0 行就重试要更新多个字段、或者结果依赖读到的旧值
③ 唯一索引用 INSERT 的冲突来互斥插入语义——比如”同一用户同一活动只能领一次券”

③ 有个常被忽略的性质:唯一约束是数据库强制的,不依赖任何外部组件的可用性,也不依赖过期时间。它天然就是”不会失效”的。

这正是 Kleppmann 想要的 fencing 语义的近亲——数据库自己会拒绝那个不合法的写入,而不是靠客户端自觉。

7.3 代价是什么

诚实地说,数据库方案的问题也很明确:

  • 数据库成了热点。所有并发都压在一行上,行锁竞争会影响其他事务
  • 性能上限比 Redis 低。单行更新的吞吐有天花板
  • 扩展性差。分库分表之后,“同一行”这件事本身就不好保证了

所以它不是万能的。但请注意它的适用面比你想象的大——绝大多数业务的并发量,根本压不到数据库的天花板。


八、把五种方案收成一张判据表

方案锁怎么自动释放依赖时钟吗挂一个节点会怎样额外要运维什么
Redis 单实例TTL 到期是主从切换可能丢锁你多半已经有
RedlockTTL 到期是(这是争议焦点)容忍少数节点故障N 个独立实例
ZooKeeper会话失效(临时节点自动删)否集群多数派存活即可一套 ZK
etcd租约到期(键自动删)否(集群自己维持)集群多数派存活即可一套 etcd
数据库不需要释放否看数据库架构你多半已经有了

看这张表要抓的重点:

① “怎么释放”这一列是分水岭。 Redis 和 Redlock 是**“到点就放”;ZooKeeper 和 etcd 是”确认客户端没了才放”**。前者简单,但把正确性押在了”客户端不会超时”上;后者依赖一次会话/租约的判断,不要求你的时钟准。

② “依赖时钟吗”这一列,就是整场 Redlock 争论的技术核心。 Kleppmann 反对的正是它——他说时钟可能跳、进程可能停,而 Redlock 的安全性建立在这些假设上。antirez 的回应是”假设可以放松到 10% 误差,而且 acquire 阶段有二次检查”。

③ 数据库这一列的”不需要释放”,是它最被低估的优势。 原子 UPDATE 和唯一索引压根没有”锁”这个东西存在,也就没有”锁失效”这个概念。


九、回到扣库存:如果要求可以放宽

现在回到开头那个切分。

我们用后果最重的场景(扣库存)走完了五种方案,因为只有它能暴露真正的差异。但你手上的需求,多半没这么重。

9.1 如果只是”多干一次活”

假设你要加锁的场景是:每天凌晨的报表任务,在多副本部署下只想跑一次。

最坏情况是什么?跑两次,生成两份一样的报表,其中一份被覆盖。

这是典型的 efficiency 用途。Kleppmann 对这种场景的建议很直接——单实例 Redis 就够了,五节点集群是浪费。

而且更进一步:“只跑一次”这个需求,本身就有比分布式锁更简单的解法——比如让任务自己去数据库抢一个唯一约束(今天这个任务 ID 只允许插入一次),或者干脆用调度平台的能力。

9.2 如果只是”防止重复提交”

用户手抖点了两次提交按钮。最坏情况是生成两个一样的订单。

这时候最自然的做法不是加锁,是给这个请求一个唯一标识,落库时靠唯一索引挡住第二次。这比锁简单得多,而且不会失效。

9.3 结论:先问后果,再问方案

绝大多数”我们需要分布式锁”的场合,真实的失效后果是”多干一次活”或”多出一条记录”。

而这两件事,通常都有比分布式锁更简单、更可靠的解法——一个唯一约束就够了。

所以整篇文章的判据可以压缩成两句话:

  1. 先问锁失效的后果。 只是多干一次活 → 别过度设计。数据会错乱 → 继续往下问。
  2. 如果数据会错乱,先问”能不能不用锁”。 一条带条件的原子 UPDATE、一个唯一索引,往往比任何锁都更可靠——因为它们不依赖任何会过期的东西。

只有这两问都过不了,才轮到”Redis 还是 ZooKeeper”。


十、自检清单

读完这篇,如果下面每一条你都能答上来,分布式锁这道题你应该不会选错了:

  • 选型的第一问应该是什么?为什么它比”用什么中间件”更重要?
  • SETNX + EXPIRE 两条命令的写法,问题出在哪?
  • 释放锁时为什么要先比对 value?不比对会发生什么?
  • “TTL 设得足够长”为什么不是安全的保证?
  • Redis 主从切换为什么会丢锁?这是配置问题还是固有性质?
  • Redlock 为什么要向多个独立实例申请,而不是一个集群?
  • Kleppmann 那句”even if you have a perfect lock service, the code above is broken”是什么意思?
  • fencing token 是怎么工作的?它解决了什么别的手段解决不了的问题?
  • antirez 反驳的核心是什么?他认为 acquire 阶段的哪一步提供了保障?
  • 这场争论的实质分歧在哪?为什么说”两边都没算错”?
  • ZooKeeper 的临时节点和 Redis 的 TTL,在”锁怎么释放”这件事上有什么本质区别?
  • 抢锁会造成惊群,ZooKeeper 是怎么避免的?
  • etcd 的 revision 为什么接近 fencing token 需要的性质?
  • 扣库存场景下,那条原子 UPDATE 为什么不需要锁?你的代码里”检查”和”写入”是分开的吗?
  • 唯一索引为什么”不会失效”?它和锁相比,优势在哪?
  • 你自己的那个需求,锁失效的后果到底是什么?

附:本文的核查方式

引用来源:

内容来源
efficiency vs correctness 的切分、“Your processes will get paused”、“neither fish nor fowl”、fencing token 定义、“please don’t use Redlock”Kleppmann, How to do distributed locking(2016-02-08)
“I don’t agree with the analysis”、“when this property is violated you already lost”、二次检查时间、“count 5 seconds with a maximum of 10% error”、单调时钟的让步antirez, Is Redlock safe?
SET 的 NX / PX 语义、解锁 Lua 脚本、discouraged in favor of the Redlock algorithmRedis SET 命令文档
临时顺序节点、最小序号持有锁、“only one client to wake up”、no polling or timeoutsZooKeeper Recipes — Locks
lease 语义、到期删除全部关联键、txn 的 If/Then/Else、Compare 的 target 字段、revision 每个事务只加一次etcd API 文档

关于 Redlock 之争:本文的两个立场都取自双方原文,没有使用任何二手转述。这样做是必要的——这场争论在网上流传的版本里,双方论点经常被简化和误传。本文刻意不评判谁对,只呈现双方假设的系统模型差异(见 §4.4)。

关于示例代码:

本文只贴了 Redis 那一种方案的代码,因为它是被写错最多的。§3.2 里那三种错误写法是为教学构造的示例,不是从任何真实项目里摘的——它们流传太广,所以值得单独点名。

其余四种方案只讲机制,不贴实现。

本文没有做任何实测:没有启动 Redis、ZooKeeper、etcd,没有做压测,没有复现主从切换丢锁。凡涉及具体机制和默认行为的,都标注了官方文档出处;凡需要实测才能得出的结论(比如各方案的延迟数字),本文一律不给。

与既有文章的边界:本号《Java - Redis 篇》的 §1.7 讲的是「Redis 怎么实现分布式锁」(SETNX 原理)。那篇讲怎么实现,这篇讲该选哪个——两篇互补,不重复。

本文不涉及:ZooKeeper 的 ZAB 协议与选举细节、etcd 的 Raft 实现、Redis 的 Redlock 完整伪代码、各方案的性能基准测试。


附:延伸阅读