作者:哈哈峰
团队:有赞云
Bond (邦德), 有赞里的一套分布式锁的标准解决方案,它是一套 SDK 型的中间件。现在服务于公司里的核心部门或核心链路,Bond 不仅提供一些面向锁语义的 API,还有提供很多场景解决方案,以及产品化相关的特性。
有赞内部刚开始也是各自业务部门自己实现的简单分布式锁方案,直接依赖公共的 Codis 、Zookeeper,或者是自己维护一套 Redis 集群,各自实现的方式也不大一样,基于这现象,随着业务的扩展,有一些问题慢慢就会浮现:
为了有效地解决这些问题,一套标准分布式锁方案是很有必要的,于是 Bond 分布式锁诞生了。但 Bond 的演进是分阶段的,如下图:
几个阶段的演进持续了一年多的时间,每个阶段都有这个周期自己的使命:
接下来会详细聊聊各个阶段的关键经历和遇到的一些问题。
在一期方案中,主要的是提供可用的分布式锁方案,在初期的用户调研的时候,主要考虑要强一致性,即在 CAP 模型中保证 CP ,结合公司内部的现状,最终在 Zookeeper 与 Etcd 中进行选型,两者的比较主要在两个方面:性能与锁的支持程度。
综合看来,最终是选用了 Etcd 作为第一期方案的直接依赖。Etcd 的最佳部署是奇数个机房,即三机房部署:
这里最主要需要考虑的是综合公司内部的运维情况,引入一个 “全新” 的底层存储,是需要全面评估掌控力,而有赞当时的情况是,Zookeeper 与 Etcd 都运行了较长的一段时间。即使是这样,后面基于 Etcd 的实现在运行了一段时间之后,瓶颈非常容易达到,需要针对场景做好压测以及评估部署情况。
随着一期的运行一段时间后,接入的业务方越来越多,一些业务场景对 RT 等场景要求越来越高,即需求不一样了;以及我们对原来一期中的业务场景进行了重新评估,发现所谓的 “强一致性” 并非强需求,绝大部分场景都是允许 Server 端异常时的不一致性。据此,允许牺牲特殊时期的不一致性,换来非常大的性能提升。
所以,Bond 在二期中引入了新的底层存储 - - Aerospike , 至于为什么选用它,主要是考虑到它的副本特性,而且它在有赞内部也是运行了较长的一段时间。
Bond 的代码实现中应用适配器模式,底层存储的适配也很方便,如下图:
在底层存储能满足性能需求的基础上, Bond 在这一期开始往分布式锁解决方案的层次靠拢,提供一系列的场景解决方案,这也是它的最主要核心能力。
接下来就讲部分遇到的场景问题,以及解决方案。
场景:在进行非阻塞锁加锁,在进行加锁过程中,出现远程 timeout 情况,但是未知server是否加锁成功。
实现方案:当加锁过程中出现客户端返回SocketTimeOutException时,进行再次的尝试加锁。
优点:更大程度保证加锁成功。
缺点:极端情况下,如加锁出现远程连接异常,多一次加锁尝试会增大加锁的时间开销。
性能损耗:正常加锁下损耗极小。当出现远程超时会增多一次重试加锁的时间开销。
弊端:以上方案存在不健全的地方。考虑网络的原因,即当第一次 lock 请求时候,客户端超时,再尝试 get 请求判断锁是否可以重入的时候,发现锁不存在,在第二次发起重试 lock 请求的时候,第一次的 lock 请求已到达且执行成功,则第二次加锁失败,会存在一个 lease time 内没有任何线程拿到这把锁 (实际server端又已经存在) 的问题。 触发的概率非常低,但是有遇到了。
解决:
场景: 线程A B之间存在互相 unlock 的情况
解决方案:轻量化 unlock ,即在允许的时间范围内才可执行 unlock 操作。
T
优点:
缺点:在最极端的边界场景,会有最多两个线程并发执行,需业务方感知到告警且人工处理。
性能损耗:ThreadLocal 的性能损耗,极小。
场景:基于纯 kv 存储 (redis, kvds 等) 支持非公平的阻塞锁。
解决方案:
Avg
是业务平均耗时,这个会根据 threadLocal 中的值计算,若没有执行 unlock 方法,则以 releaseTime 作为平均耗时。【这里是可用户自定义配置的,默认是 bond sdk 做计算】优点:简洁,没有引入其他中间件。
缺点:不适合锁竞争大的场景。
性能损耗:一个线程池、 ThreadLocal 的性能损耗。
场景:同一jvm内大量锁竞争的情况下(热点key)实现快速失败。
解决方案:
优点:本地快速失败,不受网络影响。
缺点:边界状态易失效,依赖本地缓存的性能及可靠性。
性能损耗: 本地缓存读写的性能损耗。
场景:重新发布的时候,有一些锁是没有来得及解锁则应用重启了,会导致这一部分锁没有任何线程可以持有,只能等自动过期,若锁设置的 TTL 比较长,则会等相对比较长的时间。
解决方案:
key-->expiredTime
的 kv 结构 ,解锁成功则 remove 。15s
的锁开启这个方案。优点:轻便,不需要引入其他组件,本地实现。
缺点:对异步解锁的兼容不好,暂不支持异步解锁场景。
性能损耗:ConcurrentHashMap 的损耗,其大小上限基本在 锁TTL*单机QPS
。
场景: 同一个线程中,经过不同的类,有多处地方进行同一把锁的加锁请求。
解决方案:
优点:轻便。
缺点:
性能损耗: ThreadLocal 的性能损耗。
三期方案主要是做监控相关的事情,考虑到篇幅与分享重点,就不详细展开该节。
对于分布式锁来说,监控是必要的,日志最好是有中心化式日志系统来记录一份,根据经验来说,排查问题近乎 100% 需要依靠这个日志系统来定位。如果只是打日志到本机,排查成本非常大,尤其在有赞内部的 SC 环境 几乎无法排查问题。
分布式锁的 TTL 涉及到两个方面的权衡:
所以设置个合理的 TTL 值是非常关键的,可以有效地减少损失。
在这里我们会给个参考值,至少在有赞里面我们是这样推荐业务方遵循的:
2s
,这个值看起来很大,相对于业务 RT 是几十倍甚至上千倍,这就是上面提到的权衡问题,相对来说,我们认为抖动的概率是远比宕机的概率大得多,这也是有数据支撑的,所以我们优先会考虑如何尽量覆盖抖动的情况,再在这基础上减少宕机带来的影响。在 3.1 节中有提到加锁超时的重试方案,但是具体重试几次才是比较合理的呢,这也是个权衡问题:
假设单次加锁的 RPC 请求的超时时间为 100 ms, 请求超时率为 1% 。
请求超时有很多方面的原因,Client 端原因、网络原因、 Server 端原因,都有可能存在。
有赞里面是默认重试 1 次,如果业务方反馈觉得不能接受这个异常率,允许耗时增大,则可以调大重试次数。
在 Bond 分布式锁演进的过程中,有一些没有设计好的点,亦或者是先前没有重点关注的点,以致于在一些场景下发生过不可控的事情,这里分享一些比较通用的场景。
在一期方案的时候,我们只是对应用级别做了一层逻辑隔离,即使同一个 key 的锁,各个应用也是隔离的,不会相互干扰。
随着接入的业务越来越多,使用的姿势参差不齐,一个应用混合着阻塞锁和非阻塞锁使用的场景也不少见,这样导致了后端的底层存储非常难管理。因为两种模型的锁对 Server 端的压力是不一样的,随着量逐渐增大的时候,就会遇到了没法评估现在这个集群还能接入多少量的非阻塞锁的现象,也没办法按照不同的模型做出更好的拆分方案。
为了解决这个问题,在二期的时候,Bond 新增了 API ,是根据业务场景 (business key) 来申请工单,一个应用可以拥有多个加锁场景,而一个场景限定了使用的是阻塞锁 API 还是 非阻塞锁 API 。
热点锁是最初的时候没怎么关注的,完全低估了热点锁带来的锁性能损耗,以致于压测场景没有覆盖到它。无论是在 Etcd 或者是 Aerospike 等底层存储,对于同一个 key 的大量竞争,即使是只有某一个 key 有几十个并发,足以把 Server 的资源消耗保持在高水位线上。
解决这种问题,较好的方式还是 Client 端优先本地竞争,具体实现可以参考 3.4 节。
本地竞争可以让并发数降到等于应用的实例数,即每台实例只有一个 RPC 请求出来。但是如果应用的机器实例数量比较多,还是有可能复现原问题。对于这种场景,有个方案就是嵌入一个中间层,中间层只做一些轻量的操作,而且中间层的实例数会保持在较小的数量,这样可以让并发数降到等于中间层的实例数。但是它也有风险点,就是维护中间层,且保证高可用性。
Bond 分布式锁一直服务于有赞内部,它的场景解决方案都是基于实际场景思考而得出,我们也在不断地探索中前行,欢迎各位读者与我们互动,欢迎提出更好的方案~
不久的将来,Bond 分布式锁也会输出到有赞云给外部开发者使用~