当多个进程在不同的系统中,就需要使用分布式锁控制多个进程对同一个资源的访问。本篇介绍的是通过 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
来源:CSDN
作者:郭朝
链接:https://blog.csdn.net/smartbetter/article/details/53535435