Skip to content

分布式锁应满足的条件:互斥、可重入、死锁避免、高性能

凌晨 2 点,你盯着监控大屏,看着两台服务器的日志疯狂交错——

[Server-A] 开始扣减库存...
[Server-B] 开始扣减库存...
[Server-A] 库存不足!剩余: 0
[Server-B] 库存不足!剩余: -1
[Server-A] 订单创建成功
[Server-B] 订单创建成功

超卖事故,100 个库存,卖出了 105 单。

你看了眼代码——synchronized(this),没毛病啊。

问题在哪?你的锁只锁住了同一台 JVM,但订单服务跑在两台机器上。

这就是分布式锁的由来。

单机锁为什么在集群环境下失效

JVM 内置的锁(synchronizedReentrantLock)依赖的是进程内存。线程 A 获取了锁,线程 B 怎么知道?因为它们共享同一块堆内存。

但当你的服务部署成集群时:

  • Server-A 上的线程持有锁,写在自己的 JVM 堆里
  • Server-B 上的线程完全感知不到,它只看到自己的 JVM 堆

两台机器,两块独立的内存空间,两个独立的锁——你以为自己加了锁,实际上什么都没锁住。

分布式锁的本质是:把锁的状态放到一个所有节点都能访问到的地方,比如 Redis、ZooKeeper、etcd,或者 MySQL。

分布式锁的五大必要条件

不是所有「看起来像锁」的东西都是合格的分布式锁。一个合格的分布式锁必须满足以下条件:

条件一:互斥性

这是锁的核心。任意时刻,最多只有一个客户端能持有锁。

如果两个客户端同时获取到了同一把锁,那锁就失去了意义。Redis 的 SETNX、ZooKeeper 的临时节点、MySQL 的唯一索引——都是实现互斥的手段。

条件二:不会死锁

单机 JVM 中,即使线程崩溃,持有锁的线程也会随着进程一起消失,锁自然释放。但在分布式环境中,一个节点崩溃不代表锁会被释放。

你一定见过这种情况:服务突然重启,持有着锁的进程消失,锁永远不会被释放——这就是死锁

解决方案是TTL(Time-To-Live)机制:给锁加一个「保质期」,即使持有者崩溃,锁也会在过期后自动释放。

java
// Redis SETNX + EXPIRE 的常见问题
// 如果 SETNX 成功后、EXPIRE 执行前进程崩溃了?
// 锁永远不会过期 —— 经典的死锁场景

条件三:可重入

什么叫可重入?同一个线程,在持有锁的情况下,再次请求获取同一把锁,不会被自己堵住。

这在递归调用和嵌套方法调用中非常常见:

java
public class OrderService {
    private final DistributedLock lock;

    public void createOrder(Order order) {
        lock.lock();
        try {
            // 内部调用了同一个类的另一个加锁方法
            checkInventory(order);  // 这里会死锁吗?
            saveOrder(order);
        } finally {
            lock.unlock();
        }
    }

    public void checkInventory(Order order) {
        lock.lock();
        try {
            // 检查库存逻辑
        } finally {
            lock.unlock();
        }
    }
}

如果没有可重入,同一个线程调用自己的加锁方法,就会把自己锁死。可重入锁通过记录持有者的身份和计数来解决这个问题。

条件四:公平性(可选)

公平锁严格按照请求顺序获取锁(FIFO),非公平锁则「谁抢到算谁的」。

请求顺序: 客户端A → 客户端B → 客户端C
锁释放时:

公平锁: 一定分配给 A
非公平锁: 可能分配给任意一个(取决于竞争情况)

公平锁的好处是不会有线程「饿死」,坏处是吞吐量会下降(因为要维护等待队列)。

条件五:高性能

锁是用来保护共享资源的,但它本身也可能成为瓶颈。

如果获取一次锁需要 100ms,那你的系统并发能力再高也没用——线程都在等锁。

分布式锁的操作必须是高效的

  • 网络往返时间要短(优先选本地机房或同区域部署)
  • 锁的粒度要合理(别把整张表都锁住)
  • 使用高性能的存储(Redis > MySQL > ZooKeeper)

分布式锁的四大可选特性

必要条件是底线,可选特性决定了你在实际场景中用得爽不爽。

特性一:阻塞获取 vs 非阻塞获取

非阻塞获取:尝试一次,获取不到就放弃,立即返回失败。

java
boolean locked = lock.tryLock();
if (!locked) {
    // 走降级逻辑
}

阻塞获取:获取不到就等着,直到获取成功或超时。

java
lock.lock();
try {
    // 业务逻辑
} finally {
    lock.unlock();
}

两种模式适用于不同场景:

  • 非阻塞适合「抢不到就算了」的削峰场景
  • 阻塞适合「必须拿到才能继续」的关键业务

特性二:锁值可验证

释放锁时,必须验证「当前释放者是否是锁的持有者」。否则可能误删他人的锁。

典型事故:

时刻 T1: 客户端A 获取锁,设置 value = "A"
时刻 T2: 锁过期,Redis 自动删除
时刻 T3: 客户端B 获取锁,设置 value = "B"
时刻 T4: 客户端A 执行完毕,释放锁,DELETE key
时刻 T5: 客户端B 还在执行,但锁已经被 A 释放了
时刻 T6: 客户端C 获取锁...

时刻 T4 是灾难——A 删掉了 B 的锁。正确做法是先检查 value 是否等于自己的标识,再删除:

lua
-- Lua 脚本:检查 + 删除的原子操作
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

特性三:自动续期

锁有 TTL,但业务执行时间可能超过 TTL。

比如你评估锁的 TTL 为 30 秒,但某个慢查询导致业务执行了 45 秒——锁在第 30 秒被释放,另一个客户端拿到了锁,两个客户端同时执行。

看门狗机制(Watch Dog)就是为了解决这个问题:

  • 持有锁后,启动一个后台线程
  • 每隔 TTL/3 时间,检查锁是否还被持有
  • 如果是,自动延长 TTL
  • 直到锁被显式释放或持有者崩溃

Redisson 就是用这种方式实现的。

特性四:公平 vs 非公平

前面已经讲过,核心权衡是「吞吐量」vs「无饥饿」。

选公平锁的场景:

  • 需要严格按顺序执行的业务(如秒杀抢购排队)
  • 不允许某些客户端永远抢不到锁

选非公平锁的场景:

  • 追求高吞吐量
  • 锁持有时间短,竞争不激烈

一个分布式锁的接口设计

说了这么多,来看看一个完整的分布式锁接口应该长什么样:

java
public interface DistributedLock {

    /**
     * 阻塞获取锁,一直等待直到获取成功
     */
    void lock();

    /**
     * 阻塞获取锁,最多等待 timeout 时间
     * @param timeout 最大等待时间
     * @return 是否获取成功
     */
    boolean tryLock(long timeout, TimeUnit unit);

    /**
     * 非阻塞获取锁,立即返回
     * @return 是否获取成功
     */
    boolean tryLock();

    /**
     * 释放锁(只释放自己持有的锁)
     */
    void unlock();

    /**
     * 判断当前线程是否持有锁
     */
    boolean isHeldByCurrentThread();
}

这个接口参考了 java.util.concurrent.locks.Lock,语义对齐的好处是切换实现时代码改动最小。

面试追问方向

  • 为什么单机 synchronized 不能用于分布式环境?它是锁什么的?
  • 如果锁没有 TTL 也没有自动续期,持有者崩溃后会怎样?
  • 可重入锁在单机和分布式的实现有什么区别?
  • 公平锁和非公平锁的性能差距有多大?什么场景必须用公平锁?

总结

分布式锁不是一把万能钥匙,它有五大必要条件和四大可选特性。

选择哪种实现(Redis、ZooKeeper、etcd),取决于你的业务场景对性能可靠性的权衡。

记住一个原则:没有最好的分布式锁,只有最适合业务场景的分布式锁。

基于 VitePress 构建