分布式锁设计目标

事故背景

2025年双十一,我们秒杀系统的库存扣减出了大问题。

0点5分,商品 A 的库存显示还有 100 件,但订单系统统计出的实际成交数达到了 143 单。超卖了 43 件,涉及金额超过 2 万元。

排查了整整 4 个小时后,真相浮出水面:团队用了 Redis 做库存扣减,但代码里只有一行 DECR,没有任何锁保护。在高并发下,三个节点的 143 次 DECR 并发执行,实际变成了"先读后写"的竞态条件。

这不是 Redis 的问题,是分布式锁设计缺失的问题。

今天这篇,我们来把分布式锁的五大设计目标全部讲清楚:互斥性、死锁避免、活锁规避、公平性、容错性。这五个目标不是你选哪个的问题,而是你必须全部满足的底线。

一、互斥性:锁的根本

互斥性是分布式锁最基本的要求:在任何时刻,只能有一个客户端持有锁。

听起来简单,但实现起来暗坑无数。

最朴素的实现

// 错误示范:先判断后设置,不是原子操作
if (redis.get("lock:product:123") == null) {
    redis.set("lock:product:123", "client-1");
    // 业务逻辑
    redis.del("lock:product:123");
}

这段代码在单机单线程下勉强能用,但在分布式环境下,线程 A 判断 get 返回 null 的瞬间,线程 B 也判断 get 返回 null,然后两个线程都会进入临界区。超卖就这么来的。

原子性保证

// ✅ 正确示范:SETNX 原子操作
Boolean success = redis.set(key, value, "NX", "PX", 30000);
if (success) {
    // 临界区
    redis.del(key);
}

SET key value NX PX 30000 是原子操作:只有当 key 不存在时才能设置成功。这个原子性保证了互斥——要么拿到锁,要么拿不到,不存在"都拿到了"的中间态。

【架构权衡】

互斥性的实现有两条路:

  1. 单节点原子操作:用 Redis SETNX、数据库唯一索引、ZooKeeper 的临时有序节点。优点是实现简单、性能好;缺点是单点故障风险(除非配合 failover)。

  2. 多节点共识算法:用 Raft/Paxos 共识协议在多个节点上达成一致。优点是容错性强,不存在单点;缺点是实现复杂、性能开销大(3节点共识需要2个节点确认)。

大多数业务场景选第一条路,用单节点 Redis + 主从切换 + 锁续期就能cover住 99.9% 的场景。除非你做的是金融级别的强一致锁,否则不要过度设计。

💡

互斥性的本质是"原子性",不是"加锁"。很多人把"加锁"理解为"我先问问能不能加",但正确的姿势是"直接动手,失败了就算"。SETNX 就是这个思路。

二、死锁避免:锁必须有超时

如果一个客户端拿到锁之后崩溃了,没来得及释放锁,会发生什么?

其他所有客户端都会永久等待这把锁——死锁。

单机 JVM 里可以用 Thread.interrupt() 或者守护线程来解决,但在分布式环境下,进程崩溃后没有任何机制能自动释放锁。唯一的办法是锁必须自带超时

TTL 机制

// 锁自动在 30 秒后过期
redis.set("lock:order:456", "client-abc", "NX", "PX", 30000);

即使客户端崩溃,30 秒后锁也会自动释放,其他客户端可以继续获取。这是最简单也是最有效的死锁避免方案。

TTL 的代价

但 TTL 引入了一个新问题:锁的持有时间必须小于 TTL

// 危险操作:锁的 TTL 是 30 秒,但业务逻辑可能执行 45 秒
redis.set("lock:order:456", "client-abc", "NX", "PX", 30000);
try {
    Thread.sleep(45000); // 模拟耗时操作
} finally {
    redis.del("lock:order:456");
}

当第 30 秒锁自动过期后,其他客户端会拿到同一把锁,两个客户端同时进入临界区。到第 45 秒时,第一个客户端执行 del,把第二个客户端刚拿到的锁给删了。

这就是锁续期(看门狗)机制诞生的原因。

⚠️

TTL 设计是分布式锁里最容易出错的地方。TTL 太短,业务还没执行完就过期;TTL 太长,节点故障后其他节点要等很久才能拿到锁。常见做法是设置一个保守的 TTL(如 30 秒),然后启动一个后台线程不断续期。

三、活锁规避:重试要有策略

当一个客户端拿不到锁时,应该怎么办?

无脑重试是最差的选择:

// 错误示范:无脑重试,高并发下会形成"惊群效应"
while (true) {
    if (redis.set(key, value, "NX", "PX", 30000)) {
        break;
    }
    Thread.sleep(100); // 100ms 重试一次
}

1000 个并发请求同时抢锁,每个请求都无脑重试 100ms,Redis 会被瞬间打爆。而且两个客户端可能同时重试、同时失败、同时再重试,永远拿不到锁——这就是活锁。

退避重试策略

// ✅ 正确示范:指数退避 + 随机 jitter
int baseDelay = 10;  // 基础延迟 10ms
int maxDelay = 500;  // 最大延迟 500ms
int maxRetries = 3;

for (int i = 0; i < maxRetries; i++) {
    if (redis.set(key, value, "NX", "PX", 30000)) {
        return; // 拿到锁
    }
    // 指数退避 + 随机 jitter,打散重试时间
    int delay = Math.min(baseDelay * (1 << i) + new Random().nextInt(100), maxDelay);
    Thread.sleep(delay);
}
throw new LockAcquireException("Failed to acquire lock after " + maxRetries + " retries");

关键点:

  • 指数退避:每次重试的等待时间翻倍,避免频繁碰撞
  • 随机 jitter:在同一时刻抢锁的客户端,下次重试时间分散开,减少碰撞概率
  • 最大重试次数:防止无限重试浪费资源

自旋锁的适用场景

退避重试策略适合锁持有时间较长、并发量中等的场景。但如果锁持有时间极短(比如几十毫秒),用自旋反而更高效:

// 适用于短临界区的自旋锁
for (int i = 0; i < 100; i++) {
    if (redis.set(key, value, "NX", "PX", 30000)) {
        return;
    }
    Thread.sleep(5); // 5ms 空转 100 次 = 最多等待 500ms
}

【架构权衡】

活锁规避的核心是重试策略的选择

策略适用场景代价
自旋重试锁持有时间 < 50ms,并发量低CPU 空转
指数退避 + jitter通用场景延迟增加
放弃重试,立即失败不允许等待的场景可能丢失业务

四、公平性:先到先得

分布式锁的公平性指的是:客户端获取锁的顺序是否按照请求到达的先后顺序。

非公平锁的问题

Redis 的 SETNX 是非公平锁:

时刻 0ms:  客户端 A 请求锁
时刻 1ms:  客户端 B 请求锁(持有锁的 A 还没释放)
时刻 2ms:  锁被 A 释放
时刻 3ms:  B 拿到锁
时刻 4ms:  客户端 C 请求锁
时刻 5ms:  C 拿到锁(绕过了等待中的 B)

客户端 C 在 B 之后请求,却比 B 先拿到锁。这就是非公平锁的"插队"现象。

公平锁的实现

如果业务对公平性有要求,可以用 Redis 的 Sorted Set 实现队列:

// 公平锁:按请求顺序排队
public boolean tryLock(String key, String value, long timeout) {
    // 1. 所有人都加入同一个 Sorted Set
    long rank = redis.zadd(key + ":queue", System.nanoTime(), value);

    // 2. 只有排名最靠前的人能拿锁
    if (rank == 0) {
        redis.set(key, value, "NX", "PX", 30000);
        return true;
    }

    // 3. 等待前一个节点释放锁
    String predecessor = redis.zrange(key + ":queue", 0, 1).get(1);
    while (redis.get(key + ":owner").equals(predecessor)) {
        Thread.sleep(10);
    }
    return redis.set(key, value, "NX", "PX", 30000);
}

【架构权衡】

公平性是要付出代价的:

  • 非公平锁性能更好,吞吐量高,但可能出现饥饿(Starvation)
  • 公平锁保证先到先得,但需要一个队列来管理等待者,增加了复杂度和延迟

大多数业务场景不需要严格的公平性。非公平锁 + 随机退避已经足够好了。除非你的场景是"抢单"、"限时抢购"这类对顺序敏感的业务,否则不要牺牲性能去追求公平性。

五、可重入性:同一线程多次获取

同一个线程能不能多次获取同一把锁?

单机 JVM 里的 ReentrantLock 支持可重入,但分布式环境下这很难实现,因为没有共享的线程上下文。

可重入的实现

// 基于 ThreadLocal + 引用计数实现可重入
public boolean tryLock(String key, String value) {
    String currentHolder = redis.get(key);
    String threadId = Thread.currentThread().getId();

    // 如果是自己持有,则重入
    if (value.equals(currentHolder) && threadId.equals(threadLocal.get())) {
        int count = reentrantCount.get() + 1;
        reentrantCount.set(count);
        return true;
    }

    // 否则尝试获取
    if (redis.set(key, value, "NX", "PX", 30000)) {
        threadLocal.set(threadId);
        reentrantCount.set(1);
        return true;
    }
    return false;
}

public void unlock(String key) {
    int count = reentrantCount.get();
    if (count > 1) {
        reentrantCount.set(count - 1);
    } else {
        redis.del(key);
        reentrantCount.remove();
    }
}
📖 点击展开:可重入性的边界场景

可重入性在分布式环境下面临一个根本性挑战:没有跨节点的线程标识。一个线程在节点 A 持有锁后,调用了节点 B 的方法,节点 B 无法识别这个调用来自持有锁的线程。

常见解决方案:

  1. 将线程 ID 作为锁值的一部分传递(需要在调用链中透传)
  2. 使用分布式事务上下文 ID 代替线程 ID
  3. 放弃可重入,改为每次调用都重新获取锁(最安全)

六、容错性:节点挂了怎么办

分布式锁的容错性是指:当锁服务(Redis/ ZooKeeper)部分节点故障时,锁服务能否继续正常工作。

单节点 Redis 的容错问题

主节点宕机 → 从节点还没同步到最新锁数据 → 新的客户端从从节点读到"无锁" → 同一把锁被两个客户端持有

这是 Redis 主从模式下分布式锁的经典问题。SETNX 加主节点,拿锁成功。但主节点宕机后,从节点晋升为主,但从节点没有这条锁记录。此时另一个客户端从新主节点读不到锁,又会拿到同一把锁。

【架构权衡】

容错性方案的成本差异巨大:

方案复杂度性能可靠性适用场景
单节点 Redis最高开发测试、对可用性要求不高的内部系统
Redis 主从 + 故障转移大多数生产场景
RedLock(多节点共识)对可靠性要求极高的场景
etcd / ZooKeeper需要强一致性的场景

生产避坑清单

  1. 不要在 finally 里无条件 del:只能用 Lua 脚本检查 value 匹配后再删除,否则会误删其他客户端的锁
  2. 锁的 TTL 要大于业务执行时间的最大值:加上安全 margin,建议 2~3 倍
  3. 高并发场景下先预估锁竞争程度:竞争激烈时用退避重试,竞争不激烈时直接 SETNX
  4. Redis 锁不适合锁超时很长的场景:例如批处理任务(可能运行 1 小时),这种场景用 ZooKeeper 更合适
  5. 业务上要有兜底:即使加了分布式锁,也要做好降级预案,因为锁本身也可能失败

工程代价评估

维度评估
开发成本中等(正确实现一个生产级分布式锁需要处理 10+ 个边界条件)
运维成本低(Redis/etcd/ZooKeeper 都有成熟托管服务)
排障复杂度高(锁竞争、锁超时、锁失效的线上问题极难排查)
扩展性好(大多数方案都支持水平扩展)
回滚风险低(锁是幂等的,释放后系统自动恢复)

分布式锁的五大设计目标——互斥性、死锁避免、活锁规避、公平性、可重入性——不是选择题,而是都要满足的及格线。下篇文章我们从最简单的方案开始:数据库乐观锁。