Bond (邦德), 有赞里的一套分布式锁的标准解决方案,它是一套 SDK 型的中间件。 现在服务于公司里的核心部门或核心链路,Bond 不仅提供一些面向锁语义的 API,还有提供很多场景解决方案,以及产品化相关的特性。

一、背景

有赞内部刚开始也是各自业务部门自己实现的简单分布式锁方案,直接依赖公共的 Codis 、Zookeeper,或者是自己维护一套 Redis 集群,各自实现的方式也不大一样,基于这现象,随着业务的扩展,有一些问题慢慢就会浮现:

  • 重复劳动:重复造轮子。
  • 细节掌控:可能部分同学对分布式锁的掌控力不足,对实现细节把控不到位,但这种场景是需要非常严谨的。
  • 存储选型:业务同学直接接触到了底层存储,但是对底层存储的选型把控不一定很准确。
  • 场景共享:一个业务部门遇到的 "坑",填完 “坑” 之后没法有效地分享给其他部门。
  • 监控告警:加解锁的监控、日志等信息不完善。

为了有效地解决这些问题,一套标准分布式锁方案是很有必要的,于是 Bond 分布式锁诞生了。但 Bond 的演进是分阶段的,如下图:

几个阶段的演进持续了一年多的时间,每个阶段都有这个周期自己的使命:

  • 阶段一: 可用性,提供可用的分布式锁基础组件。
  • 阶段二: 解决方案,提供一系列场景的解决方案,从原来的加解锁组件上升到分布式锁解决方案的层次。
  • 阶段三: 产品化,可以提供中间件监控、日志和锁链路追踪的能力,在页面上展示。
  • 阶段四: 内部沉淀,工单自动化接入与需求反馈等。
  • 阶段五: 对外输出,输出能力到外部,如有赞云的基础组件。

接下来会详细聊聊各个阶段的关键经历和遇到的一些问题。

二、一期方案

在一期方案中,主要的是提供可用的分布式锁方案,在初期的用户调研的时候,主要考虑要强一致性,即在 CAP 模型中保证 CP ,结合公司内部的现状,最终在 Zookeeper 与 Etcd 中进行选型,两者的比较主要在两个方面:性能锁的支持程度

2.1 性能比较

  • CPU消耗

  • 内存消耗

  • 写节点速度 :

  • 延迟

2.2 锁支持程度

  • Zookeeper :基本原理是临时有序节点+监听器的方案,但是这一套的实现都是在 Client 端实现,即加一次锁可能会有多次的网络请求。而且临时节点,若在网络抖动的情况即会导致锁对应的节点被立即释放,有一定概率会产生并发的情况。
  • Etcd : 有序节点+监听器的方案, 这一套实现是 Server 端实现,对于 Client 端就只是一个 Lock 指令。

2.3 总结

综合看来,最终是选用了 Etcd 作为第一期方案的直接依赖。 Etcd 的最佳部署是奇数个机房,即三机房部署:

这里最主要需要考虑的是综合公司内部的运维情况,引入一个 “全新” 的底层存储,是需要全面评估掌控力,而有赞当时的情况是,Zookeeper 与 Etcd 都运行了较长的一段时间。 即使是这样,后面基于 Etcd 的实现在运行了一段时间之后,瓶颈非常容易达到,需要针对场景做好压测以及评估部署情况。

三、二期方案

随着一期的运行一段时间后,接入的业务方越来越多,一些业务场景对 RT 等场景要求越来越高,即需求不一样了;以及我们对原来一期中的业务场景进行了重新评估,发现所谓的 “强一致性” 并非强需求,绝大部分场景都是允许 Server 端异常时的不一致性。 据此,允许牺牲特殊时期的不一致性,换来非常大的性能提升。

所以,Bond 在二期中引入了新的底层存储 - - Aerospike , 至于为什么选用它,主要是考虑到它的副本特性,而且它在有赞内部也是运行了较长的一段时间。

Bond 的代码实现中应用适配器模式,底层存储的适配也很方便,如下图:

在底层存储能满足性能需求的基础上, Bond 在这一期开始往分布式锁解决方案的层次靠拢,提供一系列的场景解决方案,这也是它的最主要核心能力。

接下来就讲部分遇到的场景问题,以及解决方案。

3.1 TimeOut重试重入方案

场景:在进行非阻塞锁加锁,在进行加锁过程中,出现远程 timeout 情况,但是未知server是否加锁成功。

实现方案:当加锁过程中出现客户端返回SocketTimeOutException时,进行再次的尝试加锁。

  • 使用当前节点IP、随机数、时间戳组成当前进程变量,将进程变量和当前线程信息作为存储的value值。

  • 出现远程timeout时,根据key值获取对应的value值来判断当前线程是否持有锁

    • 如果当前线程持有锁,直接返回加锁成功;如果其他线程已经获取到锁,返回加锁失败。
    • 如果当前没有线程持有锁,则进行第二次尝试加锁,加锁成功返回true,如果加锁仍然失败或者出现异常,则返回false或者抛出异常。
  • 说明:

    • 加锁过程中get一次value,判断是否加锁成功,失败才进行加锁重试,因为底层的存储可靠性较高,在绝大多数情况下是加锁成功的,首先进行get操作更合理。
    • 如果在第二次尝试加锁中,get或者doLock任一过程出现异常,则不再进行尝试加锁,直接抛出异常。

优点:更大程度保证加锁成功。

缺点:极端情况下,如加锁出现远程连接异常,多一次加锁尝试会增大加锁的时间开销。

性能损耗:正常加锁下损耗极小。当出现远程超时会增多一次重试加锁的时间开销。

3.1.1 问题浮现

弊端:以上方案存在不健全的地方。考虑网络的原因,即当第一次 lock 请求时候,客户端超时,再尝试 get 请求判断锁是否可以重入的时候,发现锁不存在,在第二次发起重试 lock 请求的时候,第一次的 lock 请求已到达且执行成功,则第二次加锁失败,会存在一个 lease time 内没有任何线程拿到这把锁 (实际server端又已经存在) 的问题。 触发的概率非常低,但是有遇到了。

解决:

  • 先加锁,若顺利获取结果,则返回结果。
  • 若第一次加锁请求超时,则立即重试,成功则返回,再超时则抛出异常 — —
    • — — 失败则 get value 判断是否为当前线程的锁,若顺利获取结果,则返回结果,超时则抛出异常。

3.2 Unlock 线程隔离方案

场景: 线程A B之间存在互相 unlock 的情况

解决方案:轻量化 unlock ,即在允许的时间范围内才可执行 unlock 操作。

  • 在发送 lock 请求之前记录时间点 T
  • 执行 unlock 操作的时候
    • 若当前时间 <= ( T + leaseTime - 150ms ) ,发送 unlock 请求。
    • 若当前时间 > ( T + leaseTime -150ms ) && 当前时间 < ( T + leaseTime ) , 不执行任何操作。
    • 若当前时间已经超时,则不发送 unlock 请求,且 告警 给对应的应用 owner 。
  • (150ms 是给予 unlock 操作的缓冲时间段,即从发送请求开始到 server 执行unlock操作的时间段) 【这里是结合有赞内部的网络情况考虑,且覆盖部分"毛刺"现象】

优点:

  • 各个线程之间不会相互 unlock 非自己的锁。
  • 即使在最极端的边界场景,也只会最多两个线程并发执行,但是会有告警,可以让业务方及时感知到。

缺点:在最极端的边界场景,会有最多两个线程并发执行,需业务方感知到告警且人工处理。

性能损耗:ThreadLocal 的性能损耗,极小。


3.3 Kv 阻塞锁方案

场景:基于纯 kv 存储 (redis, kvds 等) 支持非公平的阻塞锁。

解决方案:

  • 加锁逻辑封装为 Future 控制 waitTime 时间。
  • 尝试加锁失败的时候, sleep 业务平均耗时的 1/2 ,考虑短时间内尝试多一次,因为加锁请求非顺序请求,完全可能是 B 请求在 A 请求之后 1ms 来到,即拥有随机性。
  • 当 Future 超时的时候,可以区分是 server 异常问题还是并发加锁的问题,当然这会受 waitTime 值影响,若不是用户请求的流量,waitTime 值建议值 500ms ,用户请求的流量建议值 30 ~ 50ms (平均单次请求<3ms)。【这里是结合有赞内部的网络情况考虑,且覆盖部分"毛刺"现象】
  • Future 超时的时候,会异步结束 Future 的结果,若是拿到锁的结果,则释放锁,这个过程会在异步中执行。
  • Avg 是业务平均耗时,这个会根据 threadLocal 中的值计算,若没有执行 unlock 方法,则以 releaseTime 作为平均耗时。 【这里是可用户自定义配置的,默认是 bond sdk 做计算】

优点:简洁,没有引入其他中间件。

缺点:不适合锁竞争大的场景。

性能损耗:一个线程池、 ThreadLocal 的性能损耗。


3.4 本地竞争方案

场景:同一jvm内大量锁竞争的情况下(热点key)实现快速失败。

解决方案:

  • 核心实现:本地缓存本jvm内加锁状态,键为加锁的key,值为线程唯一的标志threadId。
  • 尝试加锁的时候执行putIfAbsent操作:
    • 返回null表示进程内未加锁,写入threadId并返回true,继续加锁。
    • 返回值等于当前threadId表示本线程持有锁,继续加锁。
    • 返回值不等于当前threadId表示其它线程持有锁,放弃加锁。
  • 加锁异常与失败时进行数据恢复:
    • 执行getIfPresent操作,若返回值等于本线程threadId,删除key。
  • 尝试解锁的时候执行getIfPresent操作:
    • 返回null表示锁已过期或被同一线程解锁,放弃解锁。
    • 返回值不等于当前threadId表示其它线程持有锁,放弃解锁。
    • 返回值等于当前threadId表示本线程持有锁,删除key,继续解锁。
  • 缓存设置失效时间,防止某线程获取锁之后未执行释放锁操作。
  • 每种key对应缓存的失效时间为该首次执行加锁方法传入的失效时间。

优点:本地快速失败,不受网络影响。

缺点:边界状态易失效,依赖本地缓存的性能及可靠性。

性能损耗: 本地缓存读写的性能损耗。


3.5 优雅停机方案

场景:重新发布的时候,有一些锁是没有来得及解锁则应用重启了,会导致这一部分锁没有任何线程可以持有,只能等自动过期,若锁设置的 TTL 比较长,则会等相对比较长的时间。

解决方案:

  • 内存中有一个 Map 来记录哪些锁是还没有主动释放的。
  • 加锁成功则 put 进 Map 中,其中是 key --> expiredTime 的 kv 结构 ,解锁成功则 remove 。
  • 有一个异步的线程定期清理 Map 过期无效的数据,因为解锁失败会遗留一些脏数据。
  • Spring 容器关闭的时候,遍历 Map 中所有的锁,对未过期的锁做 unlock 操作。
  • 关键点:

    • 该方案仅面向同步解锁的场景。因为异步解锁有可能是跨应用实例解锁,这会导致原加锁的实例没有移除 Map 的操作,只能等过期后被 Timer 定期清除,会导致Map的大小非常大。
    • 仅面向同步解锁且有解锁操作的场景。有一些场景是锁一段时间,让这一段时间仅做一次业务操作,然后让锁自动过期的。需要识别这种场景。
    • 仅面向长时间 TTL 的情景。因为这种 Map 的 put 和 remove 是有性能损耗的,在一些业务比较快的场景,业务逻辑一般在毫秒级别,设置锁的 TTL 一般都在 2s 左右,重启应用的时候,这把锁在短时间内就过期了,远比应用重启的时间短。 所以这个方案对于 TTL 时间短的锁,带来的价值不一定比它带来的损耗大。默认对超过 15s 的锁开启这个方案。

优点:轻便,不需要引入其他组件,本地实现。

缺点:对异步解锁的兼容不好,暂不支持异步解锁场景。

性能损耗:ConcurrentHashMap 的损耗,其大小上限基本在 锁TTL * 单机QPS


3.6 可重入锁方案

场景: 同一个线程中,经过不同的类,有多处地方进行同一把锁的加锁请求。

解决方案:

  • 线程本地变量存储当前的状态,根据 lock key 判断是否为新调用,如果是则执行实际加锁请求,否则则重入计数器 +1 。
  • 解锁则重入计数器先 -1 ,再判断是否小于 0 ,是的话则执行真实的解锁请求。

优点:轻便。

缺点:

  • 在 Lock Lock Unlock 的这种场景下,如果两次请求的 key 刚好相等,则会出现问题,虽然概率很低。
    • 人为的两种使用姿势混用。
    • Unlock 解锁异常导致。
  • 在 Lock A 、再 Lock B 会丢失 A 的重入信息,但是这种类组合锁的形式不允许使用。

性能损耗: ThreadLocal 的性能损耗。

四、三期方案

三期方案主要是做监控相关的事情,考虑到篇幅与分享重点,就不详细展开该节。

对于分布式锁来说,监控是必要的,日志最好是有中心化式日志系统来记录一份,根据经验来说,排查问题近乎 100% 需要依靠这个日志系统来定位。如果只是打日志到本机,排查成本非常大,尤其在有赞内部的 SC 环境 几乎无法排查问题。

五、细节讨论

5.1 合理的锁 TTL

分布式锁的 TTL 涉及到两个方面的权衡

  • 一方面是如果设置太小,则有可能因为一些网络抖动或者GC等不可控因素引起的耗时增大,导致在业务逻辑还没有执行完,这把锁就已经因为超时而被 Server 端自动释放掉了,则有概率存在两个线程在执行业务。
  • 另一方面是如果设置太大,则有可能因为服务器突然宕机,而引起这把锁无法在短时间内被释放,然而这把锁是没有实际的持有者,会造成业务在这段时间的不可用。

所以设置个合理的 TTL 值是非常关键的,可以有效地减少损失。

在这里我们会给个参考值,至少在有赞里面我们是这样推荐业务方遵循的:

  • 如果业务逻辑执行比较久,就比如一些离线的任务,这个是业务方自己评估一个保守值即可,通过监控看到最大耗时,然后基于这个值加上 20% 或适度加一些时间即可。
  • 如果业务逻辑执行很快,平均 RT 在毫秒级的业务,我们都是推荐设置 2s ,这个值看起来很大,相对于业务 RT 是几十倍甚至上千倍,这就是上面提到的权衡问题,相对来说,我们认为抖动的概率是远比宕机的概率大得多,这也是有数据支撑的,所以我们优先会考虑如何尽量覆盖抖动的情况,再在这基础上减少宕机带来的影响。

5.2 加锁重试次数

在 3.1 节中有提到加锁超时的重试方案,但是具体重试几次才是比较合理的呢,这也是个权衡问题:

假设单次加锁的 RPC 请求的超时时间为 100 ms, 请求超时率为 1% 。

  • 没有重试则可以快速失败,但是它的异常率 1%。
  • 若想减少异常率,重试一次则降低到 0.01% , 但是整体的加锁执行耗时上限就到了 200 ms 。

请求超时有很多方面的原因,Client 端原因、网络原因、 Server 端原因,都有可能存在。

有赞里面是默认重试 1 次,如果业务方反馈觉得不能接受这个异常率,允许耗时增大,则可以调大重试次数。

六、踩过的坑

在 Bond 分布式锁演进的过程中,有一些没有设计好的点,亦或者是先前没有重点关注的点,以致于在一些场景下发生过不可控的事情,这里分享一些比较通用的场景。

6.1 锁的管理

在一期方案的时候,我们只是对应用级别做了一层逻辑隔离,即使同一个 key 的锁,各个应用也是隔离的,不会相互干扰。

随着接入的业务越来越多,使用的姿势参差不齐,一个应用混合着阻塞锁和非阻塞锁使用的场景也不少见,这样导致了后端的底层存储非常难管理。因为两种模型的锁对 Server 端的压力是不一样的,随着量逐渐增大的时候,就会遇到了没法评估现在这个集群还能接入多少量的非阻塞锁的现象,也没办法按照不同的模型做出更好的拆分方案。

为了解决这个问题,在二期的时候,Bond 新增了 API ,是根据业务场景 (business key) 来申请工单,一个应用可以拥有多个加锁场景,而一个场景限定了使用的是阻塞锁 API 还是 非阻塞锁 API 。

  • 这样方便了运维管理,对于后端集群容量评估以及后期的拆分方案是很友好的。可以更细粒度地做FailOver策略等。
  • 但是也是有不友好的地方,就是一个业务场景只能使用一种模型,限定了使用方的使用姿势,如果想更换,只能重新申请工单。

6.2 热点锁

热点锁是最初的时候没怎么关注的,完全低估了热点锁带来的锁性能损耗,以致于压测场景没有覆盖到它。无论是在 Etcd 或者是 Aerospike 等底层存储,对于同一个 key 的大量竞争,即使是只有某一个 key 有几十个并发,足以把 Server 的资源消耗保持在高水位线上。

解决这种问题,较好的方式还是 Client 端优先本地竞争,具体实现可以参考 3.4 节。

本地竞争可以让并发数降到等于应用的实例数,即每台实例只有一个 RPC 请求出来。但是如果应用的机器实例数量比较多,还是有可能复现原问题。对于这种场景,有个方案就是嵌入一个中间层,中间层只做一些轻量的操作,而且中间层的实例数会保持在较小的数量,这样可以让并发数降到等于中间层的实例数。但是它也有风险点,就是维护中间层,且保证高可用性。

七、结语

Bond 分布式锁一直服务于有赞内部,它的场景解决方案都是基于实际场景思考而得出,我们也在不断地探索中前行,欢迎各位读者与我们互动,欢迎提出更好的方案~

不久的将来,Bond 分布式锁也会输出到有赞云给外部开发者使用~

相关链接

欢迎关注我们的公众号