redis实现分布式锁导致的问题(第二版)
属性:公平,互斥,可重入(业务涉及不多)
可使用redis,zookeeper,etcd实现
redis实现:
一般追求高性能使用redis
redis采用单线程架构,可以保证单个命令的原子性,但是无法保证一组命令在高并发场景下的原子性(引入lua脚本)
注意点:
-
独占排他:setnx
-
防死锁:
redis客户端程序获取到锁之后,立马宕机。给锁添加过期时间
不可重入:hash实现 : lua脚本进行判断,如果锁不存在或者这是自己的锁,就通过hincrby(不存在就新增并加1,存在就加1)获取锁或者锁次数加1。
-
防误删:
先判断是否自己的锁才能删除,加锁时赋值为自己的唯一ID,释放时进行判断
-
需保证原子性的阶段:
(1)加锁和过期时间之间
(2)判断和释放锁之间
-
可重入性:hash(key field value) + lua脚本
-
自动续期:Timer定时器 + lua脚本
-
在集群情况下,导致锁机制失效:
-
-
从还没来得及同步数据,主挂了
-
于是从升级为主
-
客户端程序10086就从新主中获取到锁,导致锁机制失效
-
redlock算法引入:
解决方法:本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用
特点:同步算法,Redlock 只有建立在「时钟正确」的前提下,才能正常工作
前提:
多实例master
客户端需提前几毫秒完成工作,作为对时钟漂移的补偿:只有当持有锁的客户端在锁有效时间达到之前完成工作,互斥性才会得到保证。提前的几毫秒用于补偿进程之间的时钟漂移。 这就跟木桶原理一样,较短的一块板子决定了最大蓄水量,最早的过期时间决定了锁的实际最大生存时长。
重试获取锁设计思想:
- 先延迟一个随机时间。
- 再使用指数退避法执行重试(redsync没有实现)。
问题:
-
客户端A从master获取到锁
-
在master将锁同步到slave之前,master宕掉了。
-
slave节点被晋级为master节点
-
根本原因:redis主从复制的异步性
redisSync源码解读
关于redlock的问题:
1. 时钟问题:
Redlock 必须「强依赖」多个节点的时钟是保持同步的,一旦有节点时钟发生错误,那这个算法模型就失效了。
结论:Redis 作者表示,Redlock 并不需要完全一致的时钟,只需要大体一致就可以了,允许有「误差」。
例如要计时 5s,但实际可能记了 4.5s,之后又记了 5.5s,有一定误差,但只要不超过「误差范围」锁失效时间即可,这种对于时钟的精度的要求并不是很高,而且这也符合现实环境。
2. 分布式场景问题:
- 客户端 1 请求锁定节点 A、B、C、D、E
- 客户端 1 的拿到锁后,进入进程暂停(时间比较久)
- 所有 Redis 节点上的锁都过期了
- 客户端 2 获取到了 A、B、C、D、E 上的锁
- 客户端 1 GC 结束,认为成功获取锁
- 客户端 2 也认为获取到了锁,发生「冲突」
导致原因:分布式系统会遇到的三座大山:NPC
- N:Network Delay,网络延迟
- P:Process Pause,进程暂停
- C:Clock Drift,时钟漂移
redis作者结论:
npc在redlock算法的1-3步骤中发生可以解决,在第3步之后如果发送npc的话(也就是客户端确认拿到了锁,去操作共享资源的途中发生了问题,导致锁失效)解决不了,且其他分布式锁实现方式也解决不了
依据:
redlock流程:
- 客户端先获取「当前时间戳 T1」
- 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
- 如果客户端从 3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳 T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
- 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
- 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)
zookeeper实现:
1.ZooKeeper的每一个节点,都是一个天然的顺序发号器
2.ZooKeeper节点的递增有序性,可以确保锁的公平
3.ZooKeeper的节点监听机制,可以保障占有锁的传递有序而且高效:释放时删除节点:击鼓传花;具体的方法是,每一个等通知的Znode节点,只需要监听(linsten)或者监视(watch)排号在自己前面那个,而且紧挨在自己前面的那个节点,就能收到其删除事件了。 只要上一个节点被删除了,就进行再一次判断,看看自己是不是序号最小的那个节点,如果是,自己就获得锁
4.ZooKeeper的节点监听机制,能避免惊群效应:所有节点都去监听,然后做出反应,这样会给服务器带来巨大压力
https://juejin.cn/post/7103157951342313479
ZooKeeper 实现分布式锁通常会用到两种类型的节点(znode):持久节点(Persistent Znode)和临时顺序节点(Ephemeral Sequential Znode)。以下是如何使用这两种类型的节点来实现分布式锁:
1. 持久节点:
- 锁节点: 通常,会有一个持久节点作为锁的根节点。所有试图获取锁的客户端都会在这个节点下创建子节点。
2. 临时顺序节点:
- 代表客户端的节点: 当一个客户端试图获取锁时,它会在锁的根节点下创建一个临时顺序节点。临时节点确保如果客户端会话结束(例如,客户端崩溃),节点会被自动删除,这有助于避免死锁。顺序节点则确保节点的创建顺序被记住,保证了锁的公平性。
实现过程:
-
锁的获取: 客户端试图在锁的根节点下创建临时顺序节点。然后,客户端查看自己创建的节点是否是当前根节点下序号最小的节点。如果是,则客户端成功获取锁。
-
等待锁: 如果客户端创建的节点不是序号最小的节点,客户端则监听比自己序号小的最近的一个节点的删除事件。
-
锁的释放: 当持有锁的客户端完成其临界区代码后,它会删除代表自己的临时节点,从而释放锁。这将触发等待该锁的下一个客户端的监听事件,使其成为新的锁持有者。
etcd实现
对比:
分布式协议:
redis集群,zookeepr,etcd所遵循的分布式协议:Gossip,zab,raft
Gossip和zab都是保证分布式环境下最终一致性协议,raft为强一致性
通讯协议:
redis为RESP协议为一个简单的文本协议。由于其简单性,解析和处理速度很快。
ZooKeeper 使用自定义的二进制协议
-
会话和心跳机制: ZooKeeper 的协议包含会话和心跳机制,这增加了一些开销,但有助于维护系统的稳定性和一致性。
-
支持观察者机制: ZooKeeper 协议支持观察者机制,可以推送节点变化的通知,但这也增加了协议的复杂性。
etcd:grpc,基于 HTTP/2 和 protobuf序列化
通讯协议对比:
特性/协议
|
Redis (RESP)
|
ZooKeeper (自定义协议)
|
etcd (gRPC/HTTP2 + Protocol Buffers)
|
---|---|---|---|
基础协议 | TCP | TCP | HTTP/2 (基于 TCP) |
数据格式 | 文本(简单字符串、错误、整数、批量字符串、数组) | 二进制 | Protocol Buffers (二进制) |
性能 | 高(由于协议简单) | 中(协议相对复杂) | 高(由于 HTTP/2 和 Protocol Buffers) |
多路复用 | 不支持 | 不支持 | 支持 |
流量控制 | 不支持 | 不支持 | 支持 |
双向通信 | 不支持(请求-响应模型) | 支持(通过 watch 机制) | 支持 |
批量处理 | 支持(管道技术) | 不明确支持 | 不明确支持 |
使用场景 | 键值存储 | 分布式协调服务 | 分布式键值存储 |
实现分布式锁对比:
特性
|
Redis (通常使用 RedLock 算法)
|
ZooKeeper
|
etcd
|
---|---|---|---|
性能 | 高 | 中 | 中 |
可靠性 | 中 | 高(由于 ZAB 协议) | 高(由于 Raft 协议) |
易用性 | 高(有多种客户端库支持) | 中(API 相对底层) | 中(API 相对底层) |
超时和过期 | 支持 | 支持 | 支持 |
公平性 | 不保证 | 保证(通过顺序节点) | 不保证 |
结论:
如果 进程暂停、网络延迟是发生在客户端拿到锁之后,那不止 Redlock 有问题,其它锁服务都有类似的问题。
这里我们可以得出一个结论:
一个分布式锁,在极端情况下,不一定是安全的。
对于redlock,不建议使用
原因:
时钟问题:人为,硬件等影响因素,很难保证时钟同步
性能不如单机版 Redis,部署成本也高
优先考虑使用 Redis「主从+哨兵」的模式,实现分布式锁。
那如何正确使用分布式锁
从上面我们也了解到,任何分布式锁都无法完全保证正确性,因此分布式锁时建议
- 在上层使用分布式锁完成「互斥」目的,虽然极端情况下锁会失效,但它可以最大程度把并发请求阻挡在最上层,减轻操作资源层的压力。
- 但对于要求数据绝对正确的业务,在资源层一定要做好「兜底」,发生极端情况时,也不会对系统造成影响