一、分布式锁必须保证的特性
互斥
无死锁
容错
阻塞与非阻塞
可重入
二、如何使用redis实现分布式锁(根据redlock算法)
redlock算法参考这篇翻译分析:http://zhangtielei.com/posts/blog-redlock-reasoning.html
常用的redis实现的分布式锁的框架是redisson
1.获取锁
SET key random_val NX PX 30000
命令解释:
random_val是由客户端生成的一个随机字符串,它要保证在所有客户端的所有获取锁的请求中都是唯一的。
NX 表示只有当key值不存在时才set
PX 30000 表示这个锁30秒过期
2.释放锁
当客户端完成了对共享资源的操作之后执行LUA脚本释放锁
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
其中入参 KEYS[1]就是key,ARGV[1]就是 random_val
命令解释:
通过Key得到分布式锁,判断value是不是同一个人加的锁,如果是才释放锁。
这里一定要用Lua脚本,是因为要用Lua脚本才能保证get和del这两个命令的执行是连续的,是不会被其他命令岔开的。换句话说保证了原子性(redis的原子性跟回滚没有关系,是只要开始执行逻辑,即使报错,后面的逻辑也会执行,并且不会被其他命令岔开)
比如:不会出现这种情况,get了keys[1],还没来得及判断keys[1]的值释放等于argv[1]的值,就被其他线程把keys[1]的值给改了。
3.多数派(奇数个独立的redis节点)
如果redis部署集群,那么建议有奇数个master节点,比如5个master节点。
获取锁的流程变成:
1.获取当前时间(毫秒数),我们叫它time_begin。
2.按顺序依次向N个Redis节点执行获取锁的操作。
为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败(该Redis节点不可用、该Redis节点上的锁已经被其它客户端持有)
3.计算整个获取锁的过程总共消耗了多长时间.
计算方法是用当前时间减去第1步记录的时间(time_had_lock - time_begin)。
如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
4.如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。
5.如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作
释放锁的流程变成:
客户端向所有Redis节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。
即使当时向某个节点获取锁没有成功,在释放锁的时候也不应该漏掉这个节点。
设想这样一种情况,客户端发给某个Redis节点的获取锁的请求成功到达了该Redis节点,这个节点也成功执行了SET
操作,但是它返回给客户端的响应包却丢失了。
这在客户端看来,获取锁的请求由于超时而失败了,但在Redis这边看来,加锁已经成功了。
因此,释放锁的时候,客户端也应该对当时获取锁失败的那些Redis节点同样发起请求。
4.崩溃恢复
节点发生崩溃重启可能出现以下不安全的情况:
假设一共有5个Redis节点:A, B, C, D, E。
1.客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)。
2.节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了。
3.节点C重启后,客户端2锁住了C, D, E,获取锁成功。
这样,客户端1和客户端2同时获得了同一资源锁
解决方法:
1.在默认情况下,Redis的AOF持久化方式是每秒写一次磁盘(即执行fsync),因此最坏情况下可能丢失1秒的数据。
为了尽可能不丢数据,Redis允许设置成每次修改数据都进行fsync。即是把AOF位置为fsync=always
但是也有可能因为跟redis无关的原因丢数据,比如操作系统扯拐了。
2.延迟重启(推荐)
一个节点崩溃后,不要马上重启该节点,等待一段时间(该时间大于锁的有效时间)再重启,这时候该节点在重启前锁参与的锁都过期了,重启后就不会对原来的锁资源竞争有影响了
5.安全性
使用Redlock算法的分布式锁一定要注意时间!!!
第一,每个机器的时间要一样
第二,程序获取到锁资源后一定要在锁自动失效前做完逻辑
不安全假设:客户端1获得了锁资源,30秒自动释放,然而30秒客户端1都没做完逻辑,客户端2获得了锁跟客户端1修改同样的资源!
解决方案:
1. 计算“有效时间” 并且在有效时间内完成“业务逻辑”,若不能完成则撤销之前的“操作”
业务逻辑应该尽可能的精简,不要掺杂不必要的代码
2.实现fencing token机制
fencing token是一个单调递增的数字,当客户端成功获取锁的时候它随同锁一起返回给客户端。而客户端访问共享资源的时候带着这个fencing token,这样提供共享资源的服务就能根据它进行检查,拒绝掉延迟到来的访问请求(避免了冲突)。如下图:
图片流程解释:
客户端1先获取到的锁,fencing token=33
客户端2后获取到的锁,fencing token=34,并且向存储服务中的资源进行了写操作成功。
客户端1从GC pause中恢复过来之后,向存储服务发送写请求,但是带了fencing token = 33。存储服务发现它之前已经处理过34的请求,所以会拒绝掉这次33的请求。
三、如何使用zookeeper实现分布式锁
常用的zk实现的分布式锁的框架是:Curator
如果想有一个安全性更高的分布式锁,可以使用zookeeper而不是redis
不过zookeeper不是100%的安全,且速度比redis慢一点点。
1.使用zookeeper实现分布式锁的方式
1.客户端尝试创建一个znode节点,比如
/lock
。那么第一个客户端就创建成功了,相当于拿到了锁;而其它的客户端会创建失败(znode已存在),获取锁失败。2.持有锁的客户端访问共享资源完成后,将znode删掉,这样其它客户端接下来就能来获取锁了。
3.znode应该被创建成ephemeral的。这是znode的一个特性,它保证如果创建znode的那个客户端失去心跳了(Session过期了),那么相应的znode会被自动删除。这保证了锁一定会被释放。
2.安全性
每个客户端都与ZooKeeper的某台服务器维护着一个Session,这个Session依赖定期的心跳(heartbeat)来维持。
如果ZooKeeper长时间收不到客户端的心跳(这个时间称为Session的过期时间),那么它就认为Session过期了,通过这个Session所创建的所有的ephemeral类型的znode节点都会被自动删除。
换句话说如果发生session过期,zk的分布式锁会被删掉,也会出现安全性问题。
比如:
1.客户端1创建了znode节点
/lock
,获得了锁。2.客户端1进入了长时间的GC pause。
3.客户端1连接到ZooKeeper的Session过期了。znode节点
/lock
被自动删除。4.客户端2创建了znode节点
/lock
,从而获得了锁。5.客户端1从GC pause中恢复过来,它仍然认为自己持有锁。
3.ZooKeeper的watch机制
这个机制可以这样来使用,比如当客户端试图创建/lock
的时候,发现它已经存在了,这时候创建失败,但客户端不一定就此对外宣告获取锁失败。
客户端可以进入一种等待状态,等待当/lock
节点被删除的时候,ZooKeeper通过watch机制通知它,这样它就可以继续完成创建操作(获取锁)。
这可以让分布式锁在客户端用起来就像一个本地的锁一样:加锁失败就阻塞住,直到获取到锁为止。
四、对比用redis实现和zookeeper实现
1.在正常情况下,zk实现的分布式锁,客户端可以持有锁任意长的时间,这可以确保它做完所有需要的资源访问操作之后再释放锁。这避免了基于Redis的锁对于有效时间(lock validity time)到底设置多长的两难问题。
2.基于zk的锁支持在获取锁失败之后等待锁重新释放的事件。这让客户端对锁的使用更加灵活。