分布式锁定义
分布式锁在分布式环境下,锁定全局唯一公共资源,表现为:
请求串行化
互斥性
第一步是上锁的资源目标,是锁定全局唯一公共资源,只有是全局唯一的资源才存在多个线程或服务竞争的情况。
互斥性表现为一个资源的隔离级别串行化,如果对照单机事务 ACID 的隔离性来说,互斥性的事务隔离级别是 SERLALIZABLE,属于最高的隔离级别。
事务隔离级别:
DEFAULT
READ_UNCOMMITTED
READ_COMMITED
REPEATABLE_READ
SERLALIZABLE
分布式锁目的
分布式锁的目的如下:
解决业务层幂等性
解决 MQ 消费端多次接受同一消息
确保串行|隔离级别
多台机器同时执行定时任务
寻找唯一资源进行上锁
例子:
- 防止用户重复下单 共享资源进行上锁的对象 : 【用户id】
- 订单生成后发送MQ给消费者进行积分的添加 寻找上锁的对象 :【订单id】
- 用户已经创建订单,准备对订单进行支付,同时商家在对这个订单进行改价 寻找上锁对象 : 【订单id】
基于 Redis 分布式锁
Redis 单线程串行处理天然就是解决串行化问题,用来解决分布式锁是再适合不过。
实现方式:
setnx key value Expire_time //获取到锁 返回 1 , 获取失败 返回 0
存在问题如下:
锁时间不可控
Redis 只能在 Setnx 指定一个锁的超时时间,假设初始设定锁的时间是 10 秒钟,但是业务获取到锁跑了 20 秒钟,在 10 秒钟之后,如果又有一个业务可以获取到相同的一把锁。
这个时候可能就存在两个相同的业务都获取得到锁的问题,并且两个业务处在并行阶段。也就是第一个获取锁的业务无法对自身的锁进行续租。
单点连接超时问题
Redis 的 Client 与 Server 端并没有维持心跳的机制,如果在连接中出现问题,Client 会得到一个超时的回馈。
主从问题
Redis 的集群实际上在 CAP 模式中是处在与 AP 的模型,保证可用性。在主从复制中“主”有数据,但可能“从”还没有数据。这个时候,一旦主挂掉或者网络抖动等各种原因,可能会切换到“从”节点。
这个时候有可能会导致两个业务线程同时的获取到两把锁:
1.业务线程-1:向主节点请求锁
2.业务线程-1:获取锁
3.业务线程-1:获取到锁并开始执行业务
4.这个时候 Redis 刚生成的锁在主从之间还未进行同步
5.Redis 这时候主节点挂掉了
6.Redis 的从节点升级为主节点
7.业务线程-2:向新的主节点请求锁
8.业务线程-2:获取到新的主节点返回的锁
9.业务线程-2:获取到锁开始执行业务
10.这个时候业务线程-1和业务线程-2同时在执行任务
Redlock
上述的问题其实并不是 Redis 的缺陷,只是 Redis 采用了 AP 模型,它本身无法确保我们对一致性的要求。
Redis 官方推荐 Redlock 算法来保证,问题是 Redlock 至少需要三个 Redis 主从实例来实现,维护成本比较高。
相当于 Redlock 使用三个 Redis 集群实现了自己的另一套一致性算法,比较繁琐,在业界也使用得比较少。
能不能使用 Redis 作为分布式锁
能不能使用 Redis 作为分布式锁,这个本身就不是 Redis 的问题,还是取决于业务场景,我们先要自己确认我们的场景是适合 AP 还是 CP。
如果在社交发帖等场景下,我们并没有非常强的事务一致性问题,Redis 提供给我们高性能的 AP 模型是非常适合的。
但如果是交易类型,对数据一致性非常敏感的场景,我们可能要寻找一种更加适合的 CP 模型。
Redis 可能作为高可用的分布式锁并不合适,我们需要确立高可用分布式锁的设计目标。
高可用分布式锁设计目标
高可用分布式锁的设计目标如下:
强一致性,是 CP 模型
服务高可用,不存在单点问题
锁能够续租和自动释放
业务接入简单
三种分布式锁方案对比
常用的三种分布式锁方案对比如下图:
基于 Zookeeper 分布式锁
刚刚也分析过,Redis 其实无法确保数据的一致性,先来看 Zookeeper 是否合适作为我们需要的分布式锁。
首先 ZK 的模式是 CP 模型,也就是说,当 ZK 锁提供给我们进行访问的时候,在 ZK 集群中能确保这把锁在 ZK 的每一个节点都存在。
这个实际上是 ZK 的 Leader 通过二阶段提交写请求来保证的,这个也是 ZK 的集群规模大了的一个瓶颈点。
ZK 锁实现的原理
说 ZK 的锁问题之前先看看 Zookeeper 中的几个特性,这几个特性构建了 ZK 的一把分布式锁。
Zookeeper 中的几个特性如下:
有序节点,当在一个父目录下如 /lock 下创建有序节点,节点会按照严格的先后顺序创建出自节点 lock000001,lock000002,lock0000003,以此类推,有序节点能严格保证各个自节点按照排序命名生成。
临时节点,客户端建立了一个临时节点,在客户端的会话结束或会话超时,Zookeeper 会自动删除该节点 ID。
事件监听,在读取数据时,我们可以对节点设置监听,当节点的数据发生变化(1 节点创建,2 节点删除,3 节点数据变动,4 子节点变动)时,Zookeeper 会通知客户端。
结合这几个特点,来看下 ZK 是怎么组合分布式锁:
业务线程-1,业务线程-2 分别向 ZK 的 /lock 目录下,申请创建有序的临时节点。
业务线程-1 抢到 /lock0001 的文件,也就是在整个目录下最小序的节点,也就是线程-1 获取到了锁。
业务线程-2 只能抢到 /lock0002 的文件,并不是最小序的节点,线程 2 未能获取锁。
业务线程-1 与 lock0001 建立了连接,并维持了心跳,维持的心跳也就是这把锁的租期。
当业务线程-1 完成了业务,将释放掉与 ZK 的连接,也就是释放了这把锁。
ZK 分布式锁的代码实现
ZK 官方提供的客户端并不支持分布式锁的直接实现,我们需要自己写代码去利用 ZK 的这几个特性去进行实现:
ZK 分布式锁客户端假死的问题
客户端创建了临时有序节点并建立了事件监听,就可以让业务线程与 ZK 维持心跳,这个心跳也就是这把锁的租期。
当客户端的业务线程完成了执行就把节点进行删除,也就释放了这把锁,不过中间也可能存在问题:
客户端挂掉。因为注册的是临时节点,客户端挂掉,ZK 会进行感知,也就会把这个临时节点删除,锁也就随着释放。
业务线程假死。业务线程并没有消息,而是一个假死状态,(例如死循环,死锁,超长 GC),这个时候锁会被一直霸占不能释放,这个问题需要从两个方面进行解决。
第一个是本身业务代码的问题,为何会出现死循环,死锁等问题;第二个是对锁的异常监控问题,这个其实也是微服务治理的一个方面。
ZK 分布式锁的 GC 问题
刚刚说了 ZK 锁的维持是靠 ZK 和客户端的心跳进行维持,如果客户端出现了长时间的 GC 会出现什么状况:
1.业务线程-1 获取到锁,但未开始执行业务。
2.业务线程-2 发生长时间的 GC。
3.业务线程-1 和 ZK 的心跳发生断链。
4.lock0001 的临时节点因为心跳断链而被删除。
5.业务线程-2 获取到锁。
6.业务线程-2 开始执行业务。
7.业务线程-1 GC完毕,开始执行业务。
8.业务线程-1 和业务线程-2 同时执行业务。
基于 Etcd 分布式锁
Etcd 分布式锁的实现原理
Etcd 实现分布式锁比 ZK 要简单很多,就是使用 Key Value 的方式进行写入。
在集群中,如果存在 Key 的话就不能写入,也就意味着不能获取到锁,如果集群中,可以写入 Key,就意味着获取得到锁。
Etcd 到使用了 Raft 保证了集群的一致性,也就是在外界看来,只要 Etcd 集群中某一台机器存在了锁,所有的机器也就存在了锁。
这个跟 ZK 一样属于强一致性,并且数据是可以进行持久化,默认数据一更新就持久化。
锁的租期续约问题
Etcd 并不存在一个心跳的机制,所以跟 Redis 一样获取锁的时候就要对其进行 Expire 的指定,这个时候就存在一个锁的租期问题。
租期问题有几种思路可以去解决,这里讨论其中一种:在获取到锁的业务线程,可以开启一个子线程去维护和轮训这把锁的有效时间,并定时的对这把锁进行续租。
假设业务线程获取到一把锁,锁的 Expire 时间为 10s,业务线程会开启一个子线程通过轮训的方式每 2 秒钟去把这把锁进行续租,每次都将锁的 Expire 还原到 10s。
当业务线程执行完业务时,会把这把锁进行删除,事件完毕。
这种思路一样会存在问题:
客户端挂掉,业务线程和续租子线程都会挂掉,锁最终会释放。
业务线程假死,这个跟 ZK 的假死情况一样,也是属于业务代码应该解决的问题。
客户端超长 GC 问题,长 GC 导致续租子进程没有进行及时续租,锁被超时释放。(GC 的问题可能是个极端问题,一般 GC 超过几秒就可能去查看问题了)
总结
首先得了解清楚我们使用分布式锁的场景,为何使用分布式锁,用它来帮我们解决什么问题,先聊场景后聊分布式锁的技术选型。
无论是 Redis,ZK,Etcd,其实在各个场景下或多或少都存在一些问题,例如:
Redis 的 AP 模型会限制很多使用场景,但它却拥有了几者中最高的性能。
ZK 的分布式锁要比 Redis 可靠很多,但他繁琐的实现机制导致了它的性能不如 Redis,而且 ZK 会随着集群的扩大而性能更加下降。
Etcd 看似是一种折中的方案,不过像锁的租期续约都要自己去实现。