实现Redis的分布式锁,除了自己基于redis client原生api来实现之外,还可以使用开源框架:Redission
Redisson是一个企业级的开源Redis Client,也提供了分布式锁的支持。
回想一下上一篇文章《基于Redis实现分布式锁》说的,如果自己写代码来通过redis设置一个值,是通过下面这个命令设置的。
SET anyLock unique_value NX PX 30000
这里设置的超时时间是30s,假如我超过30s都还没有完成业务逻辑的情况下,key会过期,其他线程有可能会获取到锁。
这样一来的话,第一个线程还没执行完业务逻辑,第二个线程进来了也会出现线程安全问题。所以我们还需要额外的去维护这个过期时间,太麻烦了~
我们来看看redisson是怎么实现的?先感受一下使用redission的爽:
就是这么简单,我们只需要通过它的api中的lock和unlock即可完成分布式锁,他帮我们考虑了很多细节:
1:redisson所有指令都通过lua脚本执行,redis支持lua脚本原子性执行
2:redisson设置一个key的默认过期时间为30s,如果某个客户端持有一个锁超过了30s怎么办? redisson中有一个watchdog的概念,翻译过来就是看门狗,它会在你获取锁之后,每隔10秒帮你把key的超时时间设为30s 这样的话,就算一直持有锁也不会出现key过期了,其他线程获取到锁的问题了。
3:redisson的“看门狗”逻辑保证了没有死锁发生。 (如果机器宕机了,看门狗也就没了。此时就不会延长key的过期时间,到了30s之后就会自动过期了,其他线程可以获取到锁)
示例代码:
// 加锁以后30秒钟自动解锁
// 无需调用unlock方法手动解锁
redissionLock.lock(30, TimeUnit.SECONDS);
其实上面截图的代码
应该改为下面这个才好,这样子看门狗才能真正生效,不然上面的代码会造成30秒后,锁会自动解锁的。
redissionLock.lock();
但是我们除了要考虑客户端要怎么实现分布式锁之外,还需要考虑redis的部署问题。
redis有三种部署方式:
1:单机模式
如果采用单机部署模式,会存在单点问题,只要redis故障了,加锁就不行了。
2:master-slave + sentinel 哨兵模式
采用master-slave模式,即便通过sentinel做了高可用(Master 宕机后立马切换Slave作为新的Master),但是由于节点之间是采用异步通信的方式,如果A客户端刚刚在 Master 节点上加了锁,但是数据还没被同步到 Salve,这时 Master 节点挂了,它上面的锁就没了,这时进行主从切换,等新的 Master 出来后,B客户端此时就可以再获取同样的锁,出现一把锁被拿到了两次的场景,从而导致系统出现脏数据。
3:redis cluster 集群模式
采用redis cluster集群模式,比如3主3从,主备切换。但是由于节点之间是采用异步通信的方式,如果A客户端刚刚根据路由规则在其中一台Master 节点上加了锁,但是数据还没被同步到 它的Salve,这时 这台Master 节点挂了,它上面的锁就没了,这时进行主备切换,等新的 Master 出来后,B客户端此时就可以再获取同样的锁,出现一把锁被拿到了两次的场景,从而导致系统出现脏数据。
为了解决上面的问题,Redis 的作者提出了名为 Redlock 的算法。
在 Redis 的分布式环境中,我们假设有 N 个 Redis Master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。(要注意这点)
前面已经描述了在单点 Redis 下,怎么安全地获取和释放锁,我们确保将在 N 个实例上使用此方法获取和释放锁。
在下面的示例中,我们假设有 5 个完全独立的 Redis Master 节点,他们分别运行在 5 台服务器中,可以保证他们不会同时宕机。
从官网上我们可以知道,一个客户端如果要获得锁,必须经过下面的五个步骤:
步骤描述来源:
http://redis.cn/topics/distlock.html
1:获取当前Unix时间,以毫秒为单位。
2:依次尝试从N个实例,使用相同的key和随机值获取锁。在步骤2,当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。
3:客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
4:如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。
通过上面的步骤我们可以知道,只要大多数的节点可以正常工作,就可以保证 Redlock 的正常工作。这样就可以解决前面单点 Redis 的情况下我们讨论的节点挂掉,由于异步通信,导致锁失效的问题。
但是,还是不能解决故障重启后带来的锁的安全性的问题。你想一下下面这个场景,这个场景让redis的创始人提出了“延迟重启”的概念。
红锁之redis持久化失败后重启场景
我们一共有 A、B、C 这三个节点。
客户端 1 在 A,B 上加锁成功。C 上加锁失败。
这时节点 B 崩溃重启了,但是由于持久化策略导致客户端 1 在 B 上的锁没有持久化下来。
客户端 2 发起申请同一把锁的操作,在 B,C 上加锁成功。
这个时候就又出现同一把锁,同时被客户端 1 和客户端 2 所持有了。
(接下来又得说一说Redis的持久化策略了,全是知识点啊,朋友们)
比如,Redis 的 AOF 持久化方式默认情况下是每秒写一次磁盘,即 fsync 操作,因此最坏的情况下可能丢失 1 秒的数据。
当然,你也可以设置成每次修改数据都进行 fsync 操作(fsync=always),但这会严重降低 Redis 的性能,违反了它的设计理念。(我也没见过这样用的,可能还是见的太少了吧。)
而且,你以为执行了 fsync 就不会丢失数据了?天真,真实的系统环境是复杂的,这都已经脱离 Redis 的范畴了。上升到服务器、系统问题了。
所以,根据墨菲定律,上面举的例子:由于节点重启引发的锁失效问题,总是有可能出现的。
为了解决这一问题,Redis 的创始人又提出了延迟重启(delayed restarts)的概念。
意思就是说,一个节点崩溃后,不要立即重启它,而是等待一定的时间后再重启。等待的时间应该大于锁的过期时间(TTL)。这样做的目的是保证这个节点在重启前所参与的锁都过期。相当于把以前的帐勾销之后才能参与后面的加锁操作。
看门狗和红锁和延迟重启其实都是redis的分布式锁的概念,真正用redis去实现是比较困难的,所以我们一般用reddission去实现,人家已经帮我们做好了,只要学会用它的代码就行。
这个红锁以及延迟重启思路的加锁算法在Redisson的红锁RedissonRedLock对象上面实现了,接下来我们来看是怎么用Reddsion实现的
该RedissonRedLock对象也可以用来将多个RLock对象关联为一个红锁,每个RLock对象实例可以来自于不同的Redisson实例。
红锁之有看门狗手动解锁方式:
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://172.0.0.1:5378").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://172.0.0.1:5379").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);
Config config3 = new Config();
config3.useSingleServer().setAddress("redis://172.0.0.1:5380").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);
/**
* 获取多个 RLock 对象
*/
RLock lock1 = redissonClient1.getLock(lockKey);
RLock lock2 = redissonClient2.getLock(lockKey);
RLock lock3 = redissonClient3.getLock(lockKey);
/**
* 根据多个 RLock 对象构建 RedissonRedLock (最核心的差别就在这里)
*/
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
????//有看门狗,业务没执行完可以不停的延长锁的过期时间
? ? boolean res = redLock.lock();
? ? if (res) {
? ? ? ? //成功获得锁,在这里处理业务
? ? }
} catch (Exception e) {
? ? throw new RuntimeException("aquire lock fail");
}finally{
? ? //无论如何, 最后都要解锁
? ? redLock.unlock();
}
大家都知道,如果负责储存某些分布式锁的某些Redis节点宕机以后,而且这些锁正好处于锁住的状态时,这些锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。
另外Redisson还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
//给lock1,lock2,lock3加锁
RedissonRedLock lock=newRedissonRedLock(lock1, lock2, lock3);
红锁之无看门狗自动解锁方式1:
//如果没有手动解开的话,10秒钟后将会自动解开
lock.lock(10,TimeUnit.SECONDS);
...
lock.unlock();
红锁之无看门狗自动解锁方式2:
//为加锁等待100秒时间,并在加锁成功10秒钟后自动解开
booleanres=lock.tryLock(100,10,TimeUnit.SECONDS);
...?
lock.unlock();?
下面代码采用的是红锁之无看门狗自动解锁方式2:
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://172.0.0.1:5378").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://172.0.0.1:5379").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);
Config config3 = new Config();
config3.useSingleServer().setAddress("redis://172.0.0.1:5380").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);
/**
* 获取多个 RLock 对象
*/
RLock lock1 = redissonClient1.getLock(lockKey);
RLock lock2 = redissonClient2.getLock(lockKey);
RLock lock3 = redissonClient3.getLock(lockKey);
/**
* 根据多个 RLock 对象构建 RedissonRedLock (最核心的差别就在这里)
*/
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
? ? /**
? ? * 尝试获取锁
? ? * waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败
? ? * leaseTime? 锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完)
? ? */
? ? boolean res = redLock.tryLock((long)waitTimeout, (long)leaseTime, TimeUnit.SECONDS);
? ? if (res) {
? ? ? ? //成功获得锁,在这里处理业务
? ? }
} catch (Exception e) {
? ? throw new RuntimeException("aquire lock fail");
}finally{
? ? //无论如何, 最后都要解锁
? ? redLock.unlock();
}
Reddsion的红锁有一个宕机细节:
如果采用红锁之有看门狗手动解锁方式,按照上面的“红锁之redis持久化失败后重启场景”,有3个节点ABC,其实我们已经解决了延迟重启问题,但是这时候,只有AC节点在正常运行,B节点还未重启的情况:
客户端2要获取一样的锁,是获取不了的,因为AB节点获取失败,C节点就算获取成功,也是只有1个获取成功,所以会获取不了一样的锁,这点是正常的。
客户端2要获取不一样的锁,AC可以获取得到,还是有办法的,这点是正常的。
这但是如果这时候C节点也挂了,剩下一个A节点有锁,
这时候想要获取一样的锁,不成功,这是正常的,等到这个锁解锁了,其他节点就可以重启了!
这时候想要获取不一样的锁,不成功,因为只剩下1个正常的节点!
比如5个节点的话,ABCDE,ABC加锁成功,DE加锁失败,这时候B又挂了,剩下AC有锁而已,DE没有锁。
客户端2要获取一样的锁,是获取不了的,因为ABC节点获取失败,DC节点就算获取成功,也是只有2个获取成功,所以会获取不了一样的锁,这点是正常的。
客户端2要获取不一样的锁,ACDE可以获取得到,还是有办法的,这点是正常的。
但是如果这时候C节点也挂了,剩下一个A节点有锁,DE没锁,
这时候想要获取一样的锁,不成功,这是正常的,等到这个锁解锁了,其他节点就可以重启了!
这时候想要获取不一样的锁,可能会成功,因为有3个正常的节点!
比如6个节点的话,ABCDEF,ABCD加锁成功,EF加锁失败,这时候B又挂了,剩下ACD有锁而已,EF没有锁。
客户端2要获取一样的锁,是获取不了的,因为ABCD节点获取失败,EF节点就算获取成功,也是只有2个获取成功,所以会获取不了一样的锁,这点是正常的。
客户端2要获取不一样的锁,ACDEF可以获取得到,还是有办法的,这点是正常的。
但是如果这时候C节点也挂了,剩下一个AD节点有锁,EF没锁,
这时候想要获取一样的锁,不成功,这是正常的,等到这个锁解锁了,其他节点就可以重启了!
这时候想要获取不一样的锁,可能会成功,因为有4个正常的节点!
但是如果这时候CD节点都挂了,剩下一个A节点有锁,EF没锁,
这时候想要获取一样的锁,不成功,这是正常的,等到这个锁解锁了,其他节点就可以重启了!
这时候想要获取不一样的锁,不会成功,因为只有3个正常的节点!
总结:如果大多数节点,进入了等待。就会导致系统的不可用,因为系统在业务执行完程序手动解锁的时间内(或者TTL时间内)任何锁(一样的锁和不一样的锁)都将无法加锁成功,其实等待 执行完手动解锁或者TTL时间内,其他节点自动重启就没事了,所以没有太大的问题!
Reddsion的红锁有一个释放锁细节:
释放锁的时候是要向所有节点发起释放锁的操作的。这样做的目的是为了解决有可能在加锁阶段,这个节点收到加锁请求了,也set成功了,但是由于返回给客户端的响应包丢了,导致客户端以为没有加锁成功。所有,释放锁的时候要向所有节点发起释放锁的操作。
红锁算法最重要的问题来了!!!也算要注意红锁算法的bug所在吧!
如果采用“看门狗失效红锁自动解锁方式1和2”,
问题1:
1、如果A客户端获取到了锁,然后它的A线程GC暂停了,经过一段时间,锁自动解锁了
2、此时B客户端来获取一样的锁,这时候A线程GC恢复了,此时A线程继续执行他的业务代码,B客户端的B线程也同时执行它的业务代码
3、这时候会导致同一时间内两个客户端有同一把锁,没有满足同一时间内一个锁只能有一个客户端持有的原则,所以会导致系统出现脏数据
从客户端的角度来看,就是这玩意不靠谱啊,你给我一把锁,我还没用呢,你就过期了,但是我还以为我能用,哪还知道其他人也有跟我一样的锁,这下好了这个锁同时被两个人用,这是不靠谱的做法!
(我觉得无法反驳)
问题2:
1、客户端 1 向 Redis 节点 A, B, C, D, E 发起锁请求。
2、各个 Redis 节点已经把请求结果返回给了客户端 1,但客户端 1 在收到请求结果之前进入了长时间的 GC 阶段。
3、长时间的 GC,导致在所有的 Redis 节点上,锁过期了。
4、客户端 2 在 A, B, C, D, E 上申请并获取到了锁。
5、客户端 1 从 GC 阶段中恢复,收到了前面第 2 步来自各个 Redis 节点的请求结果。客户端 1 认为自己成功获取到了锁。
6、客户端 1 和客户端 2 现在都认为自己持有了锁。
(我觉得可以反驳,这种情况其实对于 Redlock 是没有影响的,因为在第 5 步,客户端 1 从 GC 阶段中恢复过来以后,在 Redlock 算法中,如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间,客户端1通过这个检查发现锁已经过期了,不会再认为自己成功获取到锁了)
对于问题1和问题2,redis创始人有反驳,我只看得懂能够反驳问题2,反驳不了问题1。
1:运维人员手动修改了系统时钟。
2:从NTP服务收到了一个大的时钟更新事件。
redis创始人进行了回击:
第一点这个运维人员手动修改时钟,属于人为因素,这个我也没办法啊,人家就是要搞你,怎么办?加强管理,不要这样做。
第二点从NTP服务收到一个大的时钟更新,对于这个问题,需要通过运维来保证,通过正确配置NTP。需要将大的时间更新到服务器的时候,应当采取少量多次的方式。多次修改,每次更新时间尽量小。
关于这个地方的争论,就看你是信马丁哥的时间一定会跳跃,还是redis创始人的时间跳跃我们也是可以处理的。
redis创始人的想法:他是同意大的系统时钟跳跃会造成 Redlock 失效的。在这一点上,他与马丁哥的观点的不同在于,他认为在实际系统中是可以通过好的运维方式避免大的时钟跳跃的。
对于问题123,其实Redission使用“看门狗生效红锁方式”就能解决,A线程GC的时候,看门狗一直续锁的过期时间,使这个锁不会过期,而且确保系统时钟不要出现大的时间跳跃(比如人为修改系统时钟或者收到大的时钟更新时间的话,就正确配置NTP,其实这里的时间跳跃不要超过20秒就好,因为每个10秒,看门狗会去自动更新过期时间为30秒,不过这个30秒也是可以设置的,可以更改为60甚至更多,改为60的话,就是60/3=20,每隔20秒去重置过期时间),基本可以保证redission的分布式锁的零失误率,系统不出现脏数据!!!
还有就是各个redis应用不要大多数宕机,这会导致系统一时间没法加锁,但是不会导致系统出现脏数据。
但是其实上面这个方式,还有一个bug的存在,非常极端的条件。
问题4:看门狗gc暂停场景
1、如果A客户端获取到了锁,然后它的A线程GC暂停了,它的看门狗线程也GC暂停,经过一段时间,锁自动解锁了
2、此时B客户端来获取一样的锁,这时候A线程GC恢复了,它的看门狗线程GC没有恢复(其实此时恢复与不恢复都没关系吧,比较锁都过期了,而且别的客户端也一样拿到一样的锁了),此时A线程继续执行他的业务代码,B客户端的B线程也同时执行它的业务代码
3、这时候会导致同一时间内两个客户端有同一把锁,没有满足同一时间内一个锁只能有一个客户端持有的原则,所以会导致系统出现脏数据
所以在使用“看门狗生效红锁方式”的这种方式,保证时钟不会出问题,如果出现“看门狗gc暂停”场景,reddission的红锁算法也是不靠谱的,在极端条件下还是会出现bug的!
或者可以考虑,只使用redis单机模式的reddission分布式锁就好了,简单而且效率高。用 Redlock 太重。
但是单机模式的话,有一个场景:
A客户端拿到锁,然后 此时A客户端的线程GC暂停了,看门狗GC不GC没有关系,此时单机redis也宕机了,等到锁过期了,我们重启了redis,此时B客户端的拿到一样的锁,此时A客户端的线程GC恢复了,继续执行他的业务,此时同一个时间有两个客户端拿到同一把锁!
解决方法:
redis宕机的时候,别那么快去重启redis,防止有线程在GC暂停中,应该等线程超时之后再去重启,或者你的方法里面有事务,等到事务超时再去重启redis应用!
总结:
redis暂时也没实现这种令牌思维,所以采用redission分布式锁,用哪种都可以,
红锁:只能更好的避免出现脏数据的几率,而且不会出现单点问题。
单机redis:会出现单点故障,但是小心点处理重启时间,也不会出现脏数据。
主从哨兵模式,cluster模式:宕机的话比较容易出现脏数据。
其实用redis做分布式锁,在极端环境下,是无法完全保证不出现脏数据。
对付脏数据的方法:
其实多下了订单的话超出了库存,可以在业务层面去解决,比如多预留一些商品库存,把多下的订单商品也进行发货,
库存表如果有库存总数量,维护的时候就要把库存的总数量进行修改,剩余库存总数量就不用更改,避免脏数据。
,或者将超出库存多下的订单,回滚,设置为无效,修复脏数据,其中的数据修改细节,每个系统的都不一样,有些系统还可能涉及到积分还有其他会员等级之类的变动,所以修复脏数据,应该依照自己的系统情况来做。
redis分布式锁在这个地方有一个缺点没做好,也是之前马丁哥所说的例子:
也就是说 GC 恢复了,但是你其实已经锁已经失效了,但是你还觉得自己是有效的,然后业务继续执行,殊不知道还有人也拿到这把一样的锁,跟你一起在执行。
要实现这一目标,可以采用fencing令牌思维。
1、客户端 1 获得一个具有超时时间的锁的同时得到了令牌号 33,但随后陷入了一个长时间的暂停直到锁到期。
2、这时客户端2已经获得了锁和令牌号 34 ,然后发送写请求(以及令牌号 34 )到存储服务。?
3、接下来客户端 1 恢复过来,并以令牌号 33 来尝试写入,存储服务器由于记录了最近已经完成了更高令牌号(34 ),因此拒绝令牌号 33 的写请求。
这种版本号的机制,让我不禁想起了 Zookeeper。当使用 ZK 做锁服务时,可以用事务标识 zxid 或节点版本 cversion 来充当 fencing 令牌,这两个都可以满足单调递增的要求。
长发哥在书中也说到了:在服务端检查令牌可能看起来有点复杂,但是这其实是推荐的正确的做法:系统服务不能假定所有的客户端都表现的符合预期。从安全角度讲,服务端必须防范这种来自客户端的滥用。
对于redis的分布式锁而言,优缺点:
1、它获取锁的方式简单粗暴,获取不到锁直接不断尝试获取锁,比较消耗性能。
2、另外来说的话,redis的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题。锁的模型不够健壮
3、即便使用redlock算法来实现,在某些复杂场景下,也无法保证其实现100%没有问题,关于redlock的讨论可以看How to do distributed locking
4、但是另一方面使用redis实现分布式锁在很多企业中非常常见,而且大部分情况下都不会遇到所谓的“极端复杂场景”
所以使用redis作为分布式锁也不失为一种好的方案,最重要的一点是redis的性能很高,可以支撑高并发的获取、释放锁操作。
另外还有一种分布式锁是zookeeper分布式锁
对于zookeeper的分布式锁而言,优缺点:
1、zookeeper天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁。
2、如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。
3、如果有较多的客户端频繁的申请加锁、释放锁,对于zk集群的压力会比较大。
建议:
如果注重数据的准确性,数据不允许有一点错误:可以用zookeeper分布式锁解决,可以保证绝对不出问题,但是性能比redis差。
如果能容忍redis宕机问题导致reddsion分布式锁出现的锁失效问题,从而可能会导致数据出现问题,则可以还是使用redis。
如果使用“看门狗生效红锁方式”,则只需要确保系统时钟不要出现大的跳跃就可以保证零失误,极端条件下出现脏数据的话,就按照自己的系统的情况去处理脏数据吧!