在数据系统中,时钟(clocks)和时间(time)都很重要。应用程序会以很多种形式依赖时钟,举例来说:
例子 1-4 测量的是时间间隔(durations),例子 5-8 描述的是时间点(points in time)。在分布式系统中,时间是一个棘手的问题。因为两个机器间的通信不是瞬时完成的,虽然我们知道一条消息的接收时间一定小于发送时间,但由于通信延迟的不确定性,我们无法知道具体晚了多久。因此,发生在分布式系统内多个机器的事件,很难准确地确定其先后顺序。
更进一步,网络中的每个机器都有自己的系统时钟,通常是用石英振荡器做成的特殊硬件计时的,并且通常是独立供电的,即使系统宕机、断电也能持续运转。但这种时钟通常不是非常准确,即有的机器可能走地稍微快一些,有的可能就慢一些。因此,实践中,常通过 NTP(网络时间协议)对机器进行自动校准。其大致原理是,首先使用更精确时钟(如 GPS 接收器)构建一组可信服务器作为时钟源(比如阿里云的源),然后再利用这组服务器通过网络校准其他机器。
当代的计算机通常支持两类时钟:日历时钟(time-of-day clock)和单调时钟(monotonic clock),他们之间有些区别,其实分别和该小节最初提出的需求相对应:前者常用于时间点需求,后者常用于计算时间间隔。
该时钟和我们日常生活中的时钟关联,也称为挂钟时间(wall-clock time),通常会返回当前日期和时间。如:Linux 上的 clock_gettime(CLOCK_REALTIME)
和 Java 里的 System.currentTimeMillis()
,他们都会返回基于格里历(Gregorian calendar)1970 年 1 月 1 日 00:00:00 时刻以来的秒数(或者毫秒数),不包括闰秒。当然,一些系统可能会用其他时刻作为计时起点。
日历时钟常常使用 NTP 进行同步,以使得不同机器上时间戳能够同步。但之后会提到,日历时钟有诸多不确定性。这里值得一提的是,如果某个机器时间大大领先于 NTP 服务器,则其日历时钟会被重置,从而让该机器上的时间看起来倒流了一样。时钟回拨、跳过闰秒等等问题,使得日历时钟不能用于精确计算一个时间间隔。
此外,历史上,日历时钟还使用过粗粒度的计时方案,如老版的 Windows 系统的时钟最小粒度是 10 ms。当然,在最近的系统里,这不再是问题。
单调时钟主用于取两个时间点的差值来测量时间间隔,如服务器的超时间隔和响应时间。Linux 上的 clock_gettime(CLOCK_MONOTONIC)
和 Java 中的 System.nanoTime()
都是单调时钟。顾名思义,单调时钟不会像日历时钟一样由于同步而进行回拨,可以保证一直单调向前。也正因为如此,用其计算时间间隔才更加准确。
在具有多个 CPU 的服务中,每个 CPU 可能会有一个单独的计时器,且不同 CPU 之间不一定同步。但操作系统会试图屏蔽其间差异,对应用层保证单调递增。这样,即使一个线程被调度到不同 CPU 上去,也仍能保证单调。当然,最保险的办法是不严格依赖此单调性。
如果检测到本地石英钟和 NTP 服务器不一致,NTP 会相应调整单调时钟的频率,但是幅度不能超过 0.05%。换句话说,NTP 可以调整单调时钟频率,但不能直接往前或者往后跳拨单调时钟。因此,多数机器上单调时钟的置信度通常都很好,能达到毫秒级甚至更细粒度。即,单调时钟会通过调整频率来和 NTP 时间对齐,而非像挂历时钟那样直接跳拨。
在分布式系统中,使用单调时钟计算时间间隔很合适,因为时间间隔既不关心多机间进行时钟同步,也对时间精度不是很敏感。
单调时钟不太需要关心多机同步问题。但是对于日历时钟来说,由于自身石英钟计时不够精确,为了能够正常使用,需要定时与 NTP 服务器或者其他可信时钟源进行同步。想象你家老式的电子挂钟,就得定时手动与新闻开始时的北京时间进行校准,不然会越走越快或者越走越慢。
在计算机系统中,其本身的硬件时钟以及用于校准的 NTP 服务都不是完全可靠的:
当然,如果不计代价,我们是能够获得足够精确的时钟的。例如针对金融机构的欧洲法案:MIFID II,要求所有高频交易的基金需要和 UTC 的误差不超过 100 微秒,以便调试如“闪崩”之类的市场异常,并帮助检测市场操纵行文。
可以通过组合使用 GPS 接收机、PTP 协议(Precision Time Protocol),并进行小心部署和监控,来获取此类高精度时钟。然而,这需要非常多的专业知识和精力投入,且仍有很多问题会引起时钟不同步:NTP 服务器配置错误、防火墙错误组织了 NTP 流量。
如前所述,尽管看起来简单易用,但时钟却有一些严重的问题:
前面讨论过,虽然网络丢包和不固定延迟不常发生,但在做设计时,仍要考虑这些极端情况。对于时钟也是如此:虽然大部分时间,时钟都能如我们预期一样工作,但在设计系统时,仍要考虑最坏可能,否则一旦出现故障时,通常难以定位。
时钟问题造成的影响往往不容易被发现。如果 CPU、内存或者网络故障,可能系统会立即出现很严重的问题;但如果不正确的依赖了时钟,可能系统仍然能在表面上看起来正常运转,比如时钟漂移是慢慢累加的,可能到第一程度才会出现问题。
因此,如果你的系统依赖(或者假设)所有参与的机器时钟同步(synchronized clocks),就必须通过一定的机制来检测系统内节点间的时钟偏移,如果某个节点系统时钟与其他相差过大,就及时将其从系统内移除,以此来规避时钟相差太大所造成的不可挽回的问题。
考虑一种危险的依赖时钟的情况:使用节点的本地时钟来给跨节点的事件定序。举个具体例子,如果两个客户端同时写入同一个分布式数据库,谁居前?谁靠后?
分布式系统中事件的后发先到
如上图,Client A 向节点 1 写入 x = 1
,然后该写入被复制到节点 3 上;Client B 在节点上将 x 增加 1,得到 x = 2
;最终上述两个写入都被复制到节点 2。
在图中所有待同步的数据都会被打上一个时间戳,接收到同步来数据的节点会根据时间戳对所有写入应用到本地。那么如何使用时间戳呢?所有节点只会通过时间戳递增的顺序接受同步过来的写入请求,如果某个写请求时间戳小于上一个写请求的,则丢弃。
另外,节点 1 和节点 3 的时钟相差小于 3 ms,这在日常中是一个很小的偏差,算是时钟同步的很好了。但在这个例子中,却仍然出现了顺序问题,写请求 x = 1 的时间戳是 42.004,写请求 x = 2 的时间戳是 42.001,小于 x = 1 的写请求,但我们知道,写请求 x = 2 应该发生在后面。于是当节点 2 收到第二个写请求 x = 2 时,发现其时间戳小于上一个写时间戳,于是将其丢掉。于是,客户端 B 的自增操作在节点 2 上被错误的丢弃了。
后者胜(Last write win,LWW)作为一种冲突解决策略,在多副本(无论是多主还是无主)架构中被广泛使用,比如 Cassandra 和 Riak。有一些实现是在客户端侧而非服务侧产生时间戳,但这仍不能解决 LWW 固有问题:
因此,在使用后者胜策略解决冲突,并丢弃被覆盖更新时,需特别注意如何判定哪个事件更近(most recent),因为其定序可能依赖于不同机器的本地时钟。即使使用紧密同步的 NTP 时钟,仍然可能发生:在 100 ms 时(发送方本地时钟)发出一个数据包,而在 99ms 时(接收方本地时钟)收到该数据包,就好像时光倒流一样,但这明显不可能。
使用 NTP 进行时钟同步有可能做到足够精确,以避免网络中对事件错误的排序吗?不可能。因为 NTP 本身就是通过网络进行同步的,其精度则必受限于同步两侧的往返延迟,更遑论叠加其他误差,比如石英钟的漂移(quartz drift)。换句话说,为了处理网络误差以正确定序,我们需要使用更精确的手段,而非网络本身(NTP)。
逻辑时钟和物理时钟的区别。所谓的逻辑时钟(logical clocks),使用了自增的计数器,而非石英晶振(oscillating quartz crystal)。逻辑时钟不会追踪自然时间或者耗时间隔,而仅用来确定的系统中事件发生的先后顺序。与之相对,挂历时钟(time-of-day)和单调时钟(monotonic clocks)用于追踪时间流逝,被称为物理时钟。
尽管你可以从机器上读取以微秒(microsecond)甚至纳秒(nanosecond)为单位的日历时间戳(time-of-day),但这并不意味你可以得到具有这样精度的绝对时间。如前所述,误差来自于几方面:
总结来说,使用普通硬件,你无论如何都难以得到真正“准确”的时间戳。
因此,将时钟的读数视为一个时间点意义不大,准确来说,其读数应该是一个具有置信区间的时间范围。比如,一个系统有 95% 的信心保证当前时刻落在该分钟过 10.3 到 10.5 秒,但除此以外,不能提供任何一进步的保证。在 +/– 100 ms 的置信区间内,时间戳的微秒零头毫无意义。
时钟的误差区间可以通过你的时钟源进行计算:
但不幸,大多数服务器的时钟系统 API 在给出时间点时,并不会一并给出对应的不确定区间。例如,你使用 clock_gettime()
系统调用获取时间戳时,返回值并不包括其置信区间,因此你无法知道这个时间点的误差是 5 毫秒还是 5 年。
一个有趣的反例是谷歌在 Spanner 系统中使用的 TrueTime API,会显式的给出置信区间。当你向 TrueTime 系统询问当前时钟时,会得到两个值,或者说一个区间:[earliest, latest]
,前者是最早可能的时间戳。后者是最迟可能的时间错。通过该不确定预估,我们可以确定准确时间点就在该时钟范围内。此时,区间的大小取决于,上一次同步过后本地石英钟的漂移多少。
在“快照隔离和可重复读”一小节,本书讨论过快照隔离。当数据库想同时支持短时、较小的读写负载以及长时、很大的只读负载(如数据分析、备份)时,快照隔离很有用。快照隔离能让只读事务在不阻塞正常读写事务的情况下,看到数据库某个时间点之前的一致性视图。
快照隔离的实现通常需要一个全局自增的事务 ID。如果一个写入发生在快照 S 之后,则基于快照 S 的事务看不到该写入的内容。对于单机数据库,简单的使用一个全局自增计数器,就能够充当事务 ID 的来源。
然而,当数据库横跨多个机器,甚至多个数据库中心时,一个可用于事务全局自增 ID 并不容易实现,因为需要进行多机协作。事务 ID 必须要反应因果性:如当事务 B 读到事务 A 写的内容时,事务 B 的事务 ID 就需要比事务 A 大。非如此,快照不能维持一致。另外,如果系统中存在大量短小事务,分配事务 ID 可能会成为分布式系统中的一个瓶颈。这其实就是分布式事务中常说的 TSO 方案(Timestamp Oracle,统一中心授时),这种方案通常会有性能瓶颈;尤其在跨数据中心的数据库里,会延迟很高,实践中也有很多优化方案。
那么,我们可以用机器的挂历时钟的时间戳作为事物的 ID 吗?如果我们能让系统中的多台机器时钟保持严格同步,则其可以满足要求:后面的事务会具有较大的时间戳,即较大的事务 ID。但现实中,由于时钟同步的不确定性,用这种方法产生事务 ID 是不太靠谱的。
但 Spanner 就使用了物理时钟实现了快照隔离,它是如何做到可用的呢?Spanner 在设计 TrueTime 的 API 时,让其返回一个置信区间,而非一个时间点,来代表一个时间戳。假如现在你有两个时间戳 A 和 B(_A_ = [Aearliest, Alatest] and B = [Bearliest, Blatest]),且这两个时间戳对应的区间没有交集(例如,_Aearliest_ < Alatest < Bearliest < _Blatest_),则我们可以确信时间戳 B 发生于 A 之后。但如果两个区间有交集,我们则不能确定 A 和 B 的相对顺序。
为了保证这种时间戳能够用作事务 ID,相邻生成的两个时间戳最好要间隔一个置信区间,以保证其没有交集。为此,Spanner 在索要时间戳时(比如提交事务),会等待一个置信区间。因此置信区间越小,这种方案的性能也就越好。为此,谷歌在每个数据中心使用了专门的硬件做为时钟源,比如原子钟和 GPS 接收器,以保证时钟的置信区间不超过 7 ms。
在分布式事务中使用时钟同步,是一个比较活跃的研究领域(在书出版时,大概 2017),很多观点都很有趣,但在谷歌之外,还没有人再实现过。
现在我们来看另外一种分布式系统中使用时钟的危险情况。假设你的数据库有多分片,每个分片多副本单主,只有主副本可以接受写入。那么一个很直接的问题就是:对于每个主副本来说,为了保证安全的接受写入,我们需要确定它仍是事实上的主副本。那我们如何确定呢?毕竟有时他会自认为是主副本,但事实上不是(比如其他副本和他产生了网络隔离,已经重新选出了主)?
一种解决方案是使用租约(lease)。该机制(注意和 Raft 中 Leader 的租约区分)类似于具有超时的锁:任意时刻只有一个副本可以持有改租约。因此,一旦某副本获取到租约,它就获取到了一段时间的领导权,直到租约过期;为了持续掌握领导权,该副本需要定期续租,且续租间隔要小于租期时间。如果该副本宕机,自然就会停止续约,其他副本就可以上位。
用代码表示,续租大概长这样:
while (true) {
request = getIncomingRequest();
// Ensure that the lease always has at least 10 seconds remaining
if (lease.expiryTimeMillis - System.currentTimeMillis() < 10000) {
lease = lease.renew();
}
if (lease.isValid()) {
process(request);
}
}
这段代码有什么问题呢?
lease.expiryTimeMillis - System.currentTimeMillis() < 10000
)后,会立即进行请求处理:process(request);
。一般来说的确是这样。但如果在程序执行期间,发生了意外的停顿会怎么样?比如说,在 lease.isValid()
语句前线程停顿了 15 秒,这种情况下,在执行到真正处理请求的语句时,租约就可能会过期,此时,其他副本就有可能接下领导权限。但对于这个线程来说,并没有其他手段可以限制它继续执行,于是,就有可能发生一些不安全的处理事件。
程序在执行的时候真可能停顿这么长时间吗?还真有可能,原因有很多:
所有上述情景都会在任意时刻中断(_preempt_)正在运行的线程,并在之后某个时刻将其重新唤醒,而线程本身对这个过程是不感知的。类似的情形还有单机多线程编程:你不能对多个线程代码的相对执行顺序有任何假设,因为上下文切换和并发执行可能会在任何时间以任何形式发生。
在单机我们有很多手段可以对多线程的执行进行协调,使之线程安全。如锁、信号量、原子计数器、无锁数据结构、阻塞队列等等。但不幸的是,分布式系统中我们没有对应的手段。因为在多机间不能共享内存,只能依靠消息同步,而且是要经过不可靠网络的消息!
分布式系统中的节点可能在任意时刻的任意代码位置停顿任何时长,而在此间,系统的其他节点仍在正常往前执行,甚至由于该节点不响应而将其标记为死亡。最终,该停顿节点可能会继续执行,但此时代码逻辑本身(也就是你写分布式系统逻辑时)并不能知道发生了什么,直到其再次检查机器时钟时(虽然这也不太准)。
如前所述,在很多语言和操作系统中,进程和线程都可能停顿任意时间,我们将其称之为无界(unbounded)时延。但如果我们付出足够代价,上述造成停顿的原因都可以被消除。
在某些环境中的软件,如果不在一定时间内给响应,可能会造成严重问题。如飞机、火箭、机器人、车辆和其他一些需要对传感器输入信号进行即时反应的物理实体。在这些系统中,软件的响应延迟都需要控制在某个时限(deadline)内,超出了这个时限,整个系统可能会出现重大故障,这就所谓的强实时系统(hard real-time system,与之相对的有 soft real-time system)。
举个例子,如果你正在驾车,传感器检测到车祸,你肯定不希望你的车载系统此时正在进行 GC 而不能及时处理该信号吧。
实时系统真的实时吗?
在嵌入式系统里,实时意味着需要通过设计、测试等多层面来让系统在延迟上提供某种保证。其在 Web 中也有实时系统(real-time)的叫法,但更多的侧重于服务器会流式的处理客户端请求,并将数据发回客户端,但对响应时间并没有严苛的要求。
我们需要在全软件栈进行优化才能提供实时保证:
上述要求极大的限制了编程语言、依赖库和工具的可选范围,从而使得开发实时系统代价极为高昂。也因此,这种系统通常被用在对安全性有严苛要求的嵌入式设备里。这里需要说明的是,实时不意味着高性能。并且通常相反,实时系统都具有很低的吞吐,因为实时系统会把对时延的优化放在第一位,比如需要尽量消除并行所带来的运行的不可预测性,自然就降低了效率。
对于一般的服务端的数据处理系统来说,这种严苛的实时保证通常既不经济也不必要。也就是说,我们在设计服务端的数据系统时,还是要老老实实考虑由于任意停顿和不准确时钟所带来的问题。
我们有一些手段可以用来减轻进程停顿现象,且不必借助代价高昂的强实时系统。比如垃圾回收器(GC 进程)可以实时追踪对象分配速率和剩余可利用内存,利用这些信息,GC 进程可以给应用程序提供一些信号。然后我们在构造系统时捕获这些信号,然后拒绝服务一段时间,等待 GC 结束。就跟临时故障或者下线的节点一样,别的节点会来接管请求。一些对延迟比较敏感的系统,如交易系统,就是用了类似的方法。
另一个相似的想法是,阉割一下 GC,只用其对短时对象进行快速回收。对于生命周期较长的对象,通过通过定期重启来回收。在重启期间,该节点上的流量可以暂时切走,就像滚动升级一样。
这些手段虽然不能治本,但能一定程度上缓解 GC 对应用进程造成的影响。