一、背景
单机环境中,由于共享资源自身无法提供互斥能力。因此,为了防止多线程/多进程对共享资源的同时读写访问造成数据的破坏,就需要一个第三方提供的互斥的机制,一般往往是内核或者提供互斥能力的类库。
如下图二所示,进程首先从内核/类库获取一把互斥锁,拿到锁的进程就可以排他性的访问共享资源;演化到分布式环境,我们就需要一个提供同样功能的分布式服务,不同的机器通过该服务获取一把锁,获取到锁的机器就可以排他性的访问共享资源,这样的服务我们统称为分布式锁服务,锁也就叫分布式锁。
二、设计要点
效率:
可靠性:
三、分类
3.1、资源本身安全性的分类
3.1.1、基于异步复制的分布式系统,例如 mysql ,tair ,redis 等
基于异步复制的分布式系统,存在数据丢失(丢锁)的风险,不够安全,往往通过 TTL( Time To Live )的机制承担细粒度的锁服务,该系统接入简单,适用于对时间很敏感,期望设置一个较短的有效期,执行短期任务,丢锁对业务影响相对可控的服务。TTL 它可以指定一个时间段,即“生命周期”。在此时间段内,某个数据数据或资源可以被访问,一旦生命周期结束,该数据或者资源将被删除。
3.1.2、基于 paxos 协议的分布式一致性系统,例如 zookeeper ,etcd ,consul 等;
基于 paxos 协议的分布式系统,通过一致性协议保证数据的多副本,数据安全性高,往往通过租约(会话)的机制承担粗粒度的锁服务,该系统需要一定的门槛,适用于对安全性很敏感,希望长期持有锁,不期望发生丢锁现象的服务。
总体来说,安全和效率是考虑使用分布式锁的主要因素,下面以业内一些比较成熟的解决方案来讲讲安全和效率是如何商业化落地的。
3.2、追求效率场景
追求“效率提升”的分布式锁在业界被广泛需求,通常也会基于异步复制的分布式系统来提供分布式锁服务,根据业务系统的基本成本,对系统可靠性的要求以及分布式锁性能等诸多方面考量。
在电商平台上,一方面用户侧的订单行为本身会直接操作商品的库存,另一方面后台任务也会定期更新库存。大多数情况下都希望这两类操作在同一时间段最多只有一个,从而保证库存数据不会错乱,这是个典型的追求“效率提升”的分布式锁应用场景。
四、对比
4.1、严格的互斥性
互斥性作为分布式锁最基本的要求,对用户而言就是不能出现“一锁多占”,那么锁是如何避免该情况的呢?服务端每把锁都和唯一的会话绑定,客户端通过定期发送心跳来保证会话的有效性,也就保证了锁的拥有权。当心跳不能维持时,会话连同关联的锁节点都会被释放,锁节点就可以被重新抢占。
这里有一个关键的地方,就是如何保证客户端和服务端的同步,在服务端会话过期的时候,客户端也能感知,如下图五所示,我们在客户端和服务端都维护了会话的有效期的时间,客户端从心跳发送时刻( S0 )开始计时,服务端从收到请求( S1 )开始计时,这样就能保证客户端会先于服务端过期。
4.2、高可用
在多核时代,多线程并发是常态, 分布式锁服务通过持续心跳来保证锁的健壮性,让用户不用投入很多精力关注丢锁,但也有可能异常的用户进程持续占据锁。
我们也曾经遇到过该类场景,因为机器 load 高,硬件问题等原因,设备上的进程假死但其中仍存在部分线程在工作,比如锁的心跳维护线程依然正常运行,这样锁节点仍然是被假死进程占据着,且无法被其他进程抢占。针对该场景,为了保证锁最终可以被调度,提供了可以安全释放锁的会话加黑机制。
当用户需要将发生假死的进程持有的锁释放时,可以通过查询会话信息,并将会话加黑,此后,心跳将不能正常维护,最终导致会话过期,锁节点被安全释放。这里我们不是强制删除锁,而是选用禁用心跳的原因如下:
a. 删除锁操作本身不安全,如果锁已经被其他人正常抢占,此时删锁请求会产生误删除。
b. 删除锁后,持有锁的人会话依然正常,它仍然认为自己持有锁,会打破锁的互斥性原则。
4.3、切换效率
当进程持有的锁需要被重新调度时,持有者可以主动删除锁节点,但当持有者发生异常(如进程重启,机器宕机等),新的进程要重新抢占,就需要等待原先的会话过期后,才有机会抢占成功。默认情况下,分布式锁使用的会话生命期为 50 秒,当持有锁的进程意外退出后(未主动释放锁),最长需要经过 50 秒锁节点才可以被再次抢占。
要提升切换精度,本质上要压缩会话生命周期,同时也意味着更快的心跳频率,对后端更大的访问压力。我们通过对后端进行优化,使得会话周期可以进一步压缩,提升锁的切换效率。
例如:目前给 TableStore 提供 30 秒的分布式锁,同时也通过提供 Restful 接入方式,让用户可以自定义会话时长及控制心跳发送,同时我们会将锁的精度进一步提高到 20 秒之内 。
结合具体的业务场景,例如守护进程发现锁持有进程挂掉的场景,提供锁的 CAS 释放操作,使得进程可以零等待进行抢锁。比如利用在锁节点中存放进程的唯一标识,强制释放已经不再使用的锁,并重新争抢,该方式可以彻底避免进程升级或意外重启后抢锁需要的等待时间。
五、总结
5.1、如何让锁变的更加安全?
文章到这里,在分布式锁互斥性上,我们是不是做到完美了?并非如此,还是存在一种情况下业务基于分布式锁服务的访问互斥会被破坏。
我们来看下面的例子:如图七所示,客户端在时间点 S0 尝试去抢锁,在时间点 S1 在后端抢锁成功,因此也产生了一个分布式锁的有效期窗口。在有效期内,时间点 S2 做了一个访问存储的操作,很快完成,然后在时间点 S3 判断锁的有效期依旧成立,继续执行访问存储操作,结果这个操作耗时良久,超过了分布式锁的过期时间,那么可能这个时候,分布式锁已经被其他客户端抢占成功,进而出现两个客户端同时操作同一批数据的可能性,这种可能性是存在的,虽然概率很小。
针对这个场景,我们也是有方案可以应对的,在操作数据的时候确保有足够的锁有效期窗口,当然如果业务本身提供回滚机制的话,那么方案就更加完备,该方案也在存储产品使用分布式锁的过程中被采用。不过,我们还要继续探讨我们认为的最佳方案:存储系统本身引入 IO Fence 能力。这里就不得不提 Martin Kleppmann 和 redis 的作者 antirez 之间的讨论了,redis 为了防止异步复制导致的锁丢失的问题,引入了 redlock ,该方案引入了多数派的机制,需要获得多数派的锁,最大程度的保证了可用性和正确性,但仍然有两个问题:
- 墙上时间的不可靠( NTP 时间)
- 异构系统的无法做到严格正确性
墙上时间可以通过非墙上时间 Monotonic 来解决( redis 目前仍然依赖墙上时间),但是异构的设计的只依靠单个系统无法保证完全正确,如下图七所示,Client1 获取了锁,在操作数据的时候发生了 GC ,在 GC 完成时候丢失了锁的所有权,造成了数据不一致。
因此我们需要两个系统同时协作来完成一个完全正确的互斥访问,在存储系统引入 IO Fence 能力,如下图八所示,全局锁服务提供全局自增的 token ,Client1 拿到锁返回的 token 是 33 ,并带入存储系统,发生 GC ,当Client2 抢锁成功返回 34 ,带入存储系统,存储系统会拒绝 token 较小的请求,那么经过了长时间full gc重新恢复后的 Client 1 再次写入数据的时候,因为存储层记录的 Token 已经更新,携带 token 值为 33 的请求将被直接拒绝,从而达到了数据保护的效果( chubby 的论文中有讲述,也是 Martin Kleppmann 提出的解决方案)。
结语
分布式锁提供了分布式环境下共享资源的互斥访问,在各个产品业务中,应用十分广泛。业务或者依赖分布式锁追求效率提升,或者依赖分布式锁追求访问的绝对互斥。在接入分布式锁服务过程中,我们要考虑接入成本,要考虑服务可靠性,也考虑分布式锁切换精度,要考虑正确性等等。