Redis分布式锁和Zookeeper分布式锁的分享

前言:

本人只是将自己所学的总结分享给大家,希望不管是刚接触分布式锁的,还是有经验的大佬们多交流下。大家共同进步~!

1.为什么使用分布式锁

以商品减库存为例子,先来看看单机锁的场景下的一小段代码。

首先在redis中set key为“stock”,value为10。当程序执行时,判断stock是否大于0,大于0 则进行减1操作, 减完后重新赋值到stock中去。

synchronized同步锁是单机锁,在只有一个线程访问时,redis中的stock是不会出现问题的。 但本文阐述的是在并发场景下,所以本次案例采用 Jmeter 压测工具进行并发测试(有兴趣的小伙伴自行压测,这里就不展示结果了。。) 发现stock会出现负数的情况。

如今大部分互联网公司的业务应用基本都是是微服务架构, 在多线程的环境中,如果要避免同时操作一个共享变量产生资源争抢,数据问题,一般都会引入分布式锁来互斥,以保证共享变量的正确性,其使用范围是在同一个进程中。

如图所示,加上一个锁服务,所有进程在访问服务端之前都需要去这个服务上申请加锁。只有一个进程返回成功,其余的都会返回失败或阻塞等待,达到了互斥的效果。

2. Redis锁

首先谈谈redis锁

1)如何实现分布式锁?

想要实现分布式锁,必须要求 Redis 有互斥的能力,我们可以使用 SETNX 命令,这个命令表示 SET if Not eXists,即如果 key 不存在,才会设置它的值,否则什么也不做。

多个进程访问服务端,加锁成功的客户端,就可以去操作共享资源。

操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。释放锁直接使用DEL 命令删除这个 key就可以了。

将上述代码进行优化, 进化第二阶段~~~~

这个逻辑非常简单,就是在每次访问资源之前, 先去redis中加一把锁, 加锁成功返回true, 失败返回false, 则会返回前端一个error。 失败证明当前有其他线程占有这把key为lockKey的锁, 则进行阻塞等待。 成功则可以之前后续业务代码,将stock减一, 等业务全部执行完后,将锁释放,执行delete操作,让后续的线程能拿到锁。

但是,它存在一个很大的问题,当客户端 1 拿到锁后,在中间业务突然发生逻辑异常,导致无法及时释放锁,即没走到delete操作,此时就会造成死锁那怎么办? 看下一段进化。

进化第三阶段~~~

嘿嘿,是不是加上try catch,在finally后面做释放锁的操作就行了!

但有没有想过,我在代码中间留着那么大的一行,有没有想到呢?没错,如果客户端1拿到锁了,刚想往下执行业务代码,此时程序突然挂了, 是不是我连finally都走不到那里去,导致锁就无法进行释放,客户端1就会一直占用着这把锁。这也会造成死锁。

2) 如何避免死锁?

在 Redis 中实现时,就是给这个 key 设置一个过期时间。借助StringRedisTemplate的api Expire

于是乎,进化第四阶段~~~

给我们的锁加上一个10s的过期时间,10s后锁就会进行自动释放。 

但如果发生以下场景:

1.客户端1  SETNX执行成功了,然后执行 EXPIRE 时由于网络问题,执行失败

2.或者 SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行

3.又或者 SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行

别担心~

好在Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,用这一条命令就可以了:

于是乎,我们解决了死锁的问题。

以为这样redis锁的代码就算完成了吗? 还没那么简单,我们需要再进行完善优化。

试想这样一种场景:

如果客户端1加锁成功,开始执行业务代码,但是他执行的业务逻辑超时了, 超过了锁的过期时间10s,此时锁被自动释放,此时来了客户端2,它发现没人占用这把锁,于是乎它加锁成功也去执行业务代码了,然后客户端1刚好执行完业务代码后,去释放锁,但是它此时释放的是客户端2的锁。

3) 如何防止锁被别人释放?

客户端在加锁时,设置一个唯一标识。

于是乎,我们进化第。。。第。。第五阶段~~~

我们在锁lockKey的value设置为UUID,在释放锁的前先判断锁是否是自己持有的。 

但是还是有问题。。所以说实现redis锁是不是考虑的点比较多。

4) 如何正确评估锁过期时间?

前面我们提到,锁的过期时间如果评估不好,这个锁就会有提前过期的风险。

是否可以设计这样的方案:加锁时,先设置一个过期时间,然后我们开启一个守护线程,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行续期,重新设置过期时间。

redis早就帮我们想好了。接下来介绍下redisson。

Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了自动续期的方案来避免锁过期,这个守护线程我们一般也把它叫做看门狗线程。

而且使用起来非常简单,话不多说,上图,进化第六阶段~~

讲一下redisson lock方法的底层代码 

默认是30s的超时时间,也可以自己进行设值。再看关键代码。

其实底层采用的是lua脚本,lua脚本保证了原子性。在执行lua脚本过程中,有一部分命令成功了,有一部分失败了,也不会出现回滚的操作。因为 Redis 处理每一个请求是单线程执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。而且执行效率非常快!

之前分析的场景都是,锁在单个Redis 实例中可能产生的问题,并没有涉及到 Redis 的部署架构细节。

而我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现故障自动切换,把从库提升为主库,继续提供服务,以此保证可用性。

那当「主从发生切换」时,这个分布锁会依旧安全吗?用上面的图为例子

 当客户端1在主库上执行 SET 命令,加锁成功,此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的),从库被哨兵提升为新主库,这个锁在新的主库上丢失了!

为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)

5) Redlock红锁

这里简单介绍下,有兴趣去查找相关资料,还有redis作者与剑桥的分布式系统研究员的博弈很精彩。

Redlock 的方案基于 2 个前提:

1. 不再需要部署 从库 哨兵 实例,只部署 主库
2. 但主库要部署多个,官方推荐至少 5 个实例

整体的流程是这样的,一共分为 5 步:

  1. 客户端先获取当前时间戳 T1
  2. 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置请求的超时时间(毫秒级,要远小于锁的有效时间),这是为了 如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
  3. 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取当前时间戳 T2,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
  4. 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
  5. 加锁失败,向全部节点发起释放锁请求(前面讲到的 Lua 脚本释放锁)

1) 为什么要在多个实例上加锁?

本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。

2) 为什么大多数加锁成功,才算成功?

多个 Redis 实例一起来用,其实就组成了一个「分布式系统」。

在分布式系统中,总会出现「异常节点」,所以,在谈论分布式系统问题时,需要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。

这是一个分布式系统「容错」问题,这个问题的结论是:如果只存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的。

这个问题的模型,就是我们经常听到的拜占庭将军问题,感兴趣可以去看算法的推演过程。

3) 为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?

因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久,而且,因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。

所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。

4) 为什么释放锁,要操作所有节点?

在某一个 Redis 节点加锁时,可能因为网络原因导致加锁失败。

例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。

所以,释放锁时,不管之前有没有加锁成功,需要释放所有节点的锁,以保证清理节点上残留的锁。

3. Zookeeper分布式锁

Zookeeper节点( Znode )
持久节点 (PERSISTENT)

默认的节点类型,创建节点的客户端与zookeeper断开连接后,该节点依旧存在 。

持久节点顺序节点(PERSISTENT_SEQUENTIAL)

创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号。

临时节点(EPHEMERAL)

和持久节点相反, 创建节点的客户端与zookeeper断开连接后,临时节点会被删除。

临时顺序节点(EPHEMERAL_SEQUENTIAL)

临时顺序节点结合和临时节点和顺序节点的特点:在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号;当创建节点的客户端与zookeeper断开连接后,临时节点会被删除。

2) Zookeeper分布式锁的原理

 如图所示, 如果多个线程获取锁失败处于等待状态,当加锁成功的线程释放锁,会造成羊群效应,所有等待的线程都会去争抢锁,这会对服务器造成较大的影响。

所以在3.0版本后有了以下的改革

1. 获取锁

首先,在Zookeeper当中创建一个持久节点ParentLock。当第一个客户端想要获得锁时,需要在ParentLock这个节点下面创建一个临时顺序节点 Lock1。

之后,Client1查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock1是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。

这时候,如果再有一个客户端 Client2 前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock2。

Client2查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock2是不是顺序最靠前的一个,结果发现节点Lock2并不是最小的。

于是,Client2向排序仅比它靠前的节点Lock1注册Watcher,用于监听Lock1节点是否存在。这意味着Client2抢锁失败,进入了等待状态。

这时候,如果又有一个客户端Client3前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock3。

Client3查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock3是不是顺序最靠前的一个,结果同样发现节点Lock3并不是最小的。

于是,Client3向排序仅比它靠前的节点Lock2注册Watcher,用于监听Lock2节点是否存在。这意味着Client3同样抢锁失败,进入了等待状态。

1. 释放锁

释放锁分为两种情况:

①.任务完成,客户端显示释放

当任务完成时,Client1会显示调用删除节点Lock1的指令。

②.任务执行过程中,客户端崩溃

获得锁的Client1在任务执行过程中,如果Duang的一声崩溃,则会断开与Zookeeper服务端的链接。根据临时节点的特性,相关联的节点Lock1会随之自动删除。

由于Client2一直监听着Lock1的存在状态,当Lock1节点被删除,Client2会立刻收到通知。这时候Client2会再次查询ParentLock下面的所有节点,确认自己创建的节点Lock2是不是目前最小的节点。如果是最小,则Client2顺理成章获得了锁。

同理,如果Client2也因为任务完成或者节点崩溃而删除了节点Lock2,那么Client3就会接到通知。

最终,Client3成功得到了锁。

3)安全性

场景:
1. 客户端 1 和 2 都尝试创建「临时节点」,例如 /lock

2. 假设客户端 1 先到达,则加锁成功,客户端 2 加锁失败

3. 客户端 1 操作共享资源

4. 客户端 1 删除 /lock 节点,释放锁

Zookeeper 不像 Redis 那样,需要考虑锁的过期时间问题,它是采用了「临时节点」,保证客户端 1 拿到锁后,只要连接不断,就可以一直持有锁。

客户端 1 此时会与 Zookeeper 服务器维护一个 Session,这个 Session 会依赖客户端「定时心跳」来维持连接。如果 Zookeeper 长时间收不到客户端的心跳,就认为这个 Session 过期了,也会把这个临时节点删除。

同样地,基于此问题,我们也讨论一下 GC 问题对 Zookeeper 的锁有何影响:

  1. 客户端 1 创建临时节点 /lock 成功,拿到了锁
  2. 客户端 1 发生长时间 GC
  3. 客户端 1 无法给 Zookeeper 发送心跳,Zookeeper 把临时节点「删除」

4.    客户端 2 创建临时节点 /lock 成功,拿到了锁

5.    客户端 1 GC 结束,它仍然认为自己持有锁(冲突)

可见,即使是使用 Zookeeper,也无法保证进程 GC、网络延迟异常场景下的安全性。

Zookeeper 的优点:

  1. 不需要考虑锁的过期时间
  2. watch 机制,加锁失败,可以 watch 等待锁释放,实现乐观锁

Zookeeper 的劣势:

  1. 性能不如 Redis
  2. 部署和运维成本高
  3. 客户端与 Zookeeper 的长时间失联,锁被释放问题​​​​​​​

4)基于curator实现zookeeper分布式锁

https://curator.apache.org/getting-started.html​​​​​​​k​​​​​​​k

可以参考curator官网的快速入门。

这里直接贴代码吧。

 ​​​​​​​

看下加锁的底层源码

 

回到上一层。

 

 

 返回出去

4. 总结

分布式锁

优点

缺点

Redis

Set 和 Del 指令的性能较高。

1.实现复杂,需要考虑超时、原子性、误删等情形。

2.没有等待锁的队列,只能在客户端自旋来等锁,效率低下。 

Zookeeper

1.有封装好的框架,容易实现。

2.有等待锁的队列, 大大提升抢锁效率。 

添加和删除节点性能较低。

  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值