分布式锁设计目标
事故背景
2025年双十一,我们秒杀系统的库存扣减出了大问题。
0点5分,商品 A 的库存显示还有 100 件,但订单系统统计出的实际成交数达到了 143 单。超卖了 43 件,涉及金额超过 2 万元。
排查了整整 4 个小时后,真相浮出水面:团队用了 Redis 做库存扣减,但代码里只有一行 DECR,没有任何锁保护。在高并发下,三个节点的 143 次 DECR 并发执行,实际变成了"先读后写"的竞态条件。
这不是 Redis 的问题,是分布式锁设计缺失的问题。
今天这篇,我们来把分布式锁的五大设计目标全部讲清楚:互斥性、死锁避免、活锁规避、公平性、容错性。这五个目标不是你选哪个的问题,而是你必须全部满足的底线。
一、互斥性:锁的根本
互斥性是分布式锁最基本的要求:在任何时刻,只能有一个客户端持有锁。
听起来简单,但实现起来暗坑无数。
最朴素的实现
这段代码在单机单线程下勉强能用,但在分布式环境下,线程 A 判断 get 返回 null 的瞬间,线程 B 也判断 get 返回 null,然后两个线程都会进入临界区。超卖就这么来的。
原子性保证
SET key value NX PX 30000 是原子操作:只有当 key 不存在时才能设置成功。这个原子性保证了互斥——要么拿到锁,要么拿不到,不存在"都拿到了"的中间态。
【架构权衡】
互斥性的实现有两条路:
-
单节点原子操作:用 Redis
SETNX、数据库唯一索引、ZooKeeper 的临时有序节点。优点是实现简单、性能好;缺点是单点故障风险(除非配合 failover)。 -
多节点共识算法:用 Raft/Paxos 共识协议在多个节点上达成一致。优点是容错性强,不存在单点;缺点是实现复杂、性能开销大(3节点共识需要2个节点确认)。
大多数业务场景选第一条路,用单节点 Redis + 主从切换 + 锁续期就能cover住 99.9% 的场景。除非你做的是金融级别的强一致锁,否则不要过度设计。
互斥性的本质是"原子性",不是"加锁"。很多人把"加锁"理解为"我先问问能不能加",但正确的姿势是"直接动手,失败了就算"。SETNX 就是这个思路。
二、死锁避免:锁必须有超时
如果一个客户端拿到锁之后崩溃了,没来得及释放锁,会发生什么?
其他所有客户端都会永久等待这把锁——死锁。
单机 JVM 里可以用 Thread.interrupt() 或者守护线程来解决,但在分布式环境下,进程崩溃后没有任何机制能自动释放锁。唯一的办法是锁必须自带超时。
TTL 机制
即使客户端崩溃,30 秒后锁也会自动释放,其他客户端可以继续获取。这是最简单也是最有效的死锁避免方案。
TTL 的代价
但 TTL 引入了一个新问题:锁的持有时间必须小于 TTL。
当第 30 秒锁自动过期后,其他客户端会拿到同一把锁,两个客户端同时进入临界区。到第 45 秒时,第一个客户端执行 del,把第二个客户端刚拿到的锁给删了。
这就是锁续期(看门狗)机制诞生的原因。
TTL 设计是分布式锁里最容易出错的地方。TTL 太短,业务还没执行完就过期;TTL 太长,节点故障后其他节点要等很久才能拿到锁。常见做法是设置一个保守的 TTL(如 30 秒),然后启动一个后台线程不断续期。
三、活锁规避:重试要有策略
当一个客户端拿不到锁时,应该怎么办?
无脑重试是最差的选择:
1000 个并发请求同时抢锁,每个请求都无脑重试 100ms,Redis 会被瞬间打爆。而且两个客户端可能同时重试、同时失败、同时再重试,永远拿不到锁——这就是活锁。
退避重试策略
关键点:
- 指数退避:每次重试的等待时间翻倍,避免频繁碰撞
- 随机 jitter:在同一时刻抢锁的客户端,下次重试时间分散开,减少碰撞概率
- 最大重试次数:防止无限重试浪费资源
自旋锁的适用场景
退避重试策略适合锁持有时间较长、并发量中等的场景。但如果锁持有时间极短(比如几十毫秒),用自旋反而更高效:
【架构权衡】
活锁规避的核心是重试策略的选择:
四、公平性:先到先得
分布式锁的公平性指的是:客户端获取锁的顺序是否按照请求到达的先后顺序。
非公平锁的问题
Redis 的 SETNX 是非公平锁:
客户端 C 在 B 之后请求,却比 B 先拿到锁。这就是非公平锁的"插队"现象。
公平锁的实现
如果业务对公平性有要求,可以用 Redis 的 Sorted Set 实现队列:
【架构权衡】
公平性是要付出代价的:
- 非公平锁性能更好,吞吐量高,但可能出现饥饿(Starvation)
- 公平锁保证先到先得,但需要一个队列来管理等待者,增加了复杂度和延迟
大多数业务场景不需要严格的公平性。非公平锁 + 随机退避已经足够好了。除非你的场景是"抢单"、"限时抢购"这类对顺序敏感的业务,否则不要牺牲性能去追求公平性。
五、可重入性:同一线程多次获取
同一个线程能不能多次获取同一把锁?
单机 JVM 里的 ReentrantLock 支持可重入,但分布式环境下这很难实现,因为没有共享的线程上下文。
可重入的实现
📖 点击展开:可重入性的边界场景
可重入性在分布式环境下面临一个根本性挑战:没有跨节点的线程标识。一个线程在节点 A 持有锁后,调用了节点 B 的方法,节点 B 无法识别这个调用来自持有锁的线程。
常见解决方案:
- 将线程 ID 作为锁值的一部分传递(需要在调用链中透传)
- 使用分布式事务上下文 ID 代替线程 ID
- 放弃可重入,改为每次调用都重新获取锁(最安全)
六、容错性:节点挂了怎么办
分布式锁的容错性是指:当锁服务(Redis/ ZooKeeper)部分节点故障时,锁服务能否继续正常工作。
单节点 Redis 的容错问题
这是 Redis 主从模式下分布式锁的经典问题。SETNX 加主节点,拿锁成功。但主节点宕机后,从节点晋升为主,但从节点没有这条锁记录。此时另一个客户端从新主节点读不到锁,又会拿到同一把锁。
【架构权衡】
容错性方案的成本差异巨大:
生产避坑清单
- 不要在 finally 里无条件 del:只能用 Lua 脚本检查 value 匹配后再删除,否则会误删其他客户端的锁
- 锁的 TTL 要大于业务执行时间的最大值:加上安全 margin,建议 2~3 倍
- 高并发场景下先预估锁竞争程度:竞争激烈时用退避重试,竞争不激烈时直接 SETNX
- Redis 锁不适合锁超时很长的场景:例如批处理任务(可能运行 1 小时),这种场景用 ZooKeeper 更合适
- 业务上要有兜底:即使加了分布式锁,也要做好降级预案,因为锁本身也可能失败
工程代价评估
分布式锁的五大设计目标——互斥性、死锁避免、活锁规避、公平性、可重入性——不是选择题,而是都要满足的及格线。下篇文章我们从最简单的方案开始:数据库乐观锁。