软件应用依赖计算机的时钟 (Clocks) 来解决多种问题,如判断一个请求处理是否已经超时、一段缓存中的内容是否已经过时、一个错误发生的时间等等。计算机的时钟是一种硬件,通常是一个石英晶体谐振器 (Quart Crystal Oscillator)。这种时钟并不百分之百准确,会受其他因素(如温度)影响而跑得太快(或者太慢)。
复杂的时钟
单台计算机上的时钟的不确定性,在分布式系统中变得更加复杂。因为每个节点的时钟都是相对独立的 —— 节点 A 的时间可能比 节点 B 快一些,或者慢一些,所以在实际应用中,需要解决集群里节点时间同步的问题。通常来说计算机集群会通过网络时间协议(Network Time Protocol)来同步各节点的时间。
现代计算机系统通常提供两种时钟:time-of-day 和 monotonic。time-of-day 时钟基于一个日历提供当前的日期和时间,如 Linux 上的 clock_gettime(CLOCK_REALTIME)
和 Java 里的 System.currentTimeMillis()
会提供当前的时间相对 UTC 时间 1970年1月1日0时所经过的秒(或毫秒)数。monotonic
时钟给的则是当前时间相对一个任意时间点(如开机时间)所经过的纳秒数。这两种时钟各有各的用途。
Time-of-day 虽然适用于获取一个有意义的日历时间,但因为它会受 NTP 同步影响而导致时钟的突然向前或向后调整,而不适合用于计算两个时间点之间所经过的时间(elapsed time)。
Monotonic 时钟因为是相对任意时间点的时间,不受 NTP 同步影响,更适合用于精确计算同一节点上两个时间点之间所经过的时间。但是,尽管 monotonic 时钟不会受 NTP 同步影响而突然大步向前或向后调整,NTP 还是会干涉 monotonic 时钟的频率,即让时钟走得更快或更慢一些。默认 NTP 允许 monotonic 时钟频率被加快或减慢 0.05%。如果节点有多个 CPU,情况会变得更复杂一些,因为各个 CPU 可能有自己独立的计时器(timer),并相对隔离,尽管操作系统会综合多个 CPU 的情况,尝试提供一个统一的结果,但这并不是 100% 保证正确。
时钟同步问题
在分布式系统中 Time-of-day 时钟需要与 NTP 服务器的时钟同步才有意义。然而同步获取一个精确的时间是个非常麻烦的事情,原因有多种:
- 石英钟不准确,会发生漂移(drift)现象。时钟准确度由 ppm(parts per million)表示。ppm 代表了实际晶体频率与标称值的差别。Google 假定他家的服务器的 ppm 是 200,即如果一个服务器每30秒同步一次,则时钟漂移是6毫秒,如果是每天同步一次,则漂移是17秒。在时钟飘逸的这段时间里,可以出的错误有很多。因为漂移现象,绝对的时间同步很难达到。
- 如果一个节点与 NTP 的时间差别太大,同步之后可能会发生节点时间突然大步向前或向后调整。这种突变可能会给应用程序造成异常影响。
- NTP 同步会受网络延迟影响。节点的网络如果出问题无法与 NTP 通讯,那该节点的时间便会不再准确。如果没有适当的监控,这台时间异常节点可能很难被发现。因为有网络延迟,NTP 同步的时间能达到的准确性也取决于网络延迟的长短。
- NTP 如果处理闰秒不当,会对应用程序造成异常影响。
- 如果节点的应用程序跑在节点的虚拟机上,应用程序获取到的时间的准确性取决于宿主机 CPU 对虚拟机的管理行为(如暂停)。
因为各种各样的不确定性,获取一个准确的时钟时间是一件非常难的事情,需要花费巨大的资源和时间。精确时间协议(Precision Time Protocol)可以用来达到这一目的,但也解决不了所有问题(如上述的网络中断问题)。
危险的时钟
由于上述的各种原因,获取一个准确同步的时钟时间非常困难,如果一个应用系统依赖于同步的时钟时间,有一些危险的情况会发生。
事件排序
如何判断一个事件是否先于另一个事件发生在数据库系统中至关重要,时钟的问题会导致排序不当进而导致数据丢失。举个例子,在一个分布式的数据库系统里,每个写操作需要将结果复制到三个节点上。如果 A 发起一个写操作,请求写入 x=1
,节点1在10.004(该时间由节点1上的时钟决定)的时候收到并确认写入,同时异步地将数据复制到节点2和节点3,时间戳10.004跟着复制的请求传给两个节点。假设
B 在 A 之后请求写入 x += 1
,节点3在10.003(该事件由节点3上的时钟决定)收请求时,x
的值已经是1
,因此将值更新为2
,并异步地将数据复制到节点1和节点2,这时时间戳10.003跟着请求传给两个节点。如果在节点2上,A 发过来的 x=1
在 B 发过来的 x = 2
之后被收到,那么最后节点2上的值会是1
而不是2
。B
的写操作实际上失败了。
有个解决冲突的策略是 last write wins (LWW),然而,不管系统是采用请求发送端的时间还是采用请求接收端的时间来决定谁是last,都避免不了一个事实:last 是由一个节点上的 time-of-day 时钟来决定的,而这个时钟可能是错的。
逻辑时钟(logical clocks)是一种基于增量计数器的时钟实现方式,相比 time-of-day 或 monotonic 时钟,可以用来更加安全地决定事件发生的顺序。
进程暂停
许多分布式应用系统需要进行领导选举(leader election),即选取一个节点作为leader执行任务。一个简单的实现是基于 lease 和 timeout。一个集群中,在任意一个时间点,只有一个节点可以作为 leader,并且作为 leader 的时间有限,超时之后需要与其他节点竞争尝试重新 lease。
while (true) {
request = getIncomingRequest();
if (lease.expiryTimeMillis - System.currentTimeMillis() < 10000) {
lease = lease.renew();
}
if (lease.isValid()) {
process(request);
}
}
如果 lease.isValid()
因为某些原因等了15秒才执行完成,那这个节点开始执行 process(request)
时,已经有另一台节点 lease 了 leader 并处理了这个请求了。
有什么可能造成进程执行了或暂停了15秒这么长?
- Stop-the-world GC
- 虚拟机被宿主机挂起或被另一个虚拟机抢走了 CPU 时间
- 非应用程序主动触发的 IO,如果 Java classloader
- 运维误操作给进程发送了 SIGSTOP
在一个分布式系统中,一个节点的随时有可能在任何时间点被暂停,因此不能对 timing 做任何假设。