1. 通过 Redis 实现分布式锁
当多个进程在不同的系统中,就需要使用分布式锁控制多个进程对同一个资源的访问。本篇介绍的是通过 Redis 实现的分布式锁。
为了确保分布式锁的可用性,我们至少要确保锁的实现同时满足以下四个条件:
- 互斥性;
- 不会发生死锁,即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁;
- 具有容错性,只要大部分的 Redis 节点正常运行,客户端就可以加锁和解锁;
- 加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
分布式锁实现方案:
- 数据库乐观锁;
- 通过 Redis 实现分布式锁,利用 Redis 的 setnx 命令来实现分布式锁;
- 通过 Zookeeper 实现分布式锁,利用 Zookeeper 的顺序临时节点实现分布式锁和等待队列;
优缺点:
分布式锁 | 优点 | 缺点 |
---|---|---|
Redis | set 和 del 指令的性能较高。 | 1、实现复杂,需要考虑超时、原子性、误删等情况; 2、没有等待的队列,只能在客户端自旋来等锁,效率低下。 |
Zookeeper | 1、有封装好的框架,容易实现; 2、有等待锁的队列,大大提升抢锁效率。 | 添加和删除节点性能较低。 |
通过 Redis 实现分布式锁的流程图:
Redis 没有等待锁的队列,只能在客户端自旋来等锁。这里 Redis 客户端我们选择 Jedis。
首先看几个 Redis 的 API:
API | 说明 | 时间复杂度 |
---|---|---|
setnx key value | 如果 key 不存在,则创建并赋值,返回 1;如果 key存在,则什么也不做,返回 0。 | O(1) |
expire key seconds | 设置 key 的生存时间,当 key 过期时(生存时间为 0),会被自动删除。 | O(1) |
getset key value | 必须 key 不存在,才设置 key-value。自动将key对应到value并且返回原来key对应的value。如果key存在但是对应的value不是字符串,就返回错误。 | O(1) |
set key value px milliseconds nx | 多参数的 set 命令。 | O(1) |
eval | 执行 Lua 代码。 | O(1) |
Redis 的单条命令都是原子性操作。
通过 Redis 实现分布式锁,加锁的代码实现:
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
* 加锁
* @param key 锁
* @param value 标识, 可以使用 UUID.randomUUID().toString() 方法生成
* @param expireTime 过期时间(s)
* @return
*/
public Boolean lock(String key, String value, int expireTime) {
String result = redisClient.set(key // 使用唯一key来当锁
value, // 保证哪个请求加的锁, 哪个请求去解锁
SET_IF_NOT_EXIST, // NX参数, 效果同 setnx 命令
SET_WITH_EXPIRE_TIME, // 增加过期时间, 具体时间由第五个参数决定
expireTime); // 具体时间
return LOCK_SUCCESS.equals(result);
}
1、设置过期时间的目的?
假如锁的持有者发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁,不会发生死锁。
2、为什么不通过 setnx 命令加锁,expire 命令设置超时时间呢,这种组合操作有什么问题?
因为两个命令的组合操作不是原子操作,如果 setnx 命令和 expire 命令之间客户端崩溃,那么就会发生死锁。
3、如果过期时间作 setnx 命令的 value 呢?
如果锁已经存在则获取锁的过期时间,和当前时间比较,如果已经过期,则设置新的过期时间,并返回加锁成功。这种实现方式要求分布式环境系统时钟必须同步,而且锁不具备持有者标识,即任何客户端都可以解锁。
解锁的代码实现:
/**
* 解锁
* @param key 锁
* @param value 标识
*/
public Boolean unlock(String key, String value) {
// Lua 脚本
// 获取锁对应的标识值, 检查是否与 value 相等,如果相等则删除 (解锁)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = redisClient.eval(script, Collections.singletonList(key), Collections.singletonList(value));
return RELEASE_SUCCESS.equals(result);
}
1、为什么要使用 Lua 语言来实现呢?
因为要确保解锁操作是原子操作。
2、为什么执行 eval 命令可以确保原子性?
在 eval 命令执行 Lua 代码的时候,Lua 代码将被当成一个命令去执行,并且直到 eval 命令执行完成,Redis 才会执行其他命令。
3、为什么不直接调用 del 命令删除 key 呢?
这种不先判断锁的持有者而直接删除的方式,会导致任何客户端都可以随意进行解锁,即使这把锁不是它的。
4、为什么不通过 get 命令获取标识,校验标识,再调用 del 命令删除 key 呢?
因为两个命令的组合操作不是原子操作,如果在执行 del 命令的时候,锁过期了且已经被其他客户端持有了,那么就会出现把别人的锁解了的情况。
如果你的项目中 Redis 是分布式部署的,那么可以尝试使用 Redisson 实现分布式锁,这是 Redis 官方提供的 Java 组件,链接在参考阅读章节已经给出。
参考:
Distributed locks with Redis https://redis.io/topics/distlock
EVAL command http://www.redis.cn/commands/eval.html
Redisson https://github.com/redisson/redisson
SETNX command http://www.redis.cn/commands/setnx.html
GETSET command http://www.redis.cn/commands/getset.html
2. 通过 Zookeeper 实现分布式锁
当多个进程在不同的系统中,就需要使用分布式锁控制多个进程对同一个资源的访问。本篇介绍的是通过 Zookeeper 实现的分布式锁。
为了确保分布式锁的可用性,我们至少要确保锁的实现同时满足以下四个条件:
- 互斥性;
- 不会发生死锁,即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁;
- 具有容错性,只要大部分的 Redis 节点正常运行,客户端就可以加锁和解锁;
- 加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
通过 Zookeeper 实现分布式锁的流程图:
等待释放其实就是将当前请求挂起。这里 Zookeeper 客户端我们选择 Curator。
Zookeeper 数据存储结构就像一棵树,这棵树由节点组成,这种节点叫做 znode。znode 分为四种类型:
说明 | |
---|---|
持久节点 | 默认节点类型,当创建节点的客户端与 zk 断开连接后,该节点依然存在。 |
持久节点顺序节点 | 在创建节点时,zk 根据创建的时间顺序给该节点名称进行编号。 |
临时节点 | 当创建节点的客户端与 zk 断开连接后,该节点会被删除。 |
临时节点顺序节点 | 在创建节点时,zk 根据创建的时间顺序给该节点名称进行编号。断开连接后会被删除。 |
1.Zookeeper分布式锁的原理
Zookeeper 分布式锁就是利用顺序临时节点实现了分布式锁和等待队列。
初始化:
- 在 zk 中创建一个持久节点 ParentLock;
Client1 获取锁:
- 在 ParentLock 节点下创建临时顺序节点 Lock1;
- Client1 查找 ParentLock 下所有的临时顺序节点并排序;
- 判断自己所创建的节点是不是最靠前的一个,如果是则成功获取锁。
Client2 获取锁:
- 在 ParentLock 节点下创建临时顺序节点 Lock2;
- Client2 查找 ParentLock 下所有的临时顺序节点并排序;
- 判断自己所创建的节点是不是最靠前的一个,结果发现不是;
- Client2 向排序仅比它靠前的节点 Lock1 注册 Watcher,用于监听 Lock1 节点是否存在,这也就意味着抢锁失败,进入了等待状态。
Client3 获取锁:
- 在 ParentLock 节点下创建临时顺序节点 Lock3;
- Client3 查找 ParentLock 下所有的临时顺序节点并排序;
- 判断自己所创建的节点是不是最靠前的一个,结果发现不是;
- Client3 向排序仅比它靠前的节点 Lock2 注册 Watcher,用于监听 Lock2 节点是否存在,这也就意味着抢锁失败,进入了等待状态。
这样一来,Client1 得到了锁,Client2 监听了 Lock1,Client3 监听了 Lock2,形成了一个等待队列。
Client1 释放锁:
- 显示调用删除 Lock1。
如果 Client1 没释放锁之前,崩溃了,怎么办?
- Client1 崩溃了则会断开与 Zookeeper 服务端的连接。根据临时节点的特性,相关联的节点 Lock1 会随之自动删除。
- 由于 Client2 一直监听着 Lock1 的存在状态,当 Lock1 节点被删除,Client2 会立刻收到通知,这时候 Client2 会再次查询 ParentLock 下面的所有节点,判断自己创建的节点 Lock2 是不是顺序最靠前的一个,如果是,则成功获取锁。
Curator Recipes 模块已经有了完善的分布式锁实现。Curator Lock 相关的实现在 recipes.locks 包里,顶级接口都是 InterProcessLock。
Curator Recipes 锁的几种方案,InterProcessLock 接口实现类:
InterProcessMutex:分布式可重入排它锁
InterProcessSemaphoreMutex:分布式排它锁
InterProcessReadWriteLock:分布式读写锁
InterProcessMultiLock:将多个锁作为单个实体管理的容器
2.InterProcessMutex源码解析
InterProcessMutex 通过在 zookeeper 的某路径节点下创建临时顺序节点来实现分布式锁,即每个线程(跨进程的线程)获取同一把锁前,都需要在同样的路径下创建一个节点,节点名字由 uuid + 递增序列组成。而通过对比自身的顺序是否在所有子节点的第一位,来判断是否成功获取到了锁。当获取锁失败时,它会添加 watcher 来监听前一个节点的变动情况,然后进行等待状态。直到 watcher 的事件生效将自己唤醒,或者超时时间异常返回。
1.获取锁
InterProcessMutex 的构造函数,验证入参 path 的合法性,实例化了 LockInternals 对象:
/**
* @param client curator实现的zookeeper客户端
* @param path 要在zookeeper加锁的路径,即后面创建临时节点的父节点
*/
public InterProcessMutex(CuratorFramework client, String path) {
// 传入了一个默认的 StandardLockInternalsDriver 对象,即标准的锁驱动类
this(client, path, new StandardLockInternalsDriver());
}
public InterProcessMutex(CuratorFramework client, String path,
LockInternalsDriver driver) {
this(client, path, LOCK_NAME, 1, driver);
}
InterProcessMutex(CuratorFramework client, String path,
String lockName, int maxLeases, LockInternalsDriver driver) {
basePath = PathUtils.validatePath(path);
internals = new LockInternals(client, driver, path, lockName, maxLeases);
}
实例化完成的 InterProcessMutex 对象,开始调用 InterProcessMutex 的 acquire() 方法来尝试加锁:
/**
* 入参为空,调用该方法后,会一直堵塞,直到抢夺到锁资源,或者zookeeper连接中断后,上抛异常。
*/
@Override
public void acquire() throws Exception {
if (!internalLock(-1, null)) {
throw new IOException("Lost connection while trying to acquire lock: " + basePath);
}
}
/**
* 抢夺时,如果出现堵塞,会在超过该时间后,返回false。
* @param time 超时时间
* @param unit 单位
*/
@Override
public boolean acquire(long time, TimeUnit unit) throws Exception {
return internalLock(time, unit);
}
推荐使用带超时时间的方法,避免出现大量的临时节点累积以及线程堵塞的问题。
2、可重入锁
继续跟着代码走,acquire() 方法调用了 internalLock() 方法,这段代码实现了锁的可重入,每个 InterProcessMutex 实例,都会持有一个 ConcurrentMap 类型的 threadData 对象,以线程对象作为 Key,以 LockData 作为 Value。通过判断当前线程 threadData 是否有值,如果有,则表示线程可以重入该锁,于是将 lockData 的 lockCount 进行累加;如果没有,则进行锁的抢夺。
internals.attemptLock 方法返回 lockPath!=null 时,表明了该线程已经成功持有了这把锁,于是 LockData 对象被 new 了出来,并存放到 threadData 中。
private boolean internalLock(long time, TimeUnit unit) throws Exception {
Thread currentThread = Thread.currentThread();
LockData lockData = threadData.get(currentThread);
if (lockData != null) {
// 重新进入
lockData.lockCount.incrementAndGet();
return true;
}
String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
if (lockPath != null) {
LockData newLockData = new LockData(currentThread, lockPath);
threadData.put(currentThread, newLockData);
return true;
}
return false;
}
3、抢夺锁
重头戏来了,LockInternals 的 attemptLock() 方法就是核心部分:
String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception {
...
// 正常情况下这个循环会在下一次结束。
// 但如果出现NoNodeException异常时,会根据zk客户端的重试策略,进行有限次数的重新获取锁。
while (!isDone) {
isDone = true;
try {
ourPath = driver.createsTheLock(client, path, localLockNodeBytes);
hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath);
} catch (KeeperException.NoNodeException e) { ...
}
}
if (hasTheLock) {
return ourPath;
}
return null;
}
driver.createsTheLock() 方法就是在创建这个锁,即在 zookeeper 的指定路径上,创建一个临时顺序节点。
下面看下 StandardLockInternalsDriver 的 createsTheLock() 方法:
@Override
public String createsTheLock(CuratorFramework client, String path, byte[] lockNodeBytes)
throws Exception {
String ourPath;
if (lockNodeBytes != null) {
ourPath = client.create().creatingParentContainersIfNeeded().withProtection()
.withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(path, lockNodeBytes);
} else {
ourPath = client.create().creatingParentContainersIfNeeded().withProtection()
.withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(path);
}
return ourPath;
}
LockInternals 的 internalLockLoop() 判断自身是否能够持有锁,如果不能,进入wait,等待被唤醒。
private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath)
throws Exception {
boolean haveTheLock = false;
boolean doDelete = false;
try {
if (revocable.get() != null) {
client.getData().usingWatcher(revocableWatcher).forPath(ourPath);
}
/*
* 如果最开始使用的是无参的acquire方法,那么此处的while循环可能就是一个死循环。
* 当zk客户端启动时,并且当前线程还没有成功获取到锁时,就会开始新的一轮循环。
*/
while ((client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock) {
// getSortedChildren()方法: 获取到所有子节点列表,并且从小到大根据节点名称后10位数字进行排序。
List<String> children = getSortedChildren();
String sequenceNodeName = ourPath.substring(basePath.length() + 1);
/*
* driver.getsTheLock: 判断是否可以持有锁.
* 判断规则:当前创建的节点是否在上一步获取到的子节点列表的首位。
* 如果是,说明可以持有锁,那么getsTheLock = true,封装进PredicateResults返回。
* 如果不是,说明有其他线程早已先持有了锁,那么getsTheLock = false,然后获取到
* 自己前一个临时节点的名称pathToWatch,这个pathToWatch后面有比较关键的作用。
*/
PredicateResults predicateResults = driver.getsTheLock(
client, children, sequenceNodeName, maxLeases);
if (predicateResults.getsTheLock()) {
haveTheLock = true;
} else {
String previousSequencePath = basePath + "/" + predicateResults.getPathToWatch();
/*
* 这块代码在争夺锁失败以后的逻辑中。那么此处该线程应该做什么呢?
* 首先添加一个watcher监听,而监听的地址正是上面一步返回的pathToWatch进行basePath + "/"
* 拼接以后的地址。
* 线程交出cpu的占用,进入等待状态,等到被唤醒。
* 接下来的逻辑就很自然了,如果自己监听的节点发生了变动,那么就将线程从等待状态唤醒,重新一轮
* 的锁的争夺。
*/
synchronized(this) {
try {
client.getData().usingWatcher(watcher).forPath(previousSequencePath);
if (millisToWait != null) {
millisToWait -= (System.currentTimeMillis() - startMillis);
startMillis = System.currentTimeMillis();
if (millisToWait <= 0) {
doDelete = true;
break;
}
wait(millisToWait);
} else { wait(); }
} catch (KeeperException.NoNodeException e) {
}
}
}
}
} ...
return haveTheLock;
}
自此, 我们完成了整个锁的抢夺过程。
2.释放锁
调用 InterProcessMutex 的 release() 方法释放锁:
@Override
public void release() throws Exception {
Thread currentThread = Thread.currentThread();
LockData lockData = threadData.get(currentThread);
if (lockData == null) {
throw new IllegalMonitorStateException("You do not own the lock: " + basePath);
}
// 减少重入锁的计数,直到变成0。
int newLockCount = lockData.lockCount.decrementAndGet();
if (newLockCount > 0) {
return;
}
if (newLockCount < 0) {
throw new IllegalMonitorStateException("Lock count has gone negative for lock: " + basePath);
}
try {
// 释放锁,即移除移除Watchers & 删除创建的节点
internals.releaseLock(lockData.lockPath);
} finally {
// 从threadData中,删除自己线程的缓存
threadData.remove(currentThread);
}
}
3.锁驱动类
开始的时候,我们提到了这个 StandardLockInternalsDriver 这个标准锁驱动类。还提到了我们可以传入自定义的来扩展。下来看下 LockInternalsDriver 接口:
public interface LockInternalsDriver extends LockInternalsSorter {
/**
* 判断是够获取到了锁
*/
public PredicateResults getsTheLock(CuratorFramework client, List<String> children,
String sequenceNodeName, int maxLeases) throws Exception;
/**
* 在zookeeper的指定路径上,创建一个临时序列节点
*/
public String createsTheLock(CuratorFramework client, String path, byte[] lockNodeBytes)
throws Exception;
}
public interface LockInternalsSorter {
/**
* 修复排序,在StandardLockInternalsDriver的实现中,即获取到临时节点的最后序列数,进行排序
*/
public String fixForSorting(String str, String lockName);
}
借助于这个类,我们可以尝试实现自己的锁机制,比如判断锁获得的策略可以做修改,比如获取子节点列表的排序方案可以自定义等。