13、分布式锁模式:基于数据库的锁、基于Redis的锁(Redlock)、基于ZooKeeper的锁、锁超时与续期
分布式锁,说白了就是让多个服务节点“排队干活”的机制。你想想看,在单机时代,我们用synchronized或者ReentrantLock就能搞定资源互斥。但到了微服务架构下,你的服务可能部署了10个实例,这时候怎么保证同一时刻只有一个节点在操作共享资源?嗯,这就是分布式锁要解决的问题。
我个人习惯把分布式锁的实现方案分成三大流派:数据库派、缓存派、协调派。每种方案都有它的脾气,选错了可是要出大问题的。今天我就把这三种方案掰开揉碎了讲清楚,顺便聊聊锁超时和续期那些坑。
13.1 基于数据库的锁
先说说最朴素的做法——用数据库来实现锁。我记得刚做分布式系统那会儿,团队里有人直接往MySQL里insert一条记录当锁用,当时觉得挺聪明的,后来才发现坑不少。
13.1.1 表锁方案
最简单的做法就是建一张锁表:
CREATE TABLE distributed_lock (
lock_name VARCHAR(64) PRIMARY KEY,
owner_id VARCHAR(64) NOT NULL,
expire_time TIMESTAMP NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
获取锁的时候,尝试insert一条记录:
INSERT INTO distributed_lock(lock_name, owner_id, expire_time)
VALUES('resource_001', 'instance_001', '2024-01-01 12:00:00');
如果insert成功,说明拿到了锁。释放锁就是delete这条记录。但这里有个问题——如果持有锁的节点挂了,这条记录就永远留在表里了。所以必须加过期时间,并且获取锁的节点要定期续期。
13.1.2 乐观锁方案
另一种做法是用乐观锁,通过版本号来控制:
UPDATE resource SET version = version + 1
WHERE id = 100 AND version = 5;
如果影响行数为1,说明更新成功,相当于拿到了锁。这种方式避免了建锁表,但只适合对单个资源的操作。而且乐观锁在冲突频繁的场景下,重试成本很高。
13.2 基于Redis的锁(Redlock)
Redis锁是目前最主流的方案。为什么?因为Redis快啊,单机QPS能到10万+。但快归快,Redis锁的坑也不少。
13.2.1 单机Redis锁
最简单的Redis锁用SETNX命令:
SET resource_001 random_value NX PX 30000
这里有几个关键点:
- NX:只有key不存在时才设置,保证互斥
- PX 30000:设置30秒过期时间,防止死锁
- random_value:随机值,用于安全释放锁,防止误删别人的锁
释放锁的时候,要用Lua脚本保证原子性:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
为什么要用随机值?我遇到过这样一个事故:线程A拿到锁后业务处理超时,锁自动释放了。线程B拿到锁开始处理。这时候线程A处理完了,直接del释放锁——结果把线程B的锁给删了。嗯,这就是典型的“删错锁”问题。用随机值就能避免。
13.2.2 Redlock算法
单机Redis有个致命问题——如果Redis挂了,锁就丢了。你可能会说:“Redis有主从啊!”但主从切换是有延迟的,在切换期间,两个节点可能同时认为自己是主节点,导致锁失效。
Redlock算法就是为了解决这个问题。它的思路很简单:在多个独立的Redis节点上同时加锁,超过半数节点加锁成功,就算拿到锁。
Redlock的核心步骤:
- 获取当前时间戳T1
- 依次向N个Redis节点加锁(通常N=5),每个加锁操作都有超时时间
- 计算加锁耗时:elapsed = 当前时间 - T1
- 如果成功加锁的节点数 >= N/2 + 1,且elapsed < 锁过期时间,则加锁成功
- 加锁成功后,锁的实际有效时间 = 原始过期时间 - elapsed
这里我画了一张流程图,帮你理解Redlock的完整流程:
我的建议: Redlock虽然解决了单点问题,但实现起来比较复杂。如果你的业务场景对锁的可靠性要求不是特别高(比如允许偶尔的锁失效),用单机Redis加主从就够了。Redlock更适合金融、交易这类强一致性场景。
13.3 基于ZooKeeper的锁
ZooKeeper实现分布式锁,靠的是它的临时顺序节点。为什么用ZooKeeper?因为它天生就是干这个的——强一致性、高可用、自带Watcher机制。
13.3.1 实现原理
ZooKeeper锁的核心思路是这样的:
- 在锁的目录下创建临时顺序节点,比如 /locks/resource_001/lock_000000001
- 获取当前目录下所有子节点,排序
- 如果自己是最小的那个节点,说明拿到了锁
- 如果不是最小的,就监听前一个节点的删除事件
- 前一个节点被删除后,重新检查自己是不是最小的
这种机制叫“排他锁”,也叫“写锁”。还有一种“读写锁”,读锁之间不互斥,写锁和所有锁互斥,实现起来稍微复杂一点。
13.3.2 羊群效应
ZooKeeper锁有个经典问题——羊群效应。什么意思?如果所有节点都监听同一个父节点,一旦父节点有变化,所有节点都会被唤醒,然后一起抢锁。这会造成大量的网络开销和ZooKeeper压力。
解决办法就是上面说的——每个节点只监听前一个节点。这样锁释放时,只有下一个节点被唤醒,其他节点继续等待。说白了,就是“排队叫号”,而不是“一窝蜂抢”。
我曾经踩过的坑: 有一次用ZooKeeper做分布式调度,锁的粒度太粗,所有任务都抢同一把锁。结果ZooKeeper集群的CPU直接飙到90%,因为频繁的节点创建和删除触发了大量的Watcher通知。后来改成细粒度锁,每个任务有自己的锁路径,问题就解决了。
13.4 锁超时与续期
锁超时和续期,是分布式锁里最容易出问题的地方。说白了,锁的过期时间设短了,业务还没处理完锁就释放了;设长了,万一持有锁的节点挂了,其他节点要等很久才能拿到锁。
13.4.1 续期机制
比较常见的做法是“看门狗”模式——启动一个后台线程,定期给锁续期。比如锁的过期时间是30秒,看门狗每10秒检查一次,如果锁还在,就延长过期时间。
Redis的Redisson客户端就内置了这个机制。它的续期逻辑是这样的:
// Redisson自动续期示例
RLock lock = redisson.getLock("resource_001");
lock.lock(30, TimeUnit.SECONDS); // 30秒后自动释放
// 实际上Redisson会启动一个定时任务,每10秒续期一次
try {
// 业务逻辑
} finally {
lock.unlock();
}
ZooKeeper的临时节点本身就有会话超时机制。只要客户端和ZooKeeper保持心跳,节点就不会被删除。一旦客户端挂了,会话结束,节点自动删除,锁也就释放了。所以ZooKeeper不需要额外的续期逻辑。
13.4.2 续期失败的处理
续期失败怎么办?比如网络抖动,看门狗线程没续上期,锁被释放了。这时候业务线程还在处理,突然发现锁没了——嗯,这就是典型的“锁丢失”问题。
我的建议是:
- 设置合理的过期时间:根据业务耗时来定,一般设为业务耗时的3-5倍
- 增加重试机制:续期失败时,尝试重试3次,每次间隔100ms
- 业务侧做幂等:即使锁丢失,业务操作也要保证幂等性,这是最后的防线
三种方案对比:
| 特性 | 数据库锁 | Redis锁 | ZooKeeper锁 |
|---|---|---|---|
| 性能 | 低(500 QPS左右) | 高(10万+ QPS) | 中(1万左右 QPS) |
| 可靠性 | 中(依赖数据库) | 中(依赖Redis) | 高(强一致性) |
| 实现复杂度 | 低 | 中 | 高 |
| 自动续期 | 需手动实现 | Redisson内置 | 会话机制自带 |
| 适用场景 | 低并发、简单场景 | 高并发、可接受短暂不一致 | 强一致性、金融级场景 |
最后说一句,没有完美的分布式锁方案。选型的时候,你得想清楚自己的业务到底需要什么——是要性能,还是要一致性?是要简单,还是要可靠?想明白了,选起来就不纠结了。