锁
锁是一个与多线程编程如影随形的技术。
多线程是为了充分利用多核CPU,发挥出并行进程的效率。
锁是为了在多线程编程中保证共享资源的安全性。
得不到正确结果的程序,再快也没有用。
但,使用锁,就会降低程序的性能。因为在有锁的地方,多个线程也必然顺序执行。
所以,现在也有一些使用无锁队列的方式来保证多线程安全。
对于只有一个客户端操作Redis实例时,在多线程的环境中,可以使用事务和WATCH组成的乐观锁来解决相关的并发问题。
但是,在分布式系统,当多个客户端同时操作Redis时,可能就需要分布式锁才能保证数据安全。
分布式锁
分布式锁应具有以下特性:
- 安全性(Safety): 在任意时刻,只有一个客户端可以获得锁(排他性)
- 避免死锁:即使锁住某个资源的客户端在释放锁之前崩溃或者网络不可达,客户端最终一定可以获得锁
- 容错性:只要Redis集群中的大部分节点存活,client就可以进行加锁解锁操作
对于只有单个Redis实例和同时有多个Redis实例(如Redis集群)的情况下,实现分布式锁也有不同的方式。
SET锁
简单的实现方式是set + lua脚本
。
实现代码如下:
# 获取锁(unique_value可以是UUID等)
SET resource_name unique_value NX PX 30000
# 释放锁(lua脚本中,一定要比较value,防止误解锁)
if redis.call("get", resource_name) == unique_value then
return redis.call("del", resource_name)
else
return 0
end
注意以下几点:
- set命令要用
set key value px milliseconds nx
,带有超时,防止连接异常导致锁无法释放 - value要具有唯一性,用于释放锁进验证
- 释放锁时要验证value值,不能误解锁,否则连接A可能释放连接B的锁(如连接A在处理过程中,锁超时释放了,此时B获取了锁开始处理,然后A回来了,要释放锁,就把B的锁删除了)
但这类琐最大的缺点就是它加锁时只作用在一个Redis节点上。
即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:
- 在Redis的master节点上拿到了锁
- 但是这个加锁的key还没有同步到slave节点
- master故障,发生故障转移,slave节点升级为master节点
- 新master节点没有该key,导致锁丢失
这就要用RedLock了。
RedLock
在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。
我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。
现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:
- 获取当前Unix时间,以毫秒为单位。
- 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。
- 当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。
- 例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。
- 如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)
- 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁
- 即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁
RedLock特性:
- RedLock算法是否是异步算法
- 可以看成是同步算法;因为 即使进程间(多个电脑间)没有同步时钟,但是每个进程时间流速大致相同;并且时钟漂移相对于TTL较小,可以忽略,所以可以看成同步算法
- 不够严谨,算法上要算上时钟漂移,因为如果两个电脑在地球两端,则时钟漂移非常大
- RedLock失败重试
- 当client不能获取锁时,应该在随机时间后重试获取锁
- 并且最好在同一时刻并发的把set命令发送给所有redis实例
- 对于已经获取锁的client在完成任务后要及时释放锁,这是为了节省时间
- RedLock释放锁
- 释放锁时会判断这个锁的value是不是自己设置的,如果是才删除
- 在释放锁时非常简单,只要向所有实例都发出释放锁的命令,不用考虑能否成功释放锁
- RedLock性能及崩溃恢复的相关解决方法
- 如果redis没有持久化功能,在clientA获取锁成功后,所有redis重启,clientB能够再次获取到锁,这样违法了锁的排他互斥性;
- 如果启动AOF永久化存储,事情会好些,当我们重启redis后,由于redis过期机制是按照unix时间戳走的,所以在重启后,然后会按照规定的时间过期,不影响业务;但是由于AOF同步到磁盘的方式默认是每秒-次,如果在一秒内断电,会导致数据丢失,立即重启会造成锁互斥性失效;但如果同步磁盘方式使用Always(每一个写命令都同步到硬盘)造成性能急剧下降;所以在锁完全有效性和性能方面要有所取舍;
- 有效解决既保证锁完全有效性及性能高效及即使断电情况的方法是redis同步到磁盘方式保持默认的每秒,在redis无论因为什么原因停掉后要等待TTL时间后再重启(延迟重启) ;缺点是 在TTL时间内服务相当于暂停状态;
redis-plusplus实现RedLock
当使用c++版本客户端redis-plus-plus时,可以直接使用RedLock功能。
它提供了两个版本:
- 基于Lua脚本:更快,并且有很多参数可以调整以控制锁行为
- 基于事务实现
示例代码如下:
auto redis1 = Redis("tcp://127.0.0.1:7000");
auto redis2 = Redis("tcp://127.0.0.1:7001");
auto redis3 = Redis("tcp://127.0.0.1:7002");
// Lua script version:
{
RedLockMutex mtx({redis1, redis2, redis3}, "resource");
// Not locked.
RedLock<RedLockMutex> lock(mtx, std::defer_lock);
// Try to get the lock, and keep 30 seconds.
// It returns the validity time of the lock, i.e. the lock is only
// valid in *validity_time*, after that the lock might be acquired by others.
// If failed to acquire the lock, throw an exception of Error type.
auto validity_time = lock.try_lock(std::chrono::seconds(30));
// Extend the lock before the lock expired.
validity_time = lock.extend_lock(std::chrono::seconds(10));
// You can unlock explicitly.
lock.unlock();
} // If unlock() is not called, the lock will be unlocked automatically when it's destroied.
// Transaction version:
{
RedMutex mtx({redis1, redis2, redis3}, "resource");
RedLock<RedMutex> lock(mtx, std::defer_lock);
auto validity_time = lock.try_lock(std::chrono::seconds(30));
validity_time = lock.extend_lock(std::chrono::seconds(30));
// You can unlock explicitly.
lock.unlock();
}
小结
只要使用了锁,就会影响程序的性能和效率。
所以,不管使用了什么锁,都要根据实际业务的需求,能不用锁就不用锁,能用简单锁绝不用复杂锁。
参考资料
RedLock算法-使用redis实现分布式锁服务
https://zhuanlan.zhihu.com/p/111354065
Redlock(redis分布式锁)原理分析
redis-plus-plus