本次分享的话题,主要是redis分布式锁的常见实现方式和分布式的场景下如何实现一个优良的分布式锁的话题讨论,主要词汇如下:
setnx
redLock
redisson
1.为什么需要分布式锁
实际的业务场景中,有很多并发访问的问题:比如下单,修改库存等,可总结为如下的流程:
客户端读数据到本地,本地修改;
客户端修改完数据,会写数据;
这样的流程通常有显著的特点:"读取-修改-写回",简称为RMW操作(Read-Modify-Write)。
多个客户端对同一份数据执行RMW操作的话,需要让RMW和涉及的代码,按照原子方式执行,这种访问同一份数据的RMW操作代码,叫做临界区代码。实际场景,基本如下图:
临界区代码必须要保证多进程串行执行,否则将产生数据不一致等影响。
在redis中,处理临界区的代码通常有三种方式
多个操作合并成一条命令操作:case:INCR/DECR,set至于setnx
多个操作写道Lua脚本中执行
加分布式锁
前两个不是所有的场景都适合。为了保证多进程能串行执行,我们需要一个统一的外部资源来实现这种互斥的能力。
为了追求性能,我们使用redis 分布式锁,当然,还可以是MySQL,Zookeeper等。
2.Redis分布式锁实现
讨论之前,我们有必要分析和总结一下Redis分布式锁应有的设计原则:
安全属性:互斥,不管任何时候,只有一个客户端能持有同一个锁。
效率属性A:不会死锁,最终一定会得到锁,就算一个持有锁的客户端宕掉或者发生网络分区。
效率属性B:容错,只要大多数Redis节点正常工作,客户端应该都能获取和释放锁。
这三点,是最基本的安全和可靠性保障,除此之外,还可以考虑是否支持阻塞和非阻塞、持久性(能否自动续约活自动延期)、是否支持公平性和可重入特性。
可以以一个常见伪代码为案例:
加锁代码形式通常如下:
function writeData(filename, data) {
var lock = redis.setNx(filename,1);
redis.expire(filename,3000)
if (!lock) {
throw 'Failed to acquire lock';
}
try {
var file = storage.readFile(filename);
var updated = updateContents(file, data);
storage.writeFile(filename, updated);
} finally {
redis.del(filename)
}
}
部分代码参考:https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html</pre>
2.1 如何避免死锁
加锁代码通常如下,很容易想到加一个过期时间
SETNX key 1 //加锁
EXPIRE key 10 //设置锁过期</pre>
时间图如下:
问题:
1-进程A在T2-T3之间出现问题,没有机会释放锁,锁一直不释放
2-进程A在T3-T5之间出问题,设置有效期失败,锁一直不释放
解决方案:对于问题1-2,可以使用SET key value [EX seconds] [PX milliseconds] [NX] 保证原子性,redis单节点问题,我们稍后讨论。
修复setnx问题后,我们继续分析有另外一个进程进入的情况,考虑按时间顺序如下场景:
如果锁有效时间10s
1.进程A加锁成功,开始操作,操作时间过了锁有效期
2.进程B申请加锁,开始操作;
3.进程A释放锁(进程B的锁被释放掉了)
问题:
1-锁过期时间控制:如果一个进程执行时间过长,导致锁超期释放,别的进程可获取锁,两个进程同时拥有一把锁,操作同一份RMW 代码
2-释放别人的锁:进程A释放锁的时候,把别的进程的锁释放掉了
问题1我们先不讨论,
2.2 释放别人加的锁
上述问题,图示如下:
1-进程A加锁成功
2-进程A执行时间超出锁有效期,进程B获取锁
2-进程A执行完成,释放了B进程加的锁
解决方案:
1-通过控制加锁的value值为 唯一值:SET key random PX 5000 NX,其中,random应该是唯一值
2-删除锁的时候,先获取锁的值是否等于random值,等于则释放,为了保证原子性采用lua脚本,内容如下:
//释放锁 比较random是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
如下图:
整体代码开起来如下:
function writeData(filename, data) {
var lock = redis.set(filename,1,px,5000,NX);
if (!lock) {
throw 'Failed to acquire lock';
}
try {
var file = storage.readFile(filename);
var updated = updateContents(file, data);
storage.writeFile(filename, updated);
} finally {
redis.eval("if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end")
}
}
这段代码看起来,解决了我们想到的很多问题,事实上,很多项目中依然采用着,除了上述我们提及过:锁过期时间与线程执行时间不好确定之外,我们继续分析,还有什么问题:
1-锁过期时间
2-redis不能是主从部署方式
3-更宽泛的说来,不支持很多锁的功能:比如,是否公平,是否可重入
其实redisson已经帮我们提供了更加健壮简洁的锁实现
3.Redisson
Redisson 是架设在 Redis 基础上的一个 Java驻内存数据网格框架, 充分利用 Redis 键值数据库提供的一系列优势, 基于 Java 使用工具包中常用接口, 为使用者提供了 一系列具有分布式特性的常用工具类。其中就提供了一种RedLock的加锁算法和实现,讨论之前,我们可以先分析单机版的Redisson如何实现一个分布式锁。
RedLock官方介绍:Distributed locks with Redis – Redis
由于 Redisson自身太过于复杂, 设计的 API 调用大多用 Netty 相关, 所以本文只对 如何加锁、如何实现重入锁,释放锁进行讨论
1-加锁流程
2-解锁流程
3.1 Redlock
3.1.1 Redlock 算法介绍
部署多台 Redis, 各实例之间相互独立, 不存在主从复制或者其他集群协调机制
使用方式大体如下:
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();
原理:
加入设置节点数N=5,所以我们需要在不同的计算机或虚拟机上运行5个Redis主站,以确保它们会以一种基本独立的方式失败。
为了获得锁,客户端执行以下操作。
获取当前的时间,以毫秒为单位。
依次在所有N个实例中获取锁,在所有实例中使用相同的键名和随机值。在步骤2中,当在每个实例中设置锁时,客户端使用一个与总的锁自动释放时间相比很小的超时来获取它。例如,如果自动释放时间是10秒,超时可以在~ 5-50毫秒范围内。这可以防止客户端在试图与Redis节点对话时长时间受阻:如果一个实例不可用,我们应该尽快尝试与下一个实例对话。
客户端通过从当前时间减去步骤1中获得的时间戳,计算出获得锁所需的时间。如果并且只有当客户端能够在大多数实例(至少3个)中获取锁,并且获取锁的总时间小于锁的有效期,锁才被认为是被获取。
如果锁被获取,其有效性时间被认为是初始有效性时间减去经过的时间,如步骤3中计算的那样。
如果客户端由于某种原因未能获得锁(要么它无法锁定N/2+1个实例,要么有效性时间为负数),它将尝试解锁所有的实例(甚至是它认为无法锁定的实例)。
3.1.2 Redlock 算法是否安全
分布式系统研究员Martin Kleppmann曾对 RedLock算法深入分析并强烈反对在生产中使用,其主要原因就是redlock的实现依赖了服务器的本地时钟
如下例子,还是5个节点,Redlock失效:
客户端1获得了A、B、C节点上的锁,由于网络问题,无法到达D和E。
节点C上的时钟向前跳动,导致锁过期。
客户端2获得了节点C、D、E的锁,由于网络问题,A和B不能被联系到。
客户端1和2现在都认为他们持有锁。
也或者,在第二步骤,节点c如果出现宕机,恢复后没有之前的数据,客户端2也可能获取到锁。
再看如下例子:
客户端1请求锁定节点A、B、C、D、E。
当对客户端1的响应在路途中时,客户端1进入停止世界的GC。
所有Redis节点的锁都过期了。
客户端2获得了节点A、B、C、D、E的锁。
客户端1完成了GC,并收到了来自Redis节点的响应,表明它成功获得了锁(当进程暂停时,它们被保存在客户端1的内核网络缓冲区)。
客户端1和2现在都认为他们持有该锁。