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这条记录。但这里有个问题——如果持有锁的节点挂了,这条记录就永远留在表里了。所以必须加过期时间,并且获取锁的节点要定期续期。

我曾经踩过的坑: 用数据库做锁,性能瓶颈非常明显。有一次压测,QPS刚到500,数据库连接池就爆了。而且数据库的锁机制本身就有行锁、表锁的问题,搞不好还会死锁。说白了,数据库锁只适合低并发场景,别想着用它扛高流量。

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的核心步骤:

  1. 获取当前时间戳T1
  2. 依次向N个Redis节点加锁(通常N=5),每个加锁操作都有超时时间
  3. 计算加锁耗时:elapsed = 当前时间 - T1
  4. 如果成功加锁的节点数 >= N/2 + 1,且elapsed < 锁过期时间,则加锁成功
  5. 加锁成功后,锁的实际有效时间 = 原始过期时间 - elapsed

这里我画了一张流程图,帮你理解Redlock的完整流程:

Redlock算法流程图 客户端请求加锁 获取当前时间戳 T1 依次向5个Redis节点加锁(带超时) 加锁成功数 >= 3 ? 计算耗时 elapsed elapsed < 过期时间? 加锁成功 释放所有已加锁的节点 加锁失败

我的建议: Redlock虽然解决了单点问题,但实现起来比较复杂。如果你的业务场景对锁的可靠性要求不是特别高(比如允许偶尔的锁失效),用单机Redis加主从就够了。Redlock更适合金融、交易这类强一致性场景。

13.3 基于ZooKeeper的锁

ZooKeeper实现分布式锁,靠的是它的临时顺序节点。为什么用ZooKeeper?因为它天生就是干这个的——强一致性、高可用、自带Watcher机制。

13.3.1 实现原理

ZooKeeper锁的核心思路是这样的:

  1. 在锁的目录下创建临时顺序节点,比如 /locks/resource_001/lock_000000001
  2. 获取当前目录下所有子节点,排序
  3. 如果自己是最小的那个节点,说明拿到了锁
  4. 如果不是最小的,就监听前一个节点的删除事件
  5. 前一个节点被删除后,重新检查自己是不是最小的

这种机制叫“排他锁”,也叫“写锁”。还有一种“读写锁”,读锁之间不互斥,写锁和所有锁互斥,实现起来稍微复杂一点。

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内置 会话机制自带
适用场景 低并发、简单场景 高并发、可接受短暂不一致 强一致性、金融级场景

最后说一句,没有完美的分布式锁方案。选型的时候,你得想清楚自己的业务到底需要什么——是要性能,还是要一致性?是要简单,还是要可靠?想明白了,选起来就不纠结了。

公众号:蓝海资料掘金营,微信deep3321