分布式锁应满足的条件:互斥、可重入、死锁避免、高性能
凌晨 2 点,你盯着监控大屏,看着两台服务器的日志疯狂交错——
[Server-A] 开始扣减库存...
[Server-B] 开始扣减库存...
[Server-A] 库存不足!剩余: 0
[Server-B] 库存不足!剩余: -1
[Server-A] 订单创建成功
[Server-B] 订单创建成功超卖事故,100 个库存,卖出了 105 单。
你看了眼代码——synchronized(this),没毛病啊。
问题在哪?你的锁只锁住了同一台 JVM,但订单服务跑在两台机器上。
这就是分布式锁的由来。
单机锁为什么在集群环境下失效
JVM 内置的锁(synchronized、ReentrantLock)依赖的是进程内存。线程 A 获取了锁,线程 B 怎么知道?因为它们共享同一块堆内存。
但当你的服务部署成集群时:
- Server-A 上的线程持有锁,写在自己的 JVM 堆里
- Server-B 上的线程完全感知不到,它只看到自己的 JVM 堆
两台机器,两块独立的内存空间,两个独立的锁——你以为自己加了锁,实际上什么都没锁住。
分布式锁的本质是:把锁的状态放到一个所有节点都能访问到的地方,比如 Redis、ZooKeeper、etcd,或者 MySQL。
分布式锁的五大必要条件
不是所有「看起来像锁」的东西都是合格的分布式锁。一个合格的分布式锁必须满足以下条件:
条件一:互斥性
这是锁的核心。任意时刻,最多只有一个客户端能持有锁。
如果两个客户端同时获取到了同一把锁,那锁就失去了意义。Redis 的 SETNX、ZooKeeper 的临时节点、MySQL 的唯一索引——都是实现互斥的手段。
条件二:不会死锁
单机 JVM 中,即使线程崩溃,持有锁的线程也会随着进程一起消失,锁自然释放。但在分布式环境中,一个节点崩溃不代表锁会被释放。
你一定见过这种情况:服务突然重启,持有着锁的进程消失,锁永远不会被释放——这就是死锁。
解决方案是TTL(Time-To-Live)机制:给锁加一个「保质期」,即使持有者崩溃,锁也会在过期后自动释放。
// Redis SETNX + EXPIRE 的常见问题
// 如果 SETNX 成功后、EXPIRE 执行前进程崩溃了?
// 锁永远不会过期 —— 经典的死锁场景条件三:可重入
什么叫可重入?同一个线程,在持有锁的情况下,再次请求获取同一把锁,不会被自己堵住。
这在递归调用和嵌套方法调用中非常常见:
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 非阻塞获取
非阻塞获取:尝试一次,获取不到就放弃,立即返回失败。
boolean locked = lock.tryLock();
if (!locked) {
// 走降级逻辑
}阻塞获取:获取不到就等着,直到获取成功或超时。
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 脚本:检查 + 删除的原子操作
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「无饥饿」。
选公平锁的场景:
- 需要严格按顺序执行的业务(如秒杀抢购排队)
- 不允许某些客户端永远抢不到锁
选非公平锁的场景:
- 追求高吞吐量
- 锁持有时间短,竞争不激烈
一个分布式锁的接口设计
说了这么多,来看看一个完整的分布式锁接口应该长什么样:
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),取决于你的业务场景对性能和可靠性的权衡。
记住一个原则:没有最好的分布式锁,只有最适合业务场景的分布式锁。
