文章目录
小组内部做了一次时钟系统的讨论,感觉个人对很多细节理解得还是不够透彻,希望能在这里系统梳理一番。
之前整理了 分布式系统的一致性模型,后来发现发现想要实现不同的一致性要求时钟系统是无法绕开的,而且时钟系统是实现分布式事务的充分不必要条件,虽然说是不必要条件,目前主流分布式系统的分布式事务实现都是依赖时钟系统构建的。所以,这里站在无数前人的肩膀上,自己做一个记录,加深对时钟系统的理解从而更好得理解分布式事务实现的细节。
本文从主要的几种时钟系统展开,他们之间的关系并不是前者比后者更优秀,而是在分布式数据库系统的应用场景中因为 CAP 而做取舍选择的,他们各自本身有着非常适合自己的应用场景。
1 物理时钟
1.1 石英晶振
我们目前使用的几乎所有的电子设备都会有一些基本的运算芯片,这一些芯片的运算需要通过 稳定的频率来进行内部寄存器的数值更新。这一些稳定频率的输出目前是通过石英晶振来输出的,因为我们的电子元件供电之后(包括我们的CPU)本身对时间是没有概念的,在和门电路协同计算时的中间结果时存放在寄存器之中,整个运算过程如果不控制寄存器内数值的更新频率,那么上一个计算结果会立即同不到下一个计算结果之中(各个寄存器因为物理上有一定的距离,数据就是电信号而已),这样得到的计算结果就不是预期的了,整个芯片就是乱的。
所以芯片设计师们设计了通过稳定的频率来让所有的寄存器数值统一更新,这样每一次的更新对于当前这一批运算单元的结果来说是准确的,石英晶振能够提供这样的稳定的时钟脉冲波动。
我们CPU 这样的芯片内部看到的时钟脉冲是 方形波动:
也就是上升沿的过程中会统一加一定的电压,所有寄存器的数值被统一更新;后续的下降沿则不会加电压,这样所有计算单元的运算结果不会更新到寄存器中(寄存器内部的值稳定一段时间),直到下一个上升沿到达。
CPU 中的时钟脉冲并不是直接由 石英晶振提供的,目前市场上大多数的石英晶振频率只有 MGZ,而我们的CPU以及达到了GHZ,这个过程是设计师们为了解耦CPU的功能,芯片频率越高,则单位时间内能够运算的次数也就越多:
- 将石英晶振放在了外面(下图 1号)按照的自己振荡频率产生正线信号
- 旁边配一个PLL(Phase Locked Loop 锁相环) 芯片 负责将正弦波动转化为方形波动信号传递给 CPU
- CPU内部由自己的multiplier,将收到的方形信号放大 发送给不同的CPU核心
- CPU核心每收到一个放大后的时钟脉冲就做一次操作。
上图中的 1/2 组合也被称为时钟芯片,1内部有自己的纽扣电池用来在主板下电之后保持正常的供电 用作计时,CPU内部也有寄存器MSR 用来保存收到的时钟脉冲的次数,从而能够作为系统时钟的表示。
之间记录过 通过RDTSC 指令从CPU寄存器中获取系统时钟的 RDTSC 指令就是从 CPU内部处理时钟脉冲时做存储计数的MSR寄存器取计数。
1.2 原子钟
石英晶振 对于大多数的运算芯片来说,它的震荡频率 稳定且 因为芯片有自己的放大器,对于芯片来说是足够的。
但是,对于时钟精度来说还是不够,石英晶振 因为其构造特性(SiO2) 会受到温度的影响,不同温度下晶振的频率会有差异,而我们的服务器主板存在大量的晶体管,本身运行的过程中会大量产热,导致晶振的稳定性会有一定的误差,目前石英晶振 32.768 kHz 下每天会有 正负1秒的误差,秒级延时对于依赖时钟构造的系统 来说可谓漏洞百出了。
所以工业界需要可测量,且在各种环境下的结果更稳定的物理时钟,从而推动学术界的探索(当然具体的因果关系不一定是这样,不过工业界有钱,对于这样的微观世界的探索需要极大的资金支持)。现在的 铯133 被作为原子钟的选型,因为其他的元素的原子振动频率不会不断变化,而铯133 原子振动频率非常稳定,从而能够稳定输出脉冲信号。
原子钟的实现技术还是比较复杂的,有兴趣可以探索一下。目前世界上高精度的时间标准 UTC 都是通过原子钟输出的。
2 NTP
以上简单描述了物理时钟的一些基本构造和实现,尤其是石英晶振 在我们现如今的芯片应用中还是非常广泛。而随着互联网的发展,服务器 以及 个人终端设备的不断增加, 局域网/广域网内大家共同认可的 时间标准就非常有必要存在了。因为原子钟的价格较为昂贵,不可能为每一个个人终端配置一个原子钟,所以现在的大多数服务器/个人终端 都还是石英晶振。
那 石英晶振较大的误差就需要通过软件层来解决了,达到 降本增效 的目的。1985年特拉华大学的David L. Mills设计了网络时间协议NTP(Network Time Protocol),来解决互联网不通 IP设备之间的时钟误差问题。
其设计之初的目标是 期望将不同机器之间的时钟误差降到ms级别,但这个效果只能在局域网内部能做到,在广域网中基本到数十上百ms 级别了(5000km 的距离 光波都需要16ms,电流也是电磁波,路上还要通过各种路由器/电阻啥的 肯定不止数十ms了),但其本身的优质设计已经被广泛应用在了整个互联网领域中。
2.1 基本应用
NTP 是利用UDP 协议传输的网络报文,端口号是123。
互联网中的服务器 和 个人终端 利用NTP 同步的时间源的产生主要有如下几种:
- 其本身是支持分布式部署形态,就是局域网内部可以部署在多台机器上, 通过ntpd 来同步这一些机器之间的时钟。
- 我们个人设备如果处在广域网中,则设备本身会定期和指定的NTP 时间服务器进行时钟同步,这一些服务器内部的时钟生成是精度较高的原子钟。
- 服务器系统可以通过串口连接一个无线时钟. 无线时钟接收 GPS 的卫星发射的信号(原子钟)来决定当前时间. 无线时钟是一个非常精确的时间源, 但是需要花一定的费用。
2.1 基本设计
借鉴一个优质博主的架构图https://blog.srefan.com/2017/07/ntp-protocol/
上面展示的是 基本应用中的第一种模式,也就是 利用NTP 服务器构建的分布式时钟源系统(也有防止单点问题,广域网海量的设备 请求时钟同步的话对于单点时钟服务器来说负太重)。
上图中 在我们现实的NTP 网络中,时钟源一般处于比较高层(1-3层),第一层负责直接向 原子钟/无线时钟 所在的服务器请求时钟同步,第一层的时钟能够做到us以内的时钟误差(距离比较近),且第一层内的服务器之间也可以进行时钟同步。第二层以及更后层的服务器 则都是可以向其上一层的服务器请求时钟同步,并且层之间的服务器也能够进行时钟同步。
我们处于广域网中的个人终端就属于第一层以下的服务器,后面的NTP 报文会展示自身所处的层。国内可以同步的时钟源https://www.cnblogs.com/pipci/p/12790503.html,可以通过sntp 命令进行主动同步。
2.2 时钟同步流程
有两个服务器 ,其中 A是时钟源服务器,B向A 请求时钟同步。
- B先向A 发送NTP 报文,报文包括 消息离开B 时的时间戳 T1。
- 到达A 时,A会为报文 加上此时自己的时间戳 T2。
- 离开A时,A会为报文加上离开时自己的时间戳T3。
- 报文到达B时 ,B相应报文,并记录自己本地的时间戳T4。
此时B 本地拿到的报文信心已经足够进行时钟同步了,同步的过程如下:
- 首先需要计算 A 和 B 通信期间报文 来回往返的时间(也就是上图中虚线的两端区域的时间和):
D e l a y = ( T 4 − T 1 ) − ( T 3 − T 2 ) Delay = (T4-T1) - (T3-T2) Delay=(T4−T1)−(T3−T2) - A 和 B之间的时差 如下:
θ = T ( B ) − T ( A ) = T 3 − 1 2 ∗ D e l a y = 1 2 ∗ [ ( T 2 − T 1 ) + ( T 3 − T 4 ) ] \theta =T(B) - T(A) = T3 - \frac{1}{2} * Delay= \frac{1}{2}*[(T2-T1)+(T3-T4)] θ=T(B)−T(A)=T3−21∗Delay=21∗[(T2−T1)+(T3−T4)]
得到的时差符号表示 超过或者延迟的时间。
2.3 NTP 报文解析
前面提到的NTP 报文是通过 UDP 协议发送的(不需要过于复杂的校验,仅仅做时钟同步,对可靠性要求不高,一次无法准确同步的话发送多次就好了)。
基本格式如下(NTP V4版本):
总共是12个存储区域,每个字段后面的数字表示(bits)。
各个字段含义如下:
-
前 32 bit 表示了 6个字段。
- LI (Leap Indicator) 2bit , 用来标识闰秒,主要是做一个告警,高速本次接受NTP 报文的对端 在当前月份的最后一分钟 因为闰秒而需要多插入/删除 一秒钟。
0 : 无警告
1: 警告最后一分钟多了一秒
2: 警告最后一分钟少了一秒
3: 时钟未同步 - VN(Version Number) 3bit, 标识当前 时钟服务器使用的NTP 版本,最新版本是4
- Mode 3bit,标识当前NTP 报文发送模式。
0: 保留位
1: 主动对称模式,和被动对称模式共同使用,两个时钟同步服务器在该模式下的时钟会互相同步
2: 被动对称模式,同上。如果 A为主动对称模式,则回复的B为被动对称模式。
3: 客户端模式, A 向 B同步,则A为客户端模式。
4: 服务端模式,A 向 B同步,则B为服务端模式。
5: 广播或组播模式,时钟源服务器 向广域网/局域网 内的服务器发送NTP,收到的客户端服务器 根据收到的报文进行同步。
6: NTP 控制报文
7: 预留位,内部使用(NTP 开发者) - Stratum 8bit, 系统时钟的层数,取值是[1,15],层数越低,越靠近时钟源,也就越精确。
0: 无效字段
1: 时钟源所处层,一般是 无线时钟或者高精度原子钟
2-15: 互联网终端节点 所在时钟层
16: 无法同步状态
17-155: 预留字段 - Poll 8bit,两个连续NTP报文之间的轮训时间间隔,如果是6 则表示 2 6 = 64 2^6=64 26=64秒
- Precision 8bit,系统时钟的精度。如果是-5,则表示 − l o g 2 5 = 0.03125 -log_25=0.03125 −log25=0.03125秒
- LI (Leap Indicator) 2bit , 用来标识闰秒,主要是做一个告警,高速本次接受NTP 报文的对端 在当前月份的最后一分钟 因为闰秒而需要多插入/删除 一秒钟。
-
Root Delay 32bit,本地服务器和远端服务器之间往返的时间,前面公式中的 Delay。
-
Root Dispersion, 32bit,系统时钟相对于时钟源的最大误差
-
Reference Identifier,32bit,时钟源的唯一标识。
如果Stratum 的值是0(unspecified and invalid),则这个值就是 kiss code,用来进行debug。
如果Stratum 的值是1-15,则两者通信走的是IPV4,这个值就是服务端ip地址;如果走IPV6 通信,则就是ip地址的前4段区域的 hash值。 -
Reference Timestamp 64bit:系统时钟最后一次被设置的更新时间。
-
Origin Timestamp 64bit, NTP 请求离开发送端时 发送端本地的时间。
-
Receive Timestamp 64bit, NTP 请求到达接收端时 接收端本地的时间。
-
Transmit Timestamp 64bit, 应答报文离开应答者时应答者的本地时间。
-
Key Identifier (keyid) 32bit客户端和服务端协商的一个用于通信的密钥。
-
Message Digest 128bit, 128bit 的 md5 密钥内容。
-
Extension Field n: 变长,这个是 NTPV4 版本提供的字段,用于传输一些额外信息。
接下来看一下具体抓到的NTP 报文内容。
因为是mac 系统,wireshark 的filter 指定ntp协议即可,因mac 接入的网络算是广域网,所以每隔一段时间会进行一次时钟同步,抓到的时间看起来像是大概1000s 左右同步一次。
如果不想等的话,可以直接触发向时钟源服务器进行时钟同步: sntp 2.cn.pool.ntp.org
。
报文内容如下(自动同步时钟的话时钟源采用的是 无线时钟),奇怪的是没有看到key id 以及 md5信息:
再看看 通过 sntp 2.cn.pool.ntp.org
主动同步的报文信息,还是看看服务端发送的报文内容,会填充 reference ID,而且有一个奇怪的地方就是和无线时钟同步时 Root Delay
为0,延时时小于us 级别。。。 这么低的吗???而主动和原子钟所在的时钟源同步的话是7ms。。。(广域网的通信,能够理解)。
这里猜测应该是我们现在的电子设备都配置了 GPS 授时器模块,Mac也不例外,卫星系统越来越完善,就可以直接进行高精度通信了(具体实现可能就得单独研究了)。
抓的这几个报文信息,能够基本看到NTP 是时钟服务器能够维持一定的时钟精度(ms)级别,但对于复杂的网络环境来说随着时钟源服务器 距离的增加,本地时钟同步的精度可能越来越高(需要经过无数的交换机),延时达到百ms级别也不是不可能。NTP 确实能够降低因为物理时钟引入的时钟误差(s 级别),但是这个误差还是有点高。
因为我们的分布式系统中事务系统的构建单纯依靠本地服务器的时间戳来标识 在 电商场景/交易场景 下就会有很大的问题,还需要一些更上层的设计来尝试进一步降低 时钟误差产生的影响。
3 Lamport 时钟 LC
介绍 LC 时钟之前,需要了解一些前导内容。我们实现事务系统隔离级别的时候需要引入多版本,这样才能允许用户选择自己的隔离级别。对于我们底层系统来说,想要实现隔离级别,那两个消息之间的顺序是一定要知道的。对于单机存储引擎来说,只需要一个原子递增的 sequence number 标识每一个消息即可。但是对于分布式系统来说,使用sequence number 来标识两个事件的顺序 会有非常多的问题(如何让其他进程知道 当前进程的seq,如果节点成百上千 怎么办),用时间戳标识还是一个整体能够较为统一的选择。
也就是,在分布式系统中我们需要时间戳来标识不同事件发生的前后顺序。时钟是实现一致性(分布式事务)的充分不必要条件。
3.1 偏序和全序
介绍 LC 时钟之前先了解两个概念:
- 全序:允许任意两个元素进行比较,如果有两个元素你总是能够说出哪个更大,哪个更小,那就是一个全序关系。
一个线性一致性的系统需要能够确认不同事件之间的全序关系才能实现,因为线性一致性的系统要求整个系统对外的表现就像只有一个副本,并且所有的操作都是一个原子寄存器。 - 偏序:数学集合并不完全是全序的:
{a, b}
比{b, c}
更大吗?好吧,你没法真正比较它们,因为二者都不是对方的子集。我们说它们是**无法比较(incomparable)**的,因此数学集合是偏序。不同的集合之间是无法比较的,没有意义。
一致性模型中的因果一致性因为不同的操作并没有在彼此之前发生,那这两个操作就是并发的,他们之间的顺序是无法比较的,也就形成了偏序关系。
因果性是分布式系统中十分常见的基本一致性模型,像我们经常用到的版本管理工具 git,它经常需要处理并发但没有因果关系的操作,那也就是需要有能力判断两个并发操作的前后关系,从而有一个全序操作的能力。
接下来介绍的 LC (Lamport clock) 是一种解决方案。
3.2 Lamport Clock
论文中 老爷子对先后关系的定义是 a → b a\rightarrow b a→b,表示 a 发生在 b 之前(happened before),如果以下任意条件满足,则 a → b a\rightarrow b a→b 关系成立:
- a 和 b 是同一个进程内,a 发生在 b之前
- a 和 b 在两个进程 A,B 内,a 在 A 进程内发送c 消息, b 在 B进程内接受 c消息。
- 如果 a → b a\rightarrow b a→b 且 b → c b\rightarrow c b→c,则 a → c a\rightarrow c a→c
如果 a 和 b 是并发的,则记为
a
∣
∣
b
a||b
a∣∣b。
如下图:
A 和 B两个进程有若干用点表示的事件,其中黑色发生在进程内部,蓝色表示进程的发送事件,红色表示进程的接受事件,从上图我们可以得到如下推论:
- a → b → c → d a\rightarrow b\rightarrow c\rightarrow d a→b→c→d
- a → b → e a\rightarrow b\rightarrow e a→b→e
- f → c → d f\rightarrow c\rightarrow d f→c→d
- a ∣ ∣ f a||f a∣∣f
- b ∣ ∣ f b||f b∣∣f
- e ∣ ∣ c e||c e∣∣c
- e ∣ ∣ d e||d e∣∣d
分布式系统中每个进程 P i Pi Pi保存一个本地逻辑时钟值 C i Ci Ci, C i ( a ) Ci (a) Ci(a)表示进程Pi发生事件a时的逻辑时钟值,Ci的更新算法如下:
- 所有事件的本地逻辑时钟 C i Ci Ci 起始都初始化为0
- 进程 P i Pi Pi每发生一次事件, C i Ci Ci加1。
- 进程 P i Pi Pi给进程 P j Pj Pj发送消息,需要带上自己的本地逻辑时钟 C i Ci Ci。
- 进程 P j Pj Pj接收消息,更新Cj为 m a x ( C i , C j ) + 1 max (Ci, Cj) + 1 max(Ci,Cj)+1。
上图中的 每一个事件的
C
i
Ci
Ci 的值就可以得到了:
从以上算法 以及 偏序关系成立的前提可以得到如下结论:
- 同一进程内的事件 a和b,如果 a → b a\rightarrow b a→b,那么 C i ( a ) < C i ( b ) Ci(a) < Ci(b) Ci(a)<Ci(b)
- 不通进程内的发送事件 a 和 接受事件b,如果 a → b a\rightarrow b a→b,那么 C i ( a ) < C j ( b ) Ci(a) < Cj(b) Ci(a)<Cj(b)
可以得到 对于任意事件 ,如果
a
→
b
a\rightarrow b
a→b,则
C
(
a
)
<
C
(
b
)
C(a) < C(b)
C(a)<C(b) 成立。
反之,如果
C
(
a
)
<
C
(
b
)
C(a) < C(b)
C(a)<C(b) ,则
a
→
b
a\rightarrow b
a→b 是否成立?显然不成立,比如上图中的 e 和 d事件,两者是并发的,不存在因果关系。
这也说明了一点,LC 时钟算法 仅能对有因果性的事件提供排序关系,但是对于并发事件则无法进行排序。所以 老爷子做了补充,在原有 C i Ci Ci 逻辑时钟基础上增加一个 PID,构成新的唯一标识事件的ID [ C i , P ] [Ci, P] [Ci,P]。
这样就能够对发生的并发事件进行比较,接下来 老爷子通过 a ⇒ b a\Rightarrow b a⇒b 表示 a 和 b 两个事件之间是全序关系。如果满足以下任意一个条件,则 a ⇒ b a\Rightarrow b a⇒b 关系满足:
- C i ( a ) < C j ( b ) Ci(a) < Cj(b) Ci(a)<Cj(b)
- 如果 C i ( a ) = = C j ( b ) Ci(a) == Cj(b) Ci(a)==Cj(b) 且 P i < P j Pi < Pj Pi<Pj
这样,对于上图中的 两个进程 A,B ,假设 A 的进程号为1,B的进程号为2,则通过上面的规则就有了一个新的全序关系。
a
⇒
f
⇒
b
⇒
e
⇒
c
⇒
d
a\Rightarrow f\Rightarrow b\Rightarrow e\Rightarrow c\Rightarrow d
a⇒f⇒b⇒e⇒c⇒d
因为 e 的进程id 比较小,所以 从全序关系来看 e 在 c前面。
这样整个系统中的事件因为 有 [ C i , P ] [Ci, P] [Ci,P] 标识,就能完整构造自己的全序关系。
能够构造全序关系,就能够解决很多分布式事务中的问题,大多数场景基本够用了,但是部分场景还是有问题。
- 因为需要通过 PID 作为逻辑时钟想等时的顺序标识,随着集群规模的增加(万级别的机器),不同机器上的进程ID 可能相同,这样仍然会存在部分事件的非全序问题。
- LC时钟维护的全序特性 完全覆盖了不同事件之间的并发特性,内部根本无法分清哪一些事件之间是并发的。这样,所有的事件都会去执行。对于部分场景 比如创建用户名/银行账号/手机号码注册 等唯一标识一个用户的场景中 LC 都会去执行,这是不正确的。
所以,还需要一些能进一步处理并发事件的全序关系的时钟设计。
4 向量时钟 VC
向量时钟 (Vector clocks) 则是能够提供全序关系的同时区分其中的并发事件和因果事件。
详细介绍向量时钟之前,前面的 LC 时钟提到了 在因果关系的依赖链中 可以通过
a
→
b
a\rightarrow b
a→b 推导出
C
(
a
)
→
C
(
b
)
C(a)\rightarrow C(b)
C(a)→C(b),但是反之不成立。
而这个关系在 向量时钟下时成立的。
4.1 Vector Time 算法实现
向量时钟的思想是 不同进程之间同步时钟的时候 不仅同步自己的时钟,还同步自己知道的其他进程的时钟。
分布式系统中每个进程 P i P_i Pi保存一个本地逻辑时钟向量值 V i V_i Vi,向量的总长度是分布式系统中进程的总个数。 V i ( j ) V_i (j) Vi(j) 表示进程Pi知道的进程Pj的本地逻辑时钟值,Vi的更新算法如下:
- 初始化 V i [ k ] : = 0 V_i[k]:= 0 Vi[k]:=0 其中 k = 1 , … , N k = 1,…, N k=1,…,N.
- 进程 P i P_i Pi每发生一次事件, V i V_i Vi 的更新方式: V i [ i ] = V i [ i ] + 1 V_i[i] = V_i[i]+1 Vi[i]=Vi[i]+1。
- 进程 P i P_i Pi 发送消息m 的时候,需要带上自己的向量时钟 V i V_i Vi。
- 进程接收消息m 的时候,需要做两步操作:
4.1 对于收到的向量时钟 V m V_m Vm中的每个值 V m [ k ] V_m[k] Vm[k],需要逐个更新本地的向量时钟 V i = m a x ( V i [ k ] , V m [ k ] ) V_i= max (V_i[k], V_m[k]) Vi=max(Vi[k],Vm[k])
4.2 将 V j V_j Vj中剩下的非收到的向量时钟部分,自己对应的时钟值加1,即 V j [ k ] V_j[k] Vj[k]加1。
如下案例,有三个进程 P1,P2,P3,每一个 向量时钟的第一行标识其本地向量时钟,其他两行标识携带的已知的其他进程的时钟。
由以上算法可以得到以下结论:
- 同一个进程内部的两个事件 a,b,如果 a → b a\rightarrow b a→b ,则 V i [ a ] < V i [ b ] V_i[a] < V_i[b] Vi[a]<Vi[b]
- 如果 a 是进程 P i P_i Pi的 发送事件,b 是进程 P j P_j Pj 的接受事件,则 V i [ a ] < V i [ b ] V_i[a] < V_i[b] Vi[a]<Vi[b]
也就是 对于 对于任意两个事件 a 和 b,如果 a → b a\rightarrow b a→b ,则 V [ a ] < V [ b ] V[a] < V[b] V[a]<V[b];这个结论的反推条件是否成立呢?由 V [ a ] < V [ b ] V[a] < V[b] V[a]<V[b],得到 a → b a\rightarrow b a→b,证明如下:
-
a和b 在同一个进程内,如果 V [ a ] < V [ b ] V[a] < V[b] V[a]<V[b] ,则显然 a → b a\rightarrow b a→b 成立
-
a 和 b 不在同一进程内,我们利用反证法,如果 V [ a ] < V [ b ] V[a] < V[b] V[a]<V[b],则 要不就是 a ∣ ∣ b a||b a∣∣b 或者 b → a b\rightarrow a b→a。
假设 V [ a ] = [ m , n ] , V [ b ] = [ s , t ] V[a] = [m,n], V[b]=[s,t] V[a]=[m,n],V[b]=[s,t],因为 V [ a ] < V [ b ] V[a] < V[b] V[a]<V[b],表示a 所在集合所有元素 小于等于 b 所在集合的所有元素,则 m < = s m <=s m<=s 且 m < = t m <= t m<=t 且 n < = s n <=s n<=s 且 n < = t n<=t n<=t,如果 a ∣ ∣ b a || b a∣∣b,则表示 a b 所在集合 有公共集合, 无法比较,则b 里面一定存在一个元素 大于 m;所以 由 V [ a ] < V [ b ] V[a] < V[b] V[a]<V[b] 无法推导 a ∣ ∣ b a||b a∣∣b。
同理,想要由 V [ a ] < V [ b ] V[a] < V[b] V[a]<V[b] 推导 b → a b\rightarrow a b→a,因为 a 集合所有的元素 都已经小于 等于b 集合了, 那显然 b 中 没有事件发生在 a 之前,这个结论同样不成立。两个集合不想等的情况下,之间的关系可能有如下四种情况:
所以 我们可以得到 如果 V [ a ] < V [ b ] V[a] < V[b] V[a]<V[b] ,则显然 a → b a\rightarrow b a→b 成立的结论。
可以很明显得看到,向量时钟能够识别到事件之间的因果关系,并且能够区分两个事件是否是并发的。所以:
- 它能够识别到冲突,也就是前面 LC 时钟检测不到的在唯一标识场景下的并发冲突问题,这在分布式事务场景中非常重要(冲突检测)
- 同时VC 也能够保证因果一致性
当然,VC时钟也会有一些问题:
- VC 构造的向量集合 与进程个数相关,如果一个集群有成百上千台机器,也就是上千个进程,那整个集群不论是RPC 携带VC向量 还是用户侧的 VC向量管理 都会非常麻烦(RPC 负担太重)
- VC 时钟不够直观,我们看到的时钟希望是能代表时间的,而VC 时钟内部是一堆向量。
我们继续向下看,是否有更优质的识别因果关系的解决方案。
5 混合逻辑时钟 HLC
HLC 混合逻辑时钟(Hybrid Logical Clocks) 是2014年提出的一种时钟方案,基本能够解决前面说到的 LC 以及 VC 时钟的问题。
LC 无法定位事件之间的因果关系(即有了
V
[
a
]
<
V
[
b
]
V[a] < V[b]
V[a]<V[b],无法推导出
a
→
b
a\rightarrow b
a→b),无法识别冲突,这对数据库存储场景来说实现分布式事务基本很难用到了;
而 VC 虽然能够识别到冲突 以及 定位因果关系,但是实现上需要消耗过多的RPC 且 对时钟的表现不够直观,他们虽然都是为了解决因为不同机器之间的时钟误差而对上层带来的无法规避的影响,但是现在的实现基本已经背离了 时钟(表达时间)的含义。
所以 HLC 的设计希望能够解决这一些问题并保留时钟本身的特性。论文最开始就提出了 HLC 所想要满足的一些性质,为每一个分布式系统中的事件赋予一个时钟 记为 l . e l.e l.e:
- e → f , l . e < l . f e\rightarrow f , l.e < l.f e→f,l.e<l.f ,如果事件e 在f之前发生,那么 l . e < l . f l.e < l.f l.e<l.f,满足因果关系
- l . e l.e l.e 不会随着系统的持续运行(增加/删除节点) 需要的存储空间不断增大(VC时钟的问题)
- l . e l.e l.e 会有边界,而LC和VC时钟会持续增加
- l . e l.e l.e 和 e 事件发生时的物理时钟 p t . e pt.e pt.e 比较接近,那么 ∣ l . e − p t . e ∣ |l.e-pt.e| ∣l.e−pt.e∣ 是有边界的。
5.1 算法实现
论文先给出了一个基于 LC 算法实现的一个简单版本,记发生事件 j 的逻辑时钟为 l . j l.j l.j,接受事件 m 的逻辑时钟为 l . m l.m l.m,LC 原本的实现如下
初始化:l.j = 0
本地事件发生/发送事件发生: l.j = l.j + 1
接受事件发生:l.m = max(l.j, l.m) + 1
如果引入物理时钟的话,也就是我们想要修改逻辑时钟的时候比较一下两个事件各自的物理时钟 和 逻辑时钟 l . j > = p t . j l.j >= pt.j l.j>=pt.j,取逻辑时钟,否则取物理时钟:
初始化:l.j = 0
本地事件发生/发送事件发生:l.j = max(l.j + 1, pt.j)
接受事件: l.j = max(l.j + 1, l.m + 1, pt.j)
从变更后的实现中我们能够发现 对于前面提到的 HLC 性质中的 1,2 条是满足,但是 3和4却无法满足。
对于性质1来说,以上算法能够保证识别到因果关系;性质2的话,整个l.j 可以通过一个uint64_t 的整数标识,空间占用不会增加;但是对于性质3和4来说,可以看到算法中每发生一个事件的时候 整个 l.j 的值会持续增加,有界性基本无法保证。可以看看如下 论文中的案例:
随着消息事件持续在4个进程之间传递,最后回到进程1 的时候 逻辑时钟部分 和物理时钟部分 差异非常大 pt.j = 4, l.j=17。在频繁的消息传递场景,l.j 部分只会持续增加下去。 背离了我们最初的想法:时钟的直观性。
因为以上算法将物理时钟和逻辑时钟 都用 l.j 进行标识,也就是只要物理时钟出现一次没有追赶上逻辑时钟的话,那后续的事件传递过程中物理时钟就没有什么用了。
所以 第二版本的算法做了重新的设计,将物理时钟的标识和逻辑时钟的标识 分开。整个 HLC 的时钟部分 不再是一个
l
.
j
l.j
l.j,而是
l
.
j
l.j
l.j和
c
.
j
c.j
c.j。
其中
l
.
j
l.j
l.j 是 事件 j 发生时感知到的最大的物理时钟,
c
.
j
c.j
c.j 是逻辑时钟部分,当 两个事件的物理时钟部分想等的时候才使用
c
.
j
c.j
c.j部分更新/比较,从而达到捕获因果关系的目的。
新的算法如下:
初始化:l.j = 0, c.j = 0
本地事件发生/发送事件发生:
// 保留事件的物理时钟部分到l'.j
l'.j = l.j;
// 和本地机器的物理时钟比较,取较大值
// 确保l.j >= pt.j
l.j = max(l'.j, pt.j);
// 如果两个物理时钟部分相 等,则增加逻辑时钟部分
if (l.j == l'.j) then
c.j = c.j + 1
else // 否则,重置逻辑时钟部分(关键,,,不会让逻辑时钟部分一直增加)
c.j = 0
接受事件m 发生:
// 保留已有事件的物理时钟部分
l'.j = l.j
// 从本地时钟,上一个物理时钟 以及 接收到m 消息的物理时钟中取较大值
// 确保 l.j >= pt.j
l.j = max(l'.j, l.m, pt.j)
// 如果物理时钟部分都相等,则增加逻辑时钟部分,取 c.j 和 c.m较大的
if (l.j == l'.j == l.m) then c.j = max(c.j, c.m) + 1
// 如果只有本地物理时钟 和 当前消息的上一个物理时钟部分相等,只根据当前消息的上一个逻辑时钟值增加
else if (l.j == l'.j) then c.j = c.j + 1
// 如果本地物理时钟 和 m 消息的物理时钟相等,那根据m 消息的逻辑时钟更新即可
else if (l.j == l.m) then c.j = c.m + 1
// 如果物理时钟部分都不相等,重置逻辑时钟
else c.j = 0
从以上算法能得到如下定理:
-
对于任意两个事件,e 和 f,如果 e → f e\rightarrow f e→f,则 ( l . e , c . e ) < ( l . f , c . f ) (l.e, c.e) < (l.f,c.f) (l.e,c.e)<(l.f,c.f)
-
对于任何一个事件f,一定存在 l . f > = p t . f l.f >= pt.f l.f>=pt.f,因为每一个事件发生时 对物理时钟部分的更新都是 取所有物理时钟(本地物理时钟,上一个消息的物理时钟,接受消息的物理时钟)较大的部分。
-
l . f l.f l.f 能够标识事件 f 已知的最大时钟值。用公式来描述就是下面这个公式:
l . f > p t . f ⇒ ( ∃ g : g h b f ∧ p t . g = l . f ) l.f > pt.f \Rightarrow (\exists g: g \space hb \space f \wedge pt.g = l.f) l.f>pt.f⇒(∃g:g hb f∧pt.g=l.f)
大体意思是 对于任意一个事件f,如果 l.f > pt.f,可以推论出 一定存在一个事件g,满足 g发生在f之前 且 事件g 的物理时钟和 f的 hlc 物理时钟相等。采用数据归纳法进行证明,证明的过程如下:
-
对于发送事件f, e 是 f 的 上一个事件。我们先需要假设 n-1 项是成立的,然后能够推导出第n项,即可证明整个定理。
假设 l . e > p t . e ⇒ ( ∃ g : g h b f ∧ p t . g = l . e ) l.e > pt.e \Rightarrow (\exists g: g \space hb \space f \wedge pt.g = l.e) l.e>pt.e⇒(∃g:g hb f∧pt.g=l.e) 成立,
对于 事件f ,如果 l . f > p t . f l.f > pt.f l.f>pt.f,成立;根据发送事件的算法实现 : l . f = m a x ( l . e , p t . f ) l.f = max(l.e, pt.f) l.f=max(l.e,pt.f),也就是说 l . f = l . e l.f = l.e l.f=l.e,也就表示 e 是发生在f之前的,因为假设条件中说 l.e 是有可能等于 pt.g的,这样就有可能出现 l . f = p t . g l.f=pt.g l.f=pt.g。这样 e 事件 就是以上定理的一个子集,表示 l . f > p t . f ⇒ ( ∃ g : g h b f ∧ p t . g = l . f ) l.f > pt.f \Rightarrow (\exists g: g \space hb \space f \wedge pt.g = l.f) l.f>pt.f⇒(∃g:g hb f∧pt.g=l.f) 是成立的。 -
对于接受事件f,e 仍然是接受进程的上一个事件, m 表示接受到的事件。
同上归纳法,如果 l . f > p t . f l.f > pt.f l.f>pt.f,那么 l . f = l . e ∣ ∣ l . f = l . m l.f = l.e || l.f = l.m l.f=l.e∣∣l.f=l.m,这样利用前面归纳法的证明方式也可以得到
l . f > p t . f ⇒ ( ∃ g : g h b f ∧ p t . g = l . f ) l.f > pt.f \Rightarrow (\exists g: g \space hb \space f \wedge pt.g = l.f) l.f>pt.f⇒(∃g:g hb f∧pt.g=l.f) 推论。
-
第三个定理基本能够描述 时钟跳变的有界性,如果发生了 l . f > p t . f l.f > pt.f l.f>pt.f,则表示发送/接受事件之间的时钟不同步,而在当前事件之前发生的某一个事件中一定存在一个时钟是同步的事件。
如果分布式系统中 获取时钟的误差为 α \alpha α,HLC算法中对于任意一个事件j 来说, ∣ l . j − p t . j ∣ < = α |l.j - pt.j| <=\alpha ∣l.j−pt.j∣<=α,HLC 物理时钟部分和本地物理时钟的值差距不会超过 α \alpha α,这个具体数值取决于 NTP 的延时误差(前面介绍过,局域网内在几个ms级,甚至更小。广域网则可能达到百ms)。
查看论文中给出的如下案例:
消息在进程 0-3 之间传递,可以看到 消息事件 HLC 的物理时钟部分 l.j 因为最开始 0 号进程的物理时钟较高(可以看作时钟源),所以它发送到其他 进程的 l.j 部分都比较高。虽然,其他进程的物理时钟 相比于 0 号进程的物理时钟有偏移,但是实际的消息传递过程中l.j 因为较大会一直保持, 并且和 pt.j 之间的差异并没有被放大,而是在不断被缩小。这段期间内的 消息因果关系的比较基本都靠 c.j逻辑时钟部分。
如果后续的消息传递过程中 出现NTP 时钟延迟超过10的情况,那l.j 部分会被重置,这个时候事件的因果关系的比较会依赖物理时钟。
5.2 工程实现
这就引出来一个工程实现上的细节。如何配置 HLC l.j 和 c.j 的大小比例,一般的配置有: 48:16, 52:12, 56:8,也就是说整个 H LC 使用一个64bit 的整数标识,对于高 48位的bit 部分用来标识 l.j 物理时钟部分, 对于低 16bit 用于表示 c.j逻辑时钟部分。c.j 部分越小,也就是能够容忍的时钟跳变周期越短,毕竟跳变期间的时钟是有误差的, l.j 部分不会变化,事件的因果关系的确定只能通过 c.j的比较,配置的 bit位 比如 56: 8, 也就是这段时间只能处理256 个请求。
应用的话可能得根据实际的数据库定位,比如如果是全球部署的数据库,发生异常时 NTP 延时往往超过百ms,这样 HLC的 c.j 部分的配置得设置成允许较大的数值才行。当然,对于能提供 本地物理精确时钟(时钟误差极小的情况),可以将 c.j配置的足够小,数据库依赖物理时钟部分能坚持的事件更久一些,举个例子:52 bit 表示物理时钟的话,时钟单位如果是us,则HLC系统大概能坚持 142年,至于142年之后的事情,这个坑给谁:)。可以告个警?
实际的实现中,像现在比较火全球部署数据 YugabyteDB 以及 CRDB 都是使用HLC 方案作为自己的时钟选型,实现细节经过大规模的工业实践验证,有很多比较 Tricky 的地方可以借鉴学习。比如 YugaByteDB 的 safe-timestamp-assignment-for-read-request 以及 CockroachDB的 living-without-atomic-clocks 有更多的实现细节。
5.3 TrueTime 引入
接下来,我们看看接近十年前有钱人的做法,TrueTime,大家费劲得做了非常多的探索,想要尽可能得降低物理时钟偏差对上层应用的影响。而人家直接给自己的分布式集群提供一套 稳定在 10ms 以下精度的物理时钟,有这样的能力,那我们前面说的 64bit 中很大一部分bit 都可以交给物理时钟部分,这样人家的系统就能坚挺得更久而物理时钟部分不会被耗尽。。。
6 TrueTime
TruTime 是 Google Spanner 数据库论文中 2012年提出的一种时钟方案,包含了硬件和算法两部分。
6.1 TrueTime 硬件实现架构
先来看一下 TrueTime 的硬件架构部分,主要使用的是 原子钟 和 GPS 无线时钟,这两种物理时钟都是我们前面提到的更为精确的时钟源,在NTP系统中一般会被用作 顶层时钟源。这一些硬件会被分别作为时钟源放在对应的服务器上,TrueTime 两种时钟一起使用的原因是因为他们各自的异常情况互不重叠,防止所有时钟源因为一种异常而整个宕机不可用,这样就可以可以互为备份。
比如 ,GPS 无线时钟会收到无线电干扰而接受不到时钟信息,这种问题在原子钟中不存在。
下图是一个 google 数据中心中的 GPS时钟 server 和 原子钟 server 的形态,上下的服务器是正常的时钟同步服务器(可以理解为NTP的第二层)。
以上 类型的 GPS 时钟服务器 和 原子钟服务器 会被部署在 spanner 全球分布 的每一个数据中心。
每一个时钟服务器上会运行一个守护进程,它会定期(30s)和同一数据中心内部的时钟服务器之间会同步时钟,如果发现某一个服务器异常,会直接踢除。客户端拉取时钟的时候会从多个时钟服务器上获取 进行本地比较,防止一个时钟服务器的时钟异常。
即使不同数据中心内部都会有本数据中心非常近的时钟源,但因为各种外部因素 以及 时钟内部的一些小的偏差, 虽然 Google 控制着 TrueTime 主服务器和守护程序的网络环境,在实践中,不确定性间隔在 1 毫秒到 7 毫秒之间变化。当然,这个数据是 2012年时 spanner论文中给出的误差, 现在应该会更好吧。
6.2 TrueTime 算法
TrueTime 提供了三个 API来操作实践:
-
函数:
TT.Now()
, 返回值:TTinterval: [earliest, latest]
对于事件e 来说,获取它的当前时钟时, 返回的是当前时间 t a b s ( e ) t_{abs}(e) tabs(e),由于存在时钟误差,这个值是一个范围,也就是满足 t t . e a r l i e s t < = t a b s ( e ) < = t t . l a t e s t tt.earliest <= t_{abs}(e) <= tt.latest tt.earliest<=tabs(e)<=tt.latest。这个时钟误差前面介绍过,最大是7ms,平均延时只有4ms(相比于)。 -
函数:
TT.after(t)
, 返回值:true if t has definitely passed
用于判断当前时间戳 是否是过去时间,即 t a b s ( e ) < t t . e a r l i e s t t_{abs}(e) < tt.earliest tabs(e)<tt.earliest -
函数:TT.before(t) ,返回值:true if t has definitely not arrived
用于判断当前时间戳 是否是未来时间,即 t a b s ( e ) > t t . l a t e s t t_{abs}(e) > tt.latest tabs(e)>tt.latest
使用TrueTime API时,需要搭配下面两个规则。
- Start: 提交事务 T i T_i Ti 时,coordinator 必须选择一个大于等于TT.now().latest的时间作为提交时间戳 s i s_i si。
- Commit Wait: coordinator 必须等待TT.after(si)为true后才能提交数据,也即必须等待 s i s_i si的绝对时间过去了才能提交数据。
有这个两个规则的保证,如果事务 T1 提交后 T2开始,则 T2一定是晚于 T1 的提交时间的。T2 的起始时间是需要等待 T1 的 after 为 true之后才能开始,这样事务的提交顺序和事物的发生顺序在绝对时间上是一致的。
关于TrueTime 在事务中的提交案例可以参考 引用中的 spanner paper 以及 一位优质博主提供的案例 优质博客 : 计算机的时钟。
论文中给出了 TrueTime 最后的一个 时钟偏移延时情况:
以下测试是上千台服务器分布在 间隔2200km 的多个数据中心内部测试出来。
其中 3.29-3.30 号期间 spanner 对网络的拥塞情况做了优化之后 基本可以保证 p999在5ms以内,而在4.13 号时有一个小时的大毛次 是因为 一个数据中心的 两台 time-master 异常(原子钟/GPS时钟异常被踢出)导致的。即使这样,p999 时钟误差也在6ms以内。
6.3 总结
虽然 一个数据中心配置一套或者多套 GPS 时钟/原子钟 对于现在大多数云厂商来说 相比于庞大的服务器集群 ,成本可以忽略不计了,但实际 TrueTime 这样的系统业界除了 spanner 之外还没有做出来的。
可以看到,TrueTime 的 api 其实很简单的,应用到分布式事务中也就像使用 HLC 这样的方案一样。复杂的是 实现一套跨数据中心 的 精确控制时钟偏移 的 Time 系统。
有一位老哥在 NTP 上实现了 TrueTime 的 TrueTime - API,但是时钟漂移却达到了 5ms-350ms 之间,很大一部分原因是:
- NTP 系统的实现并不像 TrueTime 的 time-master 能够快速检测到硬件异常
- 能够允许一次客户端poll 时钟可以在 非常优秀的网络环境下(google 自己优化的)跨数据中心,从而高效得对比时钟偏移 ,尽可能得提供精确的时间戳。
技术垄断总是 从下往上的,越底层越复杂(积累得越多),也越难以超越,共勉!!!
7 参考
1. Lamport paper
2. Vector time paper
3. spanner paper
4. hlc paper
5. 优质博客 : 计算机的时钟
6. ntpv4 white paper