评论

收藏

[PHP] 开源 | WLock:高可用分布式锁设计实践

开发技术 开发技术 发布于:2022-08-16 19:28 | 阅读数:428 | 评论:0

项目名称:WLock
Github地址:
https://github.com/wuba/WLock.git
WLock是一套基于58已开源的一致性算法组件WPaxos实现的高可靠、高吞吐分布式锁服务,可应用于分布式环境下协调多进程/线程对共享资源的访问控制、多节点Master选主等业务场景。

核心特性
  • 丰富的锁类型:可重入锁、公平锁、优先级锁、读写锁、进程锁、线程锁;
  • 灵活的锁操作:支持阻塞/非阻塞、同步/异步watch等方式获取锁,支持锁续约,TTL等机制;
  • 高可靠性:基于Paxos算法实现多副本数据同步,Master节点故障时主从自动切换,在无Master或者Master漂移过程仍可保证锁状态的持续一致性,不影响正常锁操作;
  • 高吞吐:多Paxos分组的Master均匀分布在所有集群节点,不同Paxos分组的锁操作并行处理,相同Paxos分组锁操作批量合并处理,大大提升了系统的吞吐量;
  • 多租户:提供秘钥作为集群分配、锁操作隔离、权限控制的租户单位,支持单个秘钥跨集群动态平滑迁移;
  • 易用性:丰富的锁接口封装,开箱即用;
01、项目背景
在分布式部署的应用集群中,经常会存在一些业务场景,为了保证某些业务逻辑的准确性,或者避免某些逻辑被重复执行,需要限制多个应用进程或线程对共享数据资源进行互斥访问,例如秒杀下单、商品抢购等场景,通常的解决方案是引入分布式锁技术。
对于一些比较复杂的分布式场景,除了要求分布式锁具有互斥性、避免死锁、可重入等基本特性外,同时对分布式锁服务的吞吐性能、数据一致性、可靠性等方面也有很强的要求,了解CAP理论的都知道,这是分布式系统设计的难点。当前已有的分布式锁解决方案,也很难同时满足这几个特性,通常需要做出取舍,如基于Redis封装实现的分布式锁牺牲数据强一致性来保证吞吐量,或基于Zookeeper、Etcd封装实现的分布式锁牺牲一定的可用性保证数据强一致性。本项目希望提供一种分布式锁方案,不仅满足高吞吐的业务场景需求,还能够保证服务具有比较高的可用性和可靠性。
02、设计实践
功能架构如下:
    DSC0000.png
目前开源的模块主要包括负责锁核心交互的客户端、服务端,以及负责配置管理的注册中心三部分。其中核心实现有以下几个部分:
2.1 可靠存储设计
WLock选择高吞吐的键值存储系统RocksDB来持久化锁的状态信息,基于WPaxos组件实现多副本数据同步以及主从自动切换能力;每个节点配置相同数量的Paxos分组,客户端根据锁名称,将锁哈希到某一个固定Paxos分组,默认将锁请求发送到该Paxos分组对应的Master节点,锁状态更新时,由Master节点发起Propose请求同步给其它Slave节点,最终在执行状态机时写入RocksDB,保证多副本数据的强一致性,如下图所示:
DSC0001.png

我们知道Zookeeper、Etcd同样也采用一致性协议实现多副本可靠存储,但不同的是,它们只有单个Zab或Raft实例串行同步数据,而WLock采用多Paxos分组机制,不同分组的Master在集群中均匀分布,集群节点对等部署,可并行同步数据,大大提升了系统的吞吐能力;另外,得益于Paxos协议的灵活性,不对Master强依赖,当Master节点关闭或者出现异常时,未选出新的Master之前,可将锁操作请求发送到指定的候选节点来处理,保证系统持续提供服务,具有更高的可用性。
2.2 锁操作实现
WLock封装提供了丰富的锁类型如可重入锁、公平锁、优先级锁、读写锁等,下面以可重入锁为例,介绍下加锁/释放锁实现。
DSC0002.png
    DSC0003.png
DSC0004.png

DSC0005.png

DSC0006.png
  • 阻塞/非阻塞获取锁
   WLock提供有阻塞与非阻塞方式获取锁,区别在于是否等待服务端加锁成功。非阻塞方式根据服务端锁当时的状态,直接返回客户端加锁成功或失败;而阻塞方式在锁已被其它owner抢占的情况下,会在服务端内存注册一个WatchEvent事件等待获取锁,当锁被释放时,服务端立即从Watch等待队列中选择一个优先级最高的WatchEvent直接执行加锁,并通知唤醒监听的客户端。
相对于其它分布式锁通过客户端定时轮询实现的阻塞机制,WLock的阻塞机制在锁竞争度比较高时,获取锁的延迟低、实时性更强,还能够更好支持优先级锁,但也存在一个缺陷,WatchEvent长期存储于服务端缓存队列,若客户端所在机器宕机或者出现一些网络异常,服务端不能及时感知到客户端异常变化时,挂起在服务端的WatchEvent变为无效状态且不会被立即剔除,这时唤醒该WatchEvent加锁成功后,不能成功通知到客户端,锁过期前又不能被其它客户端抢占。为此,WLock引入两种机制来优化这个问题,
1)WatchEvent添加心跳保活机制。客户端每隔20s定时向服务端发起WatchEvent心跳,服务端接收到后,发现WatchEvent已存在,便延长WatchEvent的有效时间,服务端定时检测内存中WatchEvent是否有效,做过期剔除处理。
2)通过监听WatchEvent获取到锁时,客户端首先进行续约Touch。服务端在选择WatchEvent执行加锁时,会先把锁的过期时间调整为Math.min(锁真实过期时间,10s),客户端收到加锁成功通知后需要立即发起续约Touch,服务端收到续约请求后再延长锁的过期时间为客户端接口设置的真实过期时间。这样,若通知的客户端已不活跃,最长阻塞10s锁不能被其它客户端抢占。
该WatchEvent处理机制,同样应用于异步Watch方式的加锁处理。
  • 可重入实现
   WLock可重入机制是由客户端通过锁上下文中的AcquireCount原子变量计数控制,第一次获取锁时请求服务端加锁,成功后进行本地计数,其它情况下,将加锁请求转为续约锁请求发送到服务端;释放锁过程,只有AcquireCount小于等于0时,才向服务端释放锁,否则只是本地计数减一处理。这样设计考虑的是,若在服务端计数控制,在某些异常情况下,当返回客户端的加锁请求超时,实际服务端执行加锁计数成功时,可能会导致客户端与服务端重入锁计数不一致而产生死锁。
  • TTL机制
   为了避免死锁,服务端要求每个锁都设有过期时间,过期时间需要根据客户端对共享资源正常访问时间合理设置。如果设置太短,有可能在客户端完成对共享资源访问之前,锁就发生过期,从而破坏锁的安全性;如果设置太长,一旦某个持有锁的客户端释放锁失败,就会导致一段时间内其它客户端都无法获到取到锁。
WLock服务端限制锁的过期时间最多为5分钟,但是对客户端设置的锁过期时间则不做限制,对于过期时间超过5分钟的加锁请求,通过自动定时续约来延长锁的过期时间,客户端发送到服务端的加锁和续约锁请求中,携带的锁过期时间最大也只能为5分钟。自动续约周期默认为Math.min(锁过期时间,5分钟)的1/3(最小为1秒),这样在锁过期前,允许客户端至少有两次容错机会。
DSC0007.png
WLock计算过期时间戳的方式是[加锁成功的起始时间/续约锁成功的起始时间+锁过期时间],锁过期检测严格依赖机器时钟,为了避免集群服务节点间的时钟差异,导致不同节点计算锁过期触发时机不一致,WLock限制服务端只有Master节点主动检测锁的过期状态,检测到锁过期时,由Master发起Propose请求同步Slave节点对过期锁进行删除处理,并将ExpireEvent通知到客户端,降低了时钟不一致对锁服务的影响。由于锁过期检测Task仅存储在Master内存,当Master发生漂移时,新的Master需要从RocksDB中重新加载分组下所有Lockkey信息,生成锁过期检测Task分发到根据过期时间排序的优先级队列,并恢复检测任务,这个过程通常在几秒内完成,这期间可能会导致服务端检测锁过期存在延迟。为了不受服务端或网络异常影响,WLock客户端同时也开启有锁的过期检测任务,锁过期时间比服务端延迟了加锁或续约请求Response网络返回时间,客户端对于过期回调的执行做了幂等判断,避免并发重复执行。
大神 Martin Kleppmann在文章《How to do distributed locking》中对分布式锁原理进行论证时,指出Redlock可能存在的安全问题:当客户端获取到锁后发生GC pause或者服务端出现时钟回退,有可能在锁持有者释放锁之前,锁就发生过期,此时如果另外一个客户端抢占到锁,锁的互斥性会被破坏。WLock同样是引入了锁版本号(fencing token)来解决这个问题,但是需要用户自己在访问共享资源时,携带并比较当前资源更新操作的锁最大版本号(类似于乐观锁机制),再结合事务机制保证数据操作的一致性。
  • 续约机制
   WLock支持主动和自动两种续约机制,主动续约机制可通过客户端调用续约接口触发,自动续约机制可在加锁时通过参数配置启动。由于续约线程与锁持有线程通常为两个线程,自动续约机制存在一定的安全风险,业务在加锁处理逻辑的外层finally逻辑中一定要释放锁(远程释放锁前,会先停止自动续约任务),否则锁持有线程异常退出时,锁自动续约还会一直执行,导致锁永远不过期,出现死锁。
  • 事件补偿
   WLock通过[Host, Pid,ThreadID]来定义一个锁Owner,ThreadID为-1时锁为进程粒度,否则为线程粒度。锁owner不会随着客户端与服务端连接状态的更新而变化,但锁状态发生变更时,服务端会向锁owner或者锁监听者主动推送一些事件如WatchEvent、ExpireEvent,为此,服务端需要维护锁owner或锁监听者与客户端连接的对应关系。
当WLock服务端Master发生漂移或者网络连接异常重连时,客户端连接绑定关系会同时发生变更,此时就需要客户端主动进行事件补偿,补偿类型包括两种:
1)WatchEvent事件补偿,重新向服务端注册监听事件,服务端更新WatchEvent与客户端连接绑定关系。
2)AcquireEvent事件补偿,客户端对已持有的锁进行续约,服务端收到请求后不更新锁的过期时间,只更新锁owner与客户端连接的对应关系。
2.3. 高并发优化
前面提到,WLock通过引入多Paxos分组,多节点互为主备对等部署并行同步数据的集群架构,已经一定程度上提升了系统的吞吐能力。此外,Wlock还充分利用了WPaxos组件批量Propose的功能,对单分组的锁操作实现做了以下优化,进一步提升了系统的并发处理能力:
DSC0008.png
1)单个Paxos分组采用多线程并行处理锁请求,相同Lockkey的锁请求哈希到同一个线程串行处理;
2)对单个Paxos分组下不同线程阻塞的锁请求进行合并,批量发起Propose同步数据,降低了网络传输与多任务调用切换成本。
03、为什么选择WLock
在分布式领域中,分布式锁已经是一种比较成熟的技术,现有的实现方案已有很多,为什么还开发WLock,优势又有哪些?接下来我们从功能、服务特性、性能三个维度,介绍下常见的几种分布式锁的差异点。
  • 功能
   
DSC0009.png
  • 服务特性
DSC00010.png
  • 性能
   测试运行环境
机器配置:CPU:20 x Intel(R) Xeon(R) Silver 4114 CPU @ 2.20GHz  
内存:192 GB  
硬盘:SSD
网卡:万兆网卡  
服务端集群机器个数:3台
测试结果
1. 单客户端qps:
DSC00011.png
2. 相同并发下,请求响应延迟(单位ms)
DSC00012.png

说明:以上对比测试的中数据,Redis、ZK、Etcd相关非官方数据,均由我们在相同环境下实际压测得到。其中,对于QPS的统计,客户端请求一次加锁再请求一次释放锁合并为一次计数,更详细的压测数据及压测条件可查看开源对比文档。
通过以上几个维度的测试分析,WLock的优势在于可靠性与系统吞吐量比较高,处理延迟略低于Redis,但明显高于Zookeeper与Etcd,为此,对于分布式锁选型有以下建议:
  • 对可靠性要求不高,响应延迟比较敏感的场景,锁并发低于3W时可使用Redis,高于3W建议用WLock;
  • 对可靠性要求比较高,同时锁并发高于500的场景,可使用WLock;
04、未来规划
1. 提供GO、PHP、Node等多语言SDK
2. 开源WEB管控中心、监控模块
3. 支持分布式信号量机制
参考资料
  • WPaxos源码地址:https://github.com/wuba/WPaxos
  • 开源|WPaxos:一致性算法Paxos的生产级高性能Java实现:https://mp.weixin.qq.com/s/bydpMwTWAamS3u8Ko57iYg
  • How to do distributed locking:
    https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
   作者简介
   刘丹,58同城后端架构师,58分布式消息队列、分布式锁、分布式链路追踪等系统负责人
如何贡献&问题反馈
诚挚邀请对分布式锁感兴趣的同学一起参与WLock项目的开发建设,提出宝贵意见和建议,可在https://github.com/wuba/WLock.git开源社区提交issue与Pull Request反馈给我们。也可以扫描微信号,备注WLock,加入微信交流群。

关注下面的标签,发现更多相似文章