租约机制随记
最后更新:
1.绪言
在计划实现我的Tomato-Agent的执行持久化和故障恢复时,Agent提到了一个词,之前从来没有见过,就是租约
去了解了一下,发现其在分布式系统设计中无疑起到了一个比较重要的作用,原理也很简单,但是其衍生策略又较为巧妙,故作此篇,稍加记录
先用一个最简单的场景引入,说明我们为什么需要租约机制
现在假设有一个分布式系统,在做任务抢占,使用传统的加锁-解锁机制,发生了下面的情况:
- 节点A抢占任务,开始执行,上锁
- 节点A宕机,或者触发了GC长时停顿
- 节点A不会发送释放锁的请求
- 最后就是任务持续被节点A占用,其他节点无法接取并恢复任务
而租约的核心价值就是通过引入一个时间限制,实现容错和超时自动释放
2.概念
看了许多有关于租约(Lease)的介绍,发现其使用范围很广,但是究其根本,其实只是一个思想:Host为Worker发放限时的资源访问许可
或者更正式地说,Lease Grantor(颁发者/房东)将某种资源的独占权或者特定权限租给Lease Holder(持有者/租客),租约有效期为
就这么简单,没有什么多余复杂的东西
3.租约如何运作
接下来我们继续之前的例子,尝试用节点A抢占任务,有如下动作:
- 领取:A获得任务的执行许可,限时为30秒
- 续租:任务较长,A在到期之前请求延长租约,例如每10秒续租一次
- 释放:A执行结束,主动把许可交还,而不是等到过期
- 到期接管:A没有成功续租,租约到期之后,其他的节点就都可以继续接取任务
4.应用场景
4.1.分布式锁续期
在分布式场景中,常常需要将任务发放给节点,任务执行过程中,需要对数据库进行读写,如果不对任务加锁,导致多节点持有任务,存储层的并发读写会导致数据错乱问题
而在这个场景中,Lease机制就常常被使用,具体来说有两种实现方式:
- etcd Lease:由服务端掌控计时器,客户端进行心跳续约,只要客户端失联,服务端就立刻收回许可,安全性更高
- Redission Watchdog:由客户端掌控计时器,通过应用层的Watchdog进程进行定时例如TTL续约,依赖于客户端的存活
我们可以看到一个有意思的现象,这里实际上又出现了经典的可靠性和性能之间的权衡
etcd类Lease通过让服务端控制计时器从而掌控过期节奏,实现了较强的可靠性
同时Redis的定位是高速缓存,追求极致的吞吐量和延迟,如果像etcd一样,让Redis去精确追踪成千上万个客户端的Lease状态,维护复杂的心跳超时,无疑会带来沉重的性能负担,因此它放弃了部分可靠性,将负担交付给客户端,换来高性能的保持
4.2.Leader选举
在多节点的定时调度任务中,为了避免多节点并发写存储层,会在节点之中选取一个Leader作为代表进行写操作,然后通过日志同步状态给其他节点
首先看这个选举的触发条件是什么,Leader节点会通过定时心跳,告诉其他节点Leader仍然正常
每个非Leader节点内部维护一个计时器,每次收到心跳就重置计时器,如果计时器归零,仍然没有收到Leader的心跳,就认为Leader已不存在,开始选举
然后谁拿到了超过半数的选票,就上任为新Leader
在这其中有一个问题,可能由于GC暂停或者网络抖动,导致Leader心跳没有及时到达,非Leader节点计时器到期,开始新一轮选举,最终出现双Leader的情况
为了解决这个问题,采用Lease机制,在新Leader被选举出来之后,或者Leader心跳续约成功后,集群或者法定人数的节点会发给它一个Lease,作为绝对权威期限,在这个期限内,即使非Leader节点计时器过期,也不会触发新一轮选举
4.3.缓存一致性
这也是租约在较早时期的应用,用于解决缓存一致性
简单来说,服务端向客户端发放Lease许可,即承诺在时间 内,缓存内的数据绝对不会变,则在Lease过期之前,客户端可以放心读取缓存内容而无需担心一致性问题
当客户端要写更新时,必须等到所有其他对该缓存的Lease都失效,或者主动收回,才能写入新数据
4.4.DHCP
DHCP即动态主机分配协议,在设备接入网络之后,DHCP服务器分配给设备一个IP地址
但是由于IP地址在IPv4协议中的稀缺性,不可能永久地将某个IPv4地址分配给一台主机,否则将会被僵尸主机占用完,因此DHCP引入了Lease机制
具体来说,当设备拿到IP之后,以有效时效24小时为例,有两个续约的时间点
第一个 ,为租期的一半,在这里也就是12小时,此时客户端尝试续约,直接给当时发放给它IP地址的那台DHCP服务器单播一个DHCPREQUEST报文,如果该服务器在线,则返回一个DHCPACK,租期刷新
如果在 时,可能由于网络波动、对应DHCP服务器掉线等原因,未能续租成功,客户端设备继续使用该IP地址,直到到达 时间节点,租期的 ,就开始在全网广播DHCPREQUEST,向其他DHCP服务器申请继续使用该IP地址
如果这次仍然没有主机回应,则当租期到期之后,立即断网
5.局限性
在前面我们提到,租约到期之后,其他节点就可以接管任务,这样就避免了节点宕机之后,任务被该节点长期占用的问题
但是在实际情况中,可能还有一种情境,就是节点并没有宕机,它可能因为某种原因比如GC停顿,暂停执行了一段时间,而在这段时间内,Lease过期,任务被其他节点接走,而在该节点恢复执行之后,就可能会出现两个节点同时执行同一个任务的情况,这就很可能会造成竞态和读写混乱等问题
那么我们可能会想,如果让执行任务的节点在进行可能发生竞态的场景比如写操作的时候,先审阅当前Lease的情况可以吗?不完全可以,因为我们难以去确保检查Lease状态和执行写入是一个原子操作,那么就可能在检查完状态之后触发GC停顿,Lease在这中间过期,最终面临相同的问题
为了解决这个问题,我们考虑引入Fencing Token
6.Fencing Token
其实原理很简单,就是在每次授予新的执行权,也即许可Lease的时候,同时发放一个单调递增的编号,这个编号就是Fencing Token
还是以A、B抢占任务为例,A领取任务的时候获取编号41,在执行过程中由于GC停顿导致Lease失效而不自知,调度器又发放给节点B任务执行权,同时得到编号42
在B执行的过程中,A恢复执行,但是存储层看到41 < 42,因此拒绝操作
在这里我们把检查编号的任务交给真正需要检查并发的入口,比如这里就是存储层,规则可以设定为记录接受过的最大编号,只有当请求编号大于等于已经记录的编号的时候,才批准操作
需要注意的是,Fencing Token也有其边界,假设这样的一种情况,B节点虽然拿到了42编号,但是A节点在B节点执行写入操作之前就先一步写入,此时存储层仍然不会拒绝,如果要实现强一致性,就需要在B节点接管任务的时候,在存储层及时更新当前最大编号
7.实践
现在回到我的Tomato-Agent,目前还没有到多节点的调度系统,只是对于后台单worker的管理
项目中把一次任务记为Run,一次具体的执行尝试记为Attempt,也就是说当同一个任务发生故障并恢复之后,可以产生新的Attempt
后台Worker领取任务时,会在SQLite事务中创建一个Attempt,并获得一个LeaseHandle,其中包含run_id, attempt_id, owner_id, fencing_token, lease_expires_at
每次重新授予任务执行权,该任务的fencing_token递增,Worker默认领取30秒的Lease,并每10秒发送一次心跳
评论
评论会在审核后展示