在前面关于Redis分布式锁安全性的思考(上)一文中,我们提到了redis在单节点故障转移中是存在隐患的。Redis的作者antirez提出了Redlock
算法,我们可以在Redis的官网上去了解该算法描述,Redlock
也是官方实现分布式锁的指导规范。
Antirez的Redlock
1.1 获取和释放锁
antirez提出分布式锁的算法Redlock
:
- 基于N个Redis节点,这些节点是独立的(示例将N设置为5)
- 获取和释放锁的方式和单节点保持一致
获取锁时,客户端执行以下操作:
- 以毫秒为单位获取
当前时间
- 尝试在所有N个实例中依次使用所有实例中相同的键名和随机值来获取锁。在第2步中,在每个实例中设置锁时,还要设置一个
超时时间
,超时时间
小于锁的总自动释放时间
。例如,如果自动释放时间为10秒,则超时时间可能在5到50毫秒之间。这样可以防止客户端长时间与处于故障状态的Redis节点进行通信:如果某个实例不可用,我们应该尝试与下一个实例尽快进行通信。 - 客户端通过从
当前时间
中减去在步骤1中获得的时间戳,来计算获取锁所需的时间。当且仅当客户端能够在大多数实例(至少3个)中获取锁时, ,并且获取锁所花费的总时间小于锁有效时间,则认为已获取锁。 - 如果获取了锁,则将其
有效时间
视为初始有效时间减去经过的时间,如步骤3中所计算。 - 如果客户端由于某种原因(无法锁定N / 2 + 1实例或有效时间为负数)而未能获得该锁,它将尝试解锁所有实例(即使它认为不是该实例)能够锁定)。
释放锁的过程比较简单:客户端向所有Redis节点发起释放锁的操作,不管这些节点是否成功获取了锁。这是因为客户端向redis获取锁时,redis已经执行成功了,但此时返回给客户端的时候数据丢失了,在客户端看来Redis的通信是故障的,但是Redis实际是加锁成功的
1.2 Martin的分析
Martin在这篇文章中谈及了分布式系统的很多基础性的问题(特别是分布式计算的异步模型),这篇文章大致分为2个部分,总结下来:
- 带有自动过期功能的分布式锁,必须提供某种fencing机制来保证对共享资源的真正的互斥保护。Redlock提供不了这样一种机制。
- Redlock构建在一个不够安全的系统模型之上。它对于系统的记时假设(timing assumption)有比较强的要求,而这些要求在现实的系统中是无法保证的。
Martin在这里其实是要指出分布式算法研究中的一些基础性问题,或者说一些常识问题,即好的分布式算法应该基于异步模型(asynchronous model),算法的安全性不应该依赖于任何记时假设(timing assumption)。在异步模型中:进程可能pause任意长的时间,消息可能在网络中延迟任意长的时间,甚至丢失,系统时钟也可能以任意方式出错。一个好的分布式算法,这些因素不应该影响它的安全性(safety property),只可能影响到它的活性(liveness property),也就是说,即使在非常极端的情况下(比如系统时钟严重错误),算法顶多是不能在有限的时间内给出结果而已,而不应该给出错误的结果。这样的算法在现实中是存在的,像比较著名的Paxos,或Raft。但显然按这个标准的话,Redlock的安全性级别是达不到的。
此外,Martin认为如果是为了效率(efficiency)而使用分布式锁,允许锁的偶尔失效,那么使用单Redis节点的锁方案就足够了,简单而且效率高。Redlock则是个过重的实现(heavyweight)。
他对Redlock算法的形容是:neither fish nor fowl (非驴非马)
1.3 Antirez的反驳
关于fencing机制。Antirez对于Martin的这种论证方式提出了质疑:既然在锁失效的情况下已经存在一种fencing机制能继续保持资源的互斥访问了,那为什么还要使用一个分布式锁并且还要求它提供那么强的安全性保证呢?
即使退一步讲,Redlock虽然提供不了Martin所讲的递增的fencing token
,但利用Redlock产生的随机字符串(my_random_value
)可以达到同样的效果。这个随机字符串虽然不是递增的,但却是唯一的,可以称之为unique token
。
我个人也感觉Martin的第一点确实有些牵强了,客户端在完成向各个Redis节点的获取锁的请求之后,会计算这个过程消耗的时间,然后检查是不是超过了锁的有效时间(lock validity time
)。客户端从GC pause中恢复过来以后,它会通过这个检查发现锁已经过期了,不会再认为自己成功获取到锁了。
Antirez也将主要内容放在时钟发生跳跃
上的反驳,这种情况一旦发生,Redlock是没法正常工作的。他认为Redlock对时钟的要求,并不需要完全精确,它只需要时钟差不多精确就可以了
- 手动修改时钟这种人为原因,不要那么做就是了。否则的话,如果有人手动修改Raft协议的持久化日志,那么就算是Raft协议它也没法正常工作了。
- 使用一个不会进行“跳跃”式调整系统时钟的ntpd程序(可能是通过恰当的配置),对于时钟的修改通过多次微小的调整来完成。
讨论进行到这,Martin和Antirez之间谁对谁错其实并不是那么重要了。只要我们能够对Redlock(或者其它分布式锁)所能提供的安全性的程度有充分的了解,那么我们就能做出自己的选择了。
针对Martin和Antirez的两篇blog,很多技术人员在Hacker News上展开了激烈的讨论。感兴趣的同学可以了解下
针对Martin的blog讨论 与
针对Antirez的blog讨论 这两篇。
优雅的Reddison
Redisson是Redis官方推荐的Java版的Redis客户端。它提供的功能非常多,也非常强大,此处我们只讨论它的分布式锁功能和Springboot的整合。
2.1 基本用法
2.1.1 pom.xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.14.1</version>
<exclusions>
<exclusion>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-data-23</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 我用的是springboot2.x版本所以排掉了redisson-spring-data-23 -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-data-22</artifactId>
<version>3.14.1</version>
</dependency>
复制代码
2.1.2 application.yml
spring:
profiles:
active: dev
redis:
redisson:
file: classpath:redisson.yml
复制代码
2.1.3 redisson.yml
#singleServerConfig: 单节点使用
clusterServersConfig:
# 连接空闲超时,单位:毫秒
idleConnectionTimeout: 10000
# 连接超时,单位:毫秒
connectTimeout: 60000
# 命令等待超时,单位:毫秒
timeout: 3000
# 命令失败重试次数,如果尝试达到 retryAttempts(命令失败重试次数) 仍然不能将命令发送至某个指定的节点时,将抛出错误。
# 如果尝试在此限制之内发送成功,则开始启用 timeout(命令等待超时) 计时。
retryAttempts: 3
# 命令重试发送时间间隔,单位:毫秒
retryInterval: 1500
# 密码
password: xxx
# 单个连接最大订阅数量
subscriptionsPerConnection: 5
# 客户端名称
clientName: xxx
nodeAddresses:
- "redis://127.0.0.1:7004"
- "redis://127.0.0.1:7001"
- "redis://127.0.0.1:7000"
# 发布和订阅连接的最小空闲连接数
subscriptionConnectionMinimumIdleSize: 1
# 发布和订阅连接池大小
subscriptionConnectionPoolSize: 50
# 最小空闲连接数
connectionMinimumIdleSize: 32
# 连接池大小
connectionPoolSize: 64
# 数据库编号
# database: 单节点使用
# 节点地址
#address: 单节点使用
# DNS监测时间间隔,单位:毫秒
dnsMonitoringInterval: 5000
# 线程池数量,默认值: 当前处理核数量 * 2
threads: 16
# Netty线程池数量,默认值: 当前处理核数量 * 2
nettyThreads: 32
#拓扑自动刷新时间
scanInterval: 1000
# 编码
codec: !<org.redisson.codec.JsonJacksonCodec> {}
# 传输模式
transportMode : "NIO"
复制代码
2.2 使用场景
2.2.1 获取锁
redissonClient有以下锁的实现,我们需要根据不同使用场景,先获取不同的锁。通常,我们是使用getlock()
来实现分布式锁.
/**
* Returns Lock instance by name.
* <p>
* Implements a <b>non-fair</b> locking so doesn't guarantees an acquire order by threads.
* <p>
* To increase reliability during failover, all operations wait for propagation to all Redis slaves.
*
* @param name - name of object
* @return Lock object
*/
//非公平锁
RLock getLock(String name);
/**
* Returns MultiLock instance associated with specified <code>locks</code>
*
* @param locks - collection of locks
* @return MultiLock object
*/
// 联合锁
RLock getMultiLock(RLock... locks);
/*
* Use getLock method instead. Returned instance uses Redis Slave synchronization
*/
@Deprecated
//已废弃
RLock getRedLock(RLock... locks);
/**
* Returns Lock instance by name.
* <p>
* Implements a <b>fair</b> locking so it guarantees an acquire order by threads.
* <p>
* To increase reliability during failover, all operations wait for propagation to all Redis slaves.
*
* @param name - name of object
* @return Lock object
*/
//公平锁:当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程
RLock getFairLock(String name);
/**
* Returns ReadWriteLock instance by name.
* <p>
* To increase reliability during failover, all operations wait for propagation to all Redis slaves.
*
* @param name - name of object
* @return Lock object
*/
//读写锁(读写互斥,读读不互斥)
RReadWriteLock getReadWriteLock(String name);
复制代码
2.2.2 普通加锁
RLock lock = redissonClient.getLock(lockKey);
//未获取到锁的线程会阻塞在这里
lock.lock();
复制代码
2.2.3 try lock(常用)
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(30L, TimeUnit.SECONDS)) {
//获取到锁
} else {
//没获取到锁
}
}finally {
lock.unlock();
}
复制代码
lock.tryLock()
会直接返回加锁结果,该方法也可以还可以传入waitTime
超时时间,leaseTime
自动释放时间,如果leaseTime
不传默认是30s
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
复制代码
2.3 redisson实现Redlock
以主从模式为例,Redlock需要至少3个节点,redisson也至少需要创建3个实例。那么我们就不能直接用springboot自动装载配置来创建RedissonClient
2.3.1 手动创建RedissonClient
@Bean(name ="redissonClient1", destroyMethod ="shutdown")
public RedissonClient redissonClient(RedisProperties redisProperties){
Config config = new Config();
config.useSingleServer()
.setAddress("redis://xxx"+":"+redisProperties.getPort())
.setPassword(redisProperties.getPassword())
.setConnectionPoolSize(100)
.setConnectionMinimumIdleSize(10);
return Redisson.create(config);
}
@Bean(name ="redissonClient2", destroyMethod ="shutdown")
public RedissonClient redissonClient2(RedisProperties redisProperties){
Config config = new Config();
config.useSingleServer()
.setAddress("redis://xxx"+":"+redisProperties.getPort())
.setPassword(redisProperties.getPassword())
.setConnectionPoolSize(100)
.setConnectionMinimumIdleSize(10);
return Redisson.create(config);
}
@Bean(name ="redissonClient3", destroyMethod ="shutdown")
public RedissonClient redissonClient3(RedisProperties redisProperties){
Config config = new Config();
config.useSingleServer()
.setAddress("redis://xxx"+":"+redisProperties.getPort())
.setPassword(redisProperties.getPassword())
.setConnectionPoolSize(100)
.setConnectionMinimumIdleSize(10);
return Redisson.create(config);
}
复制代码
2.3.2 加锁
RLock lock = redissonClient1.getLock(lockKey);
RLock lock2 = redissonClient2.getLock(lockKey);
RLock lock3 = redissonClient3.getLock(lockKey);
final RedissonRedLock redissonRedLock = new RedissonRedLock(lock, lock2, lock3);
try {
if(redissonRedLock.tryLock(30L, TimeUnit.SECONDS)){
//获取到锁
}else {
}
}finally {
redissonRedLock.unlock();
}
复制代码
那么如果是sentinel
模式或者是cluster
模式呢? 我们必须要拥有3个sentinel
集群或者cluster
集群,注意是集群,不是节点。所以说在保证高可用的情况下,Redlock 对硬件的要求也是比较高的。大多数情况使用getLock
的trylock()
基本可以满足分布式锁的使用要求。
关于trylock()
是如何实现的,我们下回分解。