"加个分布式锁"这句话,在排查并发问题的会议上出现的频率很高。加完之后问题通常确实好了——但很多时候只是概率降低了,不是真的没了。

这篇想把这件事说清楚:用 Redis 做的分布式锁,它到底保证了什么、在什么情况下会失效、以及什么样的业务该用它、什么样的不该。

一、最朴素的实现,和它的三个 bug

先看最常见的写法:

SETNX lock:order:1001 1
# ... 执行业务 ...
DEL lock:order:1001

三个问题,一个比一个隐蔽。

问题一:进程挂了,锁永远不释放。 业务执行到一半进程被 kill、机器断电,DEL 没跑,这把锁就是永久的。所以必须带过期时间。

问题二:SETNX 和 EXPIRE 分成两步不是原子的。 早年的写法是 SETNX 成功后再 EXPIRE,两条命令之间如果崩了,又回到问题一。正确写法是用单条命令:

SET lock:order:1001 <random-token> NX PX 30000

问题三:可能删掉别人的锁。 A 拿到锁,业务执行超过了 30 秒,锁自动过期;B 拿到锁;A 执行完了,DEL 一下,删掉的是 B 的锁。于是 B 还在执行,C 又拿到了锁——两个人同时在临界区。

所以 value 必须是一个随机 token,释放时校验:

-- unlock.lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
else
  return 0
end

用 Lua 是因为"读—比较—删"这三步也得原子。

到这里,一个"能用"的锁就有了:

加锁: SET key token NX PX ttl
解锁: EVAL unlock.lua 1 key token

二、但问题三只解决了一半

注意上面那个场景:A 因为执行超时导致锁过期,token 校验只保证了 A 不会误删 B 的锁,它并没有阻止 A 和 B 同时在临界区

这是所有基于超时的分布式锁的根本问题:锁的持有者无法感知自己的锁已经没了。

A 以为自己还持有锁,继续往下写数据库;B 拿着新锁也在写。互斥从这一刻起就破了。

常见的三种缓解手段,各有代价:

手段一:看门狗续期。 后台起一个定时任务,业务没结束就不断把 TTL 续上(Redisson 的默认行为)。它把"业务超时"变成了"客户端失联才释放",覆盖了绝大多数情况。但如果是客户端 GC 长时间停顿或者网络分区导致续期没发出去,锁一样会过期,而业务线程恢复之后还会继续跑。

手段二:fencing token。 每次加锁返回一个单调递增的序号,业务在真正写存储时把这个序号带上,存储端拒绝比已见过的序号更小的写入。这是 Martin Kleppmann 那篇著名文章里的方案,它把最终的安全性交给了下游存储,而不是锁本身。

问题在于下游得支持。数据库要有一列存 token 并且写入时做条件判断,对象存储要支持条件写。很多场景下游根本不配合,这条路就走不通。

手段三:把临界区做短、做幂等。 最朴素也最有效。临界区里只做一件很快的事,并且这件事重复执行结果一样。这样即使互斥破了,也不会产生错误的数据。

三、单节点、主从、Redlock

单节点 Redis 的问题很直白:它挂了,锁服务就没了。

主从架构看起来解决了可用性,但引入了一个更麻烦的问题:Redis 的主从复制是异步的。A 在 master 上拿到锁,master 还没把这条数据同步给 slave 就宕机了,哨兵把 slave 提升为新 master——新 master 上没有这把锁,B 来加锁,成功。又是两个人同时持有。

Redlock 就是为了解决这个提出的:部署 N 个互相独立的 Redis 实例(不是主从,是完全独立的),客户端依次向所有实例申请锁,拿到超过半数(N/2+1)且总耗时小于锁的 TTL,就算加锁成功。

Redlock 有过一场很有名的争论。Kleppmann 的批评核心是:它的安全性依赖于各节点时钟不会发生大幅跳变,而这在现实中不成立——管理员手动改时间、NTP 大幅校正、虚拟机暂停后恢复,都会让某个节点上的锁"提前过期",从而打破半数假设。antirez 回应说这些是运维问题,可以通过禁用大幅 NTP 跳变来规避。

我的实际结论是:Redlock 的复杂度和它带来的确定性不成正比。 它需要 N 个独立部署的 Redis,运维成本翻几倍,而它仍然不能给你"绝对不会两个人同时进临界区"的保证。如果你的业务真的一次都不能错,那就不该用任何基于超时的锁;如果业务能容忍极小概率的重入(配合幂等),单节点或者主从加看门狗就够了。

四、那"真正安全"的锁长什么样

基于 租约 + 共识 的方案,比如 etcd 或者 ZooKeeper。

etcd 的做法大致是:客户端创建一个带 lease 的 key,并持续 keepalive 续租;lease 过期,key 自动删除。关键区别在于:

  • 写入走 Raft,多数派确认,不会因为主从异步复制丢数据。
  • 客户端可以 watch 这个 key,锁被释放时能立即得到通知,而不是轮询。
  • 每次写入有全局递增的 revision,天然就是一个 fencing token。

代价也很直接:etcd/ZK 的写入比 Redis 慢一到两个数量级(要过 Raft/ZAB),而且它们通常不是按高 QPS 场景设计的,把高频业务锁压到 etcd 上,很容易把整个集群写垮——而 etcd 在很多架构里同时还承载着服务发现、配置中心,拖垮它的影响面会很大。

所以选型不是"哪个更安全",是"你的业务容忍度和 QPS 落在哪个区间"。

五、一个更常被忽略的问题:锁粒度

见过太多这样的代码:

lock("order_process")        // 全局一把锁

所有订单处理都抢同一把锁,吞吐直接退化成单线程。正确的粒度应该跟着业务主键走:

lock("order_process:" + orderId)

但粒度也不是越细越好。粒度细了之后,一个业务流程如果要锁多个资源,就会出现死锁——A 锁了订单再锁库存,B 锁了库存再锁订单。分布式环境下没有数据库那样的死锁检测,两边都会一直等到超时,表现为接口大面积变慢。

规避办法是老一套:按固定顺序加锁(比如所有资源 ID 排序后依次锁),或者合并成一把更粗的锁。前者要求所有调用方都遵守约定,后者牺牲吞吐。

六、等待策略

拿不到锁怎么办,这件事的影响常常比锁本身还大。

自旋重试最常见,但要注意两点:一是必须有最大重试次数或总超时,否则请求会堆积到把线程池打满;二是重试间隔要加随机抖动,否则一堆客户端会同步重试,形成周期性的尖峰。

阻塞等待(比如基于 Redis 的 pub/sub 通知释放)能减少无效轮询,但引入了新的失败模式:通知丢失时会一直等到超时。所以即使用了通知,也得保留一个兜底的轮询周期。

直接失败在很多场景下反而是最好的选择。用户点了两次提交按钮,第二次直接返回"处理中",比让它排队等 3 秒更合理。

七、什么时候根本不需要分布式锁

这是我觉得最值得说的一条。相当一部分"需要分布式锁"的场景,其实有更简单、更可靠的做法:

唯一索引。 防重复下单,与其加锁,不如在订单表上给 (user_id, request_id) 建唯一索引,重复插入直接报错。数据库给的是真正的强一致保证,不依赖任何超时。

乐观锁 / CAS。 扣库存用 UPDATE stock SET num = num - 1 WHERE id = ? AND num >= 1,受影响行数为 0 就是扣减失败。一条 SQL,不需要任何外部组件。

数据库行锁。 SELECT ... FOR UPDATE 在同一个数据库内就是天然的互斥,而且事务提交/回滚时自动释放,没有超时问题。只要参与方都连同一个库,它比分布式锁可靠得多。

单分区串行化。 把同一个业务主键的消息路由到 MQ 的同一个分区,消费端单线程处理。天然串行,不需要锁。

幂等设计。 很多时候我们加锁是为了"不要重复执行",但如果操作本身是幂等的,重复执行就不是问题,锁也就不必要了。

我的经验是:先问"能不能用唯一索引或者 CAS 解决",不能再考虑分布式锁。 分布式锁是最后的手段,不是第一反应。

八、说点这套东西做不到的

它不能替代事务。 锁保证的是"同一时刻只有一个执行者",不保证"这个执行者做的多个操作要么全成要么全败"。跨库跨服务的一致性还是得靠事务消息、TCC、Saga 那一套。

它不能保证公平。 上面所有方案都是"抢到算"。如果业务要求先到先得,需要额外做排队(比如 ZK 的临时顺序节点),复杂度又上一个台阶。

它对时钟仍然有依赖。 只要用了 TTL,就依赖各节点对"多久"的判断大致一致。彻底摆脱时钟依赖只能靠 fencing。

看门狗不是万能的。 它把问题从"业务超时"挪到了"进程假死"。一个 STW 十几秒的 Full GC,看门狗线程也一起停了,锁照样过期。

测不出来。 这类问题在压测里几乎复现不出来,它们只在生产环境的长尾场景里出现——某次网络抖动、某次 GC、某次机房切换。所以设计时就得假设它会发生,而不是等它出现再修。

最后

把上面的东西收敛成一句话:Redis 分布式锁是一个"大概率互斥"的机制,不是一个"绝对互斥"的机制。

接受这个前提之后,正确的用法是清楚的:用它来降低冲突概率、保护性能,而把正确性交给下游的唯一索引、CAS 或者幂等设计。反过来,如果你的系统正确性完全压在这把锁上,那不管用单节点、主从还是 Redlock,都只是把出错概率调小,没有消除它。