1. 简述
1.1 分布式锁一般有三种实现方式:
- 基于redis的分布式锁
- 基于zookeeper的分布式锁
- 数据库乐观锁;
1.2 分布式锁没高可用满足条件
1)互斥性: 在任意时刻,只有一个客户端能持有锁。
2)不会发生死锁: 即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
3)具有容错性: 只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
4)加解锁条件必须对应: 加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
2.基于redis的分布式锁
2.1 redis三种集群模式
- redis哨兵模式
- redis 复制(主从模式)
- redis集群模式
2.1.1 主从模式
通过执行slaveof命令或设置slaveof选项,让一个服务器去复制另一个服务器的数据。被复制的服务器称为:Master主服务;对主服务器进行复制的服务器称为:Slave从服务器。主数据库可以进行读写操作,当写操作导致数据变化时会自动将数据同步给从数据库。而从数据库一般是只读的,并接受主数据库同步过来的数据。一个主数据库可以拥有多个从数据库,而一个从数据库只能拥有一个主数据库。
总结:
- 在主从复制中,数据库分为两类,主数据库(master)和从数据库(slave)
- 主数据库可以进行读写操作,当读写操作导致数据变化时会自动将数据同步给从数据库。
- 从数据库一般是只读的,并且接收主数据库同步过来的数据。
- 一个master可以拥有多个slave,但是一个slave只能对应一个master。
缺点:
- 每个客户端连接redis实例的时候都需要指定ip和端口号,如果连接的redis实例因为故障下线了,而主从模式也没有提供一定的手段通知客户端其他可连接的客户端地址,因为需要手动更改客户端配置重新连接。
- 如果节点下线了,那么从节点因为没有主节点而同步中断,因而需要人工配置重新连接。
- 无法动态扩容。
优点
- 解决数据备份问题
- 做到读写分离。提高服务器的性能。
1)从服务器向主服务器发送SYNC命令
2)主服务器收到SYNC命令后,执行BGSAVE命令,在后台生成RDB文件,使用缓冲区记录从现在开始执行的所有的写命令。
3)当主服务器的BGSAVE命令执行完毕后,主服务器后将BGSAVE命令生成的RDB文件发送给从服务器,从服务器接收并载入这个RDB文件,将自己的数据库状态更新至主服务器执行BGSAVE命令时的数据库状态。
4)主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态。
2.1.2 redis的哨兵模式(Sentinel)
为了解决Redis的主从复制的不支持高可用性能,Redis实现了哨兵(Sentinel)机制解决方案。由一个或多个Sentinel去监听任意多个主服务以及主服务器下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线的主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已经下线的从服务器,并且Sentinel可以互相监视。
总结:
- 哨兵的作用是监视redis系统的运行状况。
- 监控主从数据库是否正常运行。
- master出现故障时, 自动将slave转化为master
- 多哨兵配置的时候,哨兵之间也会自动监控。
- 多个哨兵可以监控同一个redis。
缺点:
- 如果从节点下线了,哨兵是不会对其进行故障转移,连接从节点的客户端也无法获取到新的可用从几节点。
- 无法实现动态扩容。
优点:
- 对master进行状态监测
- 如果master异常,则会进行master-slave转换,将其中一个slave作为master,将之前的master作为slave。
- master-slave切换后,master_redis.conf、slave_redis.conf和sentinel.con都会发生改变,即master_redis.conf中会多一行slaveof的配置,sentinel.conf的监控目标会随之调换。
当有多个Sentinel,在进行监视和转移主从服务器时,Sentinel之间会自己首先进行选举,选出Sentinel的leader来进行执行任务。2.1.2 redis集群模式
集群是Redis提供的分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能。一个Redis集群通常由多个节点组成;最初,每个节点都是独立的,需要将独立的节点连接起来才能形成可工作的集群,如下图所示。
Cluster Nodes命令和Cluster Meet命令,添加和连接节点形成集群。
Redis中的集群分为主节点和从节点。其中主节点用于处理槽;而从节点用于复制某个主节点,并在被复制的主节点下线时,代替下线的主节点继续处理命令请求。
如下图每一个蓝色的圈都代表着一个redis的服务器节点。它们任何两个节点之间都是相互连通的。客户端可以与任何一个节点相连接,然后就可以访问集群中的任何一个节点。对其进行存取和其他操作。
一般集群建议搭建三主三从架构,三主提供服务,三从提供备份功能。每一个节点都存有这个集群所有主节点以及从节点的信息。
它们之间通过互相的ping-pong判断是否节点可以连接上。如果有一半以上的节点去ping一个节点的时候没有回应,集群就认为这个节点宕机了,然后去连接它的备用节点。如果某个节点和所有从节点全部挂掉,我们集群就进入faill状态。还有就是如果有一半以上的主节点宕机,这时候redis就进入投票阶段。
1)什么是投票过程?
投票过程是集群中所有master参与,如果半数以上master节点与master节点通信超时(cluster-node-timeout),认为当前master节点挂掉.
2)什么时候整个集群不可用(cluster_state:fail)?
- 如果集群任意master挂掉,且当前master没有slave.集群进入fail状态,也可以理解成集群的slot映射[0-16383]不完整时进入fail状态. ps : redis-3.0.0.rc1加入cluster-require-full-coverage参数,默认关闭,打开集群兼容部分失败.
- 如果集群超过半数以上master挂掉,无论是否有slave,集群进入fail状态.
缺点:
- 为了性能提升,客户端需要缓存路由表信息
- 节点发现、重新分片操作不够自动化
优点:
- 有效的解决了redis在分布式方面的需求
- 遇到单机内存,并发和流量瓶颈等问题时,可采用Cluster方案 达到负载均衡的目的
- 可实现动态扩容
- 点对点模式,无中心化
- 通过Gossip协议同步节点信息
- 自动故障转移、Slot迁移中数据可用
2.1.4 三种模式对比
2.2 redis分布式锁实现的几种方式
2.2.1 redis实现
引入组件依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
1)基于redisgetSet
分布式锁实现。(不推荐此方法,这里只是介绍)
加锁代码:
@Autowired
private RedisTemplate redisTemplate;
/**
* 循环间隔时间(根据自己业务执行时间设置)
*/
private static final int DEFAULT_ACQUIRY_RESOLUTION_MILLIS = 200;
private boolean LOCKED = false;
/**
* 锁超时时间,防止线程在入锁以后,无限的执行等待(这个时间根据自己业务执行时间来设定)
*/
private int WXPIRE_MESECS = 60 * 1000;
/**
* 锁等待时间,防止线程饥饿(这个时间根据自己业务执行时间来设定)
*/
private int TIMEOUT_MESECS = 10 * 1000;
/**
* @return lock key
*/
public String get(final String key) {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
Object result = operations.get(key);
return null == result ? null : (String) result;
}
/**
* 写入缓存,如果key不存在才写入,用于锁,同时设置key过期时间
*
* @param key
* @param value
* @param expireTime 锁超时时间
* @return
*/
private boolean setNX(final String key, Object value, long expireTime) {
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
//设置key,value
Boolean result = operations.setIfAbsent(key, value);
//设置key的有效时间(和设置key不是原子操作,会出现设置了key,但过期时间未设置成功)
redisTemplate.expire(key, expireTime, TimeUnit.MILLISECONDS);
return null == result ? false : result;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 设置新的value的值,并返回旧值
*
* @param key
* @param value
* @return
*/
private String getSet(final String key, final String value) {
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
Object result = operations.getAndSet(key, value);
return null == result ? null : (String) result;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 获得 lock.
* 实现思路: 主要是使用了redis 的setnx命令,缓存了锁.
* reids缓存的key是锁的key,所有的共享, value是锁的到期时间(注意:这里把过期时间放在value了,没有时间上设置其超时时间)
* 执行过程:
* 1.通过setnx尝试设置某个key的值,成功(当前没有这个锁)则返回,成功获得锁
* 2.锁已经存在则获取锁的到期时间,和当前时间比较,超时的话,则设置新的值
*
* @param lockKey 锁key
* @return true if lock is acquired, false acquire timeouted
* @throws InterruptedException in case of thread interruption
*/
public synchronized boolean lock(String lockKey) throws InterruptedException {
int timeout = TIMEOUT_MESECS / 10;
try {
while (timeout >= 0) {
//当前时间+key过期时间,作为value进行存储
long expires = System.currentTimeMillis() + WXPIRE_MESECS + 1;
String expiresStr = String.valueOf(expires);
if (this.setNX(lockKey, expiresStr, WXPIRE_MESECS)) {
LOCKED = true;
return true;
}
//获取key的value的值,这里的value是锁的过期时间
String currentValueStr = this.get(lockKey);
/**
* 代码作用:确认锁是否出现过期但是未自动删除情况
* 如果返回的值为空,说明此时该锁已经被释放了;
* 如果返回的值小于系统当前时间,则说明锁已经过期,但redis没有自动删除
*/
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
//设置新的value(保存key的过期时间),并返回旧的value。
String oldValueStr = this.getSet(lockKey, expiresStr);
/**
* 代码作用:确认锁是否已被其他线程修改
* 如果返回的的旧值为空,说明此时该锁已经被释放了;
* 如果oldValueStr和currentValueStr不相等说明该锁已被其他线程修改
*/
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
LOCKED = true;
//更新锁的过期时间
redisTemplate.expire(lockKey, WXPIRE_MESECS, TimeUnit.MILLISECONDS);
return true;
}
}
timeout -= DEFAULT_ACQUIRY_RESOLUTION_MILLIS;
/**
* 休眠线程,防止出过多的线程出现饥饿问题(根据自己业务执行时间设置)
*/
Thread.sleep(DEFAULT_ACQUIRY_RESOLUTION_MILLIS);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
return false;
}
WXPIRE_MESECS
锁持有超时,防止线程在入锁以后,无限的执行下去,让锁无法释放TIMEOUT_MESECS
锁等待超时,防止线程饥饿,永远没有入锁执行代码的机会
算法思路:
- 1)先定义好一个锁等待时间和锁超时时间;
- 2)线程执行进方法后,通过
setNX()
中的setIfAbsent()
,expire()
方法设置key和value(这里的value=System.currentTimeMillis() + WXPIRE_MESECS + 1
,可以理解锁的过期时间)和设置锁的过期时间; - 3)
setNX()
返回true
则加锁成功,返回;如果返回false
则加锁失败,进行下一步操作; - 4)检查所是否出现已过期,但redis没有地洞删除锁的情况(或者在执行
expire()
的时候redis异常,锁过期时间没有设置成功); - 5)由
get()
方法获取value,如果value为空,则说明锁已经被删除;或者value
小于当前系统时间,则返回步骤(2);反之则进行下一步; - 6)通过
getSet()
方法设置新的value
值,拿到旧的value
值;判断旧的value
是否为空,或旧值是否与第(5)获得value是否相等; - 7)如果value为空或者不相等,则返回步骤(2);反之则进行下一步;
- 8)设置
LOCKED
为true
,则表示锁可删除,通过expire()
设置新的锁的过期时间;最后返回true表示该线程已获得锁;
原理图如下:
此方法存在的问题:
- 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。
- 当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。
- 锁不具备拥有者标识,即任何客户端都可以解锁。
解锁:
代码如下:
/**
* 根据key判断是否存在锁
*
* @param lockKey
* @return
*/
public boolean isLocked(String lockKey) {
/**
* 只根据key判断当前锁是否存在,但锁有可能不是自己的
*/
String currentValueStr = this.get(lockKey);
//检验是否超过有效期,如果不在有效期内,那说明当前锁已经失效,不能进行删除操作
if (currentValueStr != null && Long.parseLong(currentValueStr) > System.currentTimeMillis()) {
return true;
}
return false;
}
/**
* 释放线程
*
* @param lockKey
*/
public synchronized void unlock(String lockKey) {
try {
if (isLocked(lockKey) && LOCKED) {
redisTemplate.delete(lockKey);
LOCKED = false;
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
算法思路:
- (1)
LOCKED
为true,则表示线程已获得锁; - (2)通过
get()
方法获得value值,判断是否已过期,如果已过期则不会删除,反之可以删除; - (3)满足条件(1)、(2)时即可通过delete()方法删除锁;
2)基于LUA脚本分布式锁实现
添加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
加锁源码:
@Autowired
private RedisTemplate redisTemplate;
//锁过期时间
private long LOCK_LEASE_TIME = 30 * 1000;
//线程等待时间(根据自己的业务执行时间来设置)
private long WAIT_TIME = 5 * 1000;
//循环时间间隔(根据自己的业务执行时间来设置)
private long INTERVAL_TIME = 1000;
/**
* @param key
* @param value 请求标示,每次请求都是唯一的
* @return
*/
public boolean lock(String key, String value) {
List<String> list_key = new ArrayList();
list_key.add(key);
/**
* -- 加锁脚本,其中KEYS[]为外部传入参数
* -- KEYS[1]表示key
* -- ARGV[1]表示value
* -- ARGV[2]表示过期时间
*/
String lua_script = "if redis.call('SETNX',KEYS[1],ARGV[1]) == 1 then" +
" return redis.call('pexpire',KEYS[1],ARGV[2])" +
" else" +
" return 0 " +
"end";
try {
while (WAIT_TIME >= 0) {
RedisScript<String> redis_script = new DefaultRedisScript<>(lua_script, String.class);
Object return_flag = redisTemplate.execute(redis_script, list_key, value, String.valueOf(LOCK_LEASE_TIME));
if ("1".equals(String.valueOf(return_flag))) {
return true;
}
WAIT_TIME -= INTERVAL_TIME;
}
} catch (Exception e) {
throw new RuntimeException(e);
}
return false;
}
解析:
lua脚本解释
- KEYS[1]表示key
- ARGV[1]表示value
- ARGV[2]表示过期时间
语句解释:
- 执行
SENT
命令设置key和value,如果返回结果为1,则表示锁不存在,加锁成功,然后进行下一步操作;否则返回0; - 执行
pexpire
命令设置锁的过期时间,返回结果,执行成功则返回1,失败则返回0;
3)基于redis.set()分布式锁实现(用redis.clients包实现)
添加依赖:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.0.1</version>
</dependency>
加锁源码:
JedisPool配置
@Component
@Slf4j
public class RedisPoolConfig {
@Autowired
RedisProperties redisProperties;
//扫描次数
final static Integer RENTRY_NUM = 5;
public JedisPool getPool() {
/**
*获取jedisPool的默认配置,这里也可以设置自己的配置
*/
JedisPoolConfig config = new JedisPoolConfig();
return new JedisPool(config);
}
/**
*获取配置文件中配置的节点信息
*/
public JedisCluster JedisClusterConfig() {
this.getPool();
JedisCluster jedisCluster = null;
List<String> nodes = redisProperties.getCluster().getNodes();
Set<HostAndPort> clusterNodde = new HashSet<>();
try {
nodes.stream().forEach(node -> clusterNodde.add(new HostAndPort(node.substring(0, node.indexOf(":")),
Integer.valueOf(node.substring(node.indexOf(":") + 1, node.length())))));
jedisCluster = new JedisCluster(clusterNodde);
} catch (Exception e) {
throw new RuntimeException("JedisCluster connect is error", e);
}
return jedisCluster;
}
}
public class RedisLockLUA {
@Autowired
JedisPool jedisPool;
//锁过期时间
protected long internalLockLeaseTime = 30 * 1000;
//获取锁的超时时间
private long TIME_OUT = 30 * 1000;
//SET NX、PX命令的参数
SetParams params = SetParams.setParams().nx().px(internalLockLeaseTime);
/**
* @param key
* @param value 请求标示,每次都是唯一的
* @return
*/
public boolean lock(String key, String value) {
JedisCluster jedis = jedisPool.JedisClusterConfig();
Long startTime = System.currentTimeMillis();
try {
while (true) {
try {
//SET命令返回OK ,则证明获取锁成功
String lock = jedis.set(key, value, params);
if ("OK".equals(lock)) {
return true;
}
//否则循环等待,在TIME_OUT时间内仍未获取到锁,则获取失败
long waitTime = System.currentTimeMillis() - startTime;
if (waitTime >= TIME_OUT) {
return false;
}
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
jedis.close();
}
}
}
算法原理:
上述代码中可以看出其实加锁的代码就只有:set(final String key, final String value, final SetParams params)
,这个方法中一共有三个参数:
- 第一个为
key
,我们使用key来当锁,因为key是唯一的。 - 第二个为
value
,我们传的是value,这个value我们可以使用UUID.randomUUID().toString()
方法生成,来确保每次请求都是不一样的value。 - 第三个参数params,是通过redis的工具类
SetParams
,来设置NX、PX命令和锁过期时间如:SetParams params = SetParams.setParams().nx().px(internalLockLeaseTime)
。
所以执行set()方法只有两种结果:
- 1)当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。
- 2)已有锁存在,不做任何操作。
这样便保重了redis加锁,和设置锁过期时间的原子性。
解锁源码:
/**
* 释放分布式锁
*
* @param key 锁
* @param value 请求标识
* @return 是否释放成功
*/
public boolean unlock(String key, String value) {
JedisCluster jedis = jedisPool.JedisClusterConfig();
String script =
"if redis.call('get',KEYS[1]) == ARGV[1] then " +
" return redis.call('del',KEYS[1]) " +
"else return 0 " +
"end";
try {
Object result = jedis.eval(script, Collections.singletonList(key),
Collections.singletonList(value));
if ("1".equals(result.toString())) {
return true;
}
return false;
} finally {
jedis.close();
}
}
算法思路:
- 将写好的LUA代码脚本,传入到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为value。
- eval()的方法作用就是将LUA代码交给Redis服务端去执行。
这段LUA代码的功能是:
- 获取对应key的value值,检查与传过来的value值是否相等,如果相等则删除锁(解锁),不相等则保留锁。这样就避免了锁的误删。
2.2.2 redisson实现
添加依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.10.1</version>
</dependency>
1)可重入锁:
加锁源码
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
//取得最大等待时间
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
//当前线程Id,当前锁的唯一标示
long threadId = Thread.currentThread().getId();
//1.尝试申请锁,并返回还剩余的锁过期时间
Long ttl = tryAcquire(leaseTime, unit, threadId);
//2.如果为空,表示申请锁成功
if (ttl == null) {
return true;
}
//3.申请锁的耗时如果大于等于最大等待时间,则申请锁失败
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
current = System.currentTimeMillis();
/**
* 4.订阅锁释放事件,并通过await方法阻塞等待锁释放,有效的解决了无效的锁申请浪费资源的问题:
* 基于信息量,当锁被其它资源占用时,当前线程通过 Redis 的 channel 订阅锁的释放事件,一旦锁释放会发消息通知待等待的线程进行竞争
* 当 this.await返回false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败
* 当 this.await返回true,进入循环尝试获取锁
*/
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
//await 方法内部是用CountDownLatch来实现阻塞,获取subscribe异步执行的结果(应用了Netty 的 Future)
if (!await(subscribeFuture, time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(threadId);
return false;
}
try {
//计算获取锁的总耗时,如果大于等于最大等待时间,则获取锁失败
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
/**
* 5.收到锁释放的信号后,在最大等待时间之内,循环一次接着一次的尝试获取锁
* 获取锁成功,则立马返回true,
* 若在最大等待时间之内还没获取到锁,则认为获取锁失败,返回false结束循环
*/
while (true) {
long currentTime = System.currentTimeMillis();
//再次尝试申请锁
ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
//超过最大等待时间则返回false结束循环,获取锁失败
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
/**
* 6.阻塞等待锁(通过信号量(共享锁)阻塞,等待解锁消息)
*/
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
//如果剩余时间(ttl)小于wait time ,就在 ttl 时间内,从Entry的信号量获取一个许可(除非被中断或者一直没有可用的许可)。
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
//则就在wait time 时间范围内等待可以通过信号量
getEntry(threadId).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
//7.更新剩余的等待时间(最大等待时间-已经消耗的阻塞时间)
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
}
} finally {
//8.无论是否获得锁,都要取消订阅解锁消息
unsubscribe(subscribeFuture, threadId);
}
}
分析
tryLock
方法调用关系图:
其中 tryAcquire
内部通过调用 tryLockInnerAsync
实现申请锁的逻辑。申请锁并返回锁有效期还剩余的时间,如果为空说明锁未被其它线程申请则直接获取并返回,如果获取到时间,则进入等待竞争逻辑。
tryLockInnerAsync
源码
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
// 1.如果缓存中的key不存在,则执行 hset 命令(hset key UUID+threadId 1),然后通过 pexpire 命令设置锁的过期时间(即锁的租约时间)
// 返回空值 nil ,表示获取锁成功
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
// 如果key已经存在,并且value也匹配,表示是当前线程持有的锁,则执行 hincrby 命令,重入次数加1,并且设置失效时间
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
//如果key已经存在,但是value不匹配,说明锁已经被其他线程持有,通过 pttl 命令获取锁的剩余存活时间并返回,至此获取锁失败
"return redis.call('pttl', KEYS[1]);",
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
LUA脚本参数说明:
- KEYS[1]就是Collections.singletonList(getName()),表示分布式锁的key;
- ARGV[1]就是internalLockLeaseTime,即锁的租约时间(持有锁的有效时间),默认30s;
- ARGV[2]就是getLockName(threadId),是获取锁时set的唯一值 value,即UUID+threadId。
加锁流程图
解锁源码
public void unlock() {
try {
get(unlockAsync(Thread.currentThread().getId()));
} catch (RedisException e) {
if (e.getCause() instanceof IllegalMonitorStateException) {
throw (IllegalMonitorStateException) e.getCause();
} else {
throw e;
}
}
分析
unlock
内部通过unlockAsync()
来调用unlockInnerAsync()
方法来实现解锁操作。get()
方法利用是CountDownLatch
在异步调用结果返回前将当前线程阻塞,然后通过 Netty 的 FutureListener 在异步调用完成后解除阻塞,并返回调用结果。
unlock
内部调用图:
get()
源码
public <V> V get(RFuture<V> future) {
//如果任务没有完成
if (!future.isDone()) {
// 设置一个单线程的同步控制器
CountDownLatch l = new CountDownLatch(1);
future.onComplete((res, e) -> {
//操作完成时,唤醒在await()方法中等待的线程
l.countDown();
});
boolean interrupted = false;
while (!future.isDone()) {
try {
//阻塞等待
l.await();
} catch (InterruptedException e) {
interrupted = true;
break;
}
}
if (interrupted) {
Thread.currentThread().interrupt();
}
}
if (future.isSuccess()) {
return future.getNow();
}
throw convertException(future);
}
RFuture
源码
public RFuture<Void> unlockAsync(long threadId) {
RPromise<Void> result = new RedissonPromise<Void>();
RFuture<Boolean> future = unlockInnerAsync(threadId);
future.onComplete((opStatus, e) -> {
if (e != null) {
cancelExpirationRenewal(threadId);
result.tryFailure(e);
return;
}
if (opStatus == null) {
IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
+ id + " thread-id: " + threadId);
result.tryFailure(cause);
return;
}
cancelExpirationRenewal(threadId);
result.trySuccess(null);
});
return result;
}
unlockInnerAsync
源码
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
//如果分布式锁存在,但是value不匹配,表示锁已经被其他线程占用,无权释放锁,那么直接返回空值(解铃还须系铃人)
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
//如果value匹配,则就是当前线程占有分布式锁,那么将重入次数减1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
//重入次数减1后的值如果大于0,表示分布式锁有重入过,那么只能更新失效时间,还不能删除
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
//重入次数减1后的值如果为0,这时就可以删除这个KEY,并发布解锁消息,返回1
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
LUA脚本参数说明
- KEYS[1] 表是的是getName() 代表锁名test_lock
- KEYS[2] 表示getChanelName() 表示的是发布订阅过程中使用- 的Chanel
- ARGV[1] 表示的是LockPubSub.unLockMessage 是解锁消息,实际代表的是数字 0,代表解锁消息
- ARGV[2] 表示的是internalLockLeaseTime 默认的有效时间 30s
- ARGV[3] 表示的是getLockName(thread.currentThread().getId()),是当前锁id+线程id
解锁流程图
解锁消息处理
public class LockPubSub extends PublishSubscribe<RedissonLockEntry> {
public static final Long UNLOCK_MESSAGE = 0L;
public static final Long READ_UNLOCK_MESSAGE = 1L;
public LockPubSub(PublishSubscribeService service) {
super(service);
}
@Override
protected RedissonLockEntry createEntry(RPromise<RedissonLockEntry> newPromise) {
return new RedissonLockEntry(newPromise);
}
@Override
protected void onMessage(RedissonLockEntry value, Long message) {
//判断是否是解锁消息
if (message.equals(UNLOCK_MESSAGE)) {
Runnable runnableToExecute = value.getListeners().poll();
if (runnableToExecute != null) {
runnableToExecute.run();
}
//释放一个信号量,唤醒等待的entry.getLatch().tryAcquire去再次尝试申请锁
value.getLatch().release();
} else if (message.equals(READ_UNLOCK_MESSAGE)) {
while (true) {
//如果还有其他Listeners回调,则也唤醒执行
Runnable runnableToExecute = value.getListeners().poll();
if (runnableToExecute == null) {
break;
}
runnableToExecute.run();
}
value.getLatch().release(value.getLatch().getQueueLength());
}
}
}
2.2.3 分布式锁性能测试
测试环境:
- 系统:centOS linux;
- 软件:jmeter
- 压力:10s内执行10000请求
测试结果:
1)基于redis getset()
分布式锁锁:
-
图1 :报告仪表盘
从上图可看得出:
- 请求成功百分比:100%
- 性能指数:APDEX=0.00
- 执行时间: T<=81min
时间间隔 | T<=500ms | 500ms<T<=1500ms | T>1500ml |
---|---|---|---|
请求个数 | 0 | 5 | 9995 |
2)基于redisson分布式锁
从上图可看得出:
- 请求成功百分比:100%
- 性能指数:APDEX=0.669
- 执行时间:T<=2min
- 获得锁的请求情况:个数:10000 百分比:100%
时间间隔 | T<=500ms | 500ms<T<=1500ms | T>1500ml |
---|---|---|---|
请求个数 | 1278 | 3500 | 5222 |
3)基于LUA脚本分布式锁
-
-
图1 :报告仪表盘
从上图可看得出:
- 请求成功百分比:100%
- 性能指数:APDEX=0.669
- 执行时间:T<=1min
- 获得锁的请求情况:个数:9977百分比:99.77%
时间间隔 | T<=500ms | 500ms<T<=1500ms | T>1500ml |
---|---|---|---|
请求个数 | 4285 | 4218 | 1200 |
(说明: APDEX(应用程序性能指数),Apdex=(1 × 满意样本 + 0.5 × 容忍样本)÷ 样本总数这样,采样结果被量化为一个0到1之间的数值即“Apdex指数”,0代表没有满意用户,1则代表所有用户都满意,所以APDEX越大性能也就越好。)
测试结果分析:
- 性能: 基于LUA的分布式锁 > Redisson分布式锁 > 基于Redis GETSET分布式锁
- 获得锁个数: Redisson分布式锁 > 基于LUA的分布式锁 > 基于Redis GETSET分布式锁
- 说明: 对各个方案进行了多次测试,每次的测试结果并不相同,而上述的测试结果只是抽取了某一次的测试结果,但是每个方案的测试结果基本一致;对于基于LUA的分布式锁方案它的APDEX 值基本在0.6 ~ 0.8之间徘徊,执行时间也是T<=1min,请求获得的锁的个数在9950 ~9990之间徘徊 ;对于Redisson分布式锁方案它的APDEX值基本在0.2 ~ 0.5之间徘徊,执行时间基本T<2min ,请求获得的锁的个数始终都是保持10000,获得锁的概率始终100%。;对于基于Redis GETSET分布式锁来说它的APDEX始终都是0,且执行时间都是T>2h。
2.2.4总结:
- 从测试结果来看对于基于Redis GETSET分布式锁这种方案基本不考虑,从性能上看基于基于LUA的分布式锁要优于基于redisson分布式锁,但是从获取锁的情况上来说Redisson分布式锁能保证每个请求100%拿到锁,而对于基于LUA的分布式锁每次基本都99%左右,二者相差并不大;
- 虽然在性能上基于LUA的分布式锁要优于Redisson分布式锁,但是从代码上分析可以看出,它是纯Redis命令来实现分布式锁,并没有考虑太多的因素,所以可以在简单应用场景使用,而Redisson分布式锁考虑到了很多因素,包括:可重入锁、异步、失败重试等,在实现上也做了一些优化,减少了无效的锁申请,提升了资源的利用率,所以安全性上高于基于LUA的分布式锁。
注意:
-RedisLock
跟RedissonLock
相比 同样没有解决节点挂掉的时候,存在丢失锁的风险的问题。而现实情况是有一些场景无法容忍的,所以Redisson
提供了实现了redlock
算法的RedissonRedLock
,RedissonRedLock
真正解决了单点失败的问题,代价是需要额外的为RedissonRedLock
搭建Redis环境。