当数据库说“之前”的时候:分布式事务里那个被假装存在的“同时”
快照隔离给每个事务分配一个时间戳,读取者看到的是“时间戳更小”的写入。这个机制听起来自洽,但它暗含一个几乎无法在分布式系统中兑现的前提:时间戳的大小顺序,必须与真实世界的先后顺序一致。如果 T1 在真实时间中提交,然后 T2 在另一台机器上开始,T2 的快照必须包含 T1——这个保证叫外部一致性,也叫事务的线性一致性。单机数据库靠一个时钟就能做到。分布式数据库不行:普通服务器时钟之间的偏差在几十到几百毫秒量级,“之后”这件事,恰恰是分布式系统看不见的。
围绕这个看不见的“之后”,生产系统给出了两条截然不同的路径。它们的差异不在于谁“更正确”,而在于把时钟不可靠的代价转移到了哪个维度。
第一条路:把不确定性变小,然后用睡眠把它花掉
Google Spanner 的 TrueTime 不返回一个时间点,它返回一个区间 [earliest, latest],保证真实时间落在这个区间内,半宽 ε 是当前最坏情况下的时钟误差。Google 在每个数据中心部署 GPS 接收器和原子钟,把 ε 压到大约 1–7 ms,再加上两次同步之间的漂移记账。
有了一个“知道自己有多不准”的时钟,Spanner 就能用一种朴素的方式兑现外部一致性:commit-wait。事务提交时,时间戳取 TT.now().latest,然后等待,直到 TT.now().earliest 超过这个时间戳,才向客户端确认提交[reference:4]。等待结束后,任何机器上的时钟都已经越过了这个提交时间戳,之后开始的任何事务,拿到的时间戳必然更大。
这条路的代价非常直白:每一次写入提交,都要支付大约 2ε 的睡眠。ε 是 1–7 ms 时,这个睡眠在毫秒级,对于跨地域的 OLTP 来说可以接受。但它的前提是硬件:GPS 接收器、原子钟、每个数据中心的时钟基础设施。没有这些,ε 就是 NTP 的几百毫秒,commit-wait 的睡眠会直接变成不可承受的延迟。Spanner 的承诺(外部一致性、无需读时重启)绑定在一个它自己建造的物理基础设施上。
第二条路:不缩小不确定性,而是在读的时候承认它
CockroachDB 走的是另一条路。它不假设 GPS 和原子钟,接受 NTP 级别的时钟偏差。它使用混合逻辑时钟(HLC):时间戳由物理分量和逻辑计数器组成,物理分量让 HLC 贴近真实时间,逻辑分量保证因果顺序——如果 A 因果先于 B,那么 HLC(A) 一定小于 HLC(B)[reference:6]。
但 HLC 只给了因果顺序,没有给外部一致性。一个写入在真实时间中先提交,另一个节点上的读事务仍然可能拿到更小的时间戳,因为两个节点的物理时钟并不对齐[reference:7]。CockroachDB 的补丁打在读路径上:每个读事务定义一个不确定性区间 [read_ts, read_ts + max_offset],max_offset 默认是 500 ms。任何时间戳落在这个区间内的写入都是“模糊的”——读事务无法判断它是在自己开始之前还是之后提交的。CockroachDB 的处理方式是保守重启:把读事务提升到一个高于该模糊写入的时间戳,重新执行[reference:8]。
这就是 ReadWithinUncertaintyInterval 重启。它的关键性质在于:这不是一个可以调参消除的偶发事件,而是时钟偏差在读写路径上的直接投影。只要两个节点的时钟不对齐,不确定性区间就会存在,区间内的模糊写入就会存在,重启就会发生。Netdata 的运维文档把这个信号定性得很清楚:readwithinuncertainty 的重启率如果持续非零,指向的是 NTP 或底层时钟源没有让节点时钟对齐,而不是查询或 schema 的问题[reference:9]。
800 毫秒的底线:max-offset 是一份部署承诺
CockroachDB 的 max_offset 在代码注释里被描述为“部署做出的承诺,而不是一次测量”[reference:10]。这个措辞值得停下来看。
max_offset = 500 ms 意味着:集群假设任意两个节点的时钟偏差不超过 500 ms。只要这个假设成立,不确定性区间就是安全的,重启逻辑就是正确的。但这个假设不是数据库自己保证的,它是部署方通过 NTP 配置和维护来兑现的。如果实际偏差接近这个上限,不确定性区间会变宽,更多并发写入落入模糊地带,重启率上升,尾延迟被直接拉高[reference:11]。
CockroachDB 为此设了一道硬边界:当一个节点检测到自己的时钟相对于多数派节点的偏差超过 max_offset 的 80%(默认即 400 ms),并且持续超过 2 分钟,节点会自终止,以防止时钟偏差演变成数据损坏[reference:12]。这是一个正确性优先于可用性的选择:宁可让节点下线,也不允许它在时钟假设已经被破坏的情况下继续参与事务。
但这道边界也创造了一个级联风险:如果多个节点共享同一个出问题的 NTP 基础设施,它们可能在相近的时间同时漂移、同时自终止,集群可能因此失去多数派[reference:13]。时钟基础设施的故障,在这里不再是一个“性能问题”,而是一个可用性事件。
两条路的代价不在同一个地方
把 Spanner 和 CockroachDB 并排放在一起,会看到一种结构上的对称:两者都承认时钟不可靠,但一个选择在提交时支付代价,另一个选择在读时支付代价。
Spanner 用硬件把 ε 压小,然后用 commit-wait 在写路径上花掉它。读路径是干净的:读事务不需要因为时钟模糊而重启。代价是硬件的资本开支,以及每一次提交固定量的睡眠延迟。
CockroachDB 不买硬件,接受几百毫秒的时钟偏差,把代价放在读路径上:一部分读事务会因为不确定性区间而重启,尾延迟中多出一块与时钟偏差直接相关的税。写路径没有 commit-wait 的睡眠,但 max_offset 的运维纪律变成了正确性的一部分——NTP 配置失误不再只是“时间不准”,而是会触发节点自终止和重启率上升。
这两种设计都不是“更好的”或“更差的”。它们是同一个约束在两个不同成本维度上的投影。Spanner 的承诺绑定在物理基础设施上,CockroachDB 的承诺绑定在运维纪律上。选择其中任何一个,意味着选择自己更愿意在哪个维度上承担不确定性。
还有第三条路,但它把边界挪到了别处
中心化的时间戳分配器(TSO)是第三种方案,Percolator 和 TiDB 都采用它。一个全局授时服务为所有事务分配时间戳,顺序天然是全局一致的,不需要 HLC 的逻辑计数器,也不需要不确定性区间。TiDB 的 PD leader 作为授时服务,官方给出的性能量级是百万级 QPS[reference:14]。
但中心化 TSO 的代价是它把问题从“时钟是否对齐”挪到了“授时服务是否可达”。每一次事务的时间戳分配都需要与 TSO 通信,跨地域部署时这是一次 WAN 往返;TSO 本身是单点,需要高可用机制来兜底。它没有消除时钟问题,只是把“物理时钟不可靠”替换成了“授时服务的网络延迟和可用性”,然后用一个集中式的顺序点来覆盖前者。
时间戳不是免费的,只是账单寄到了不同的地方
回到文章开头那个被假装存在的“同时”:分布式数据库说“T1 在 T2 之前”的时候,它要么为此造了一套物理时钟基础设施,要么为此接受一部分读事务会被重启,要么为此维护一个中心化的授时服务。没有一种方案能让时间戳在零成本下获得真实时间的顺序。
判断一个分布式数据库的时钟设计是否适合自己,比看它的 TPS 数字更有用的,是先确认一件事:它为“之前”这个判断支付的代价,落在你愿意承担的那个维度上吗? 如果答案是硬件和提交延迟,Spanner 的模型是自洽的;如果答案是读尾延迟和 NTP 运维纪律,CockroachDB 的模型是自洽的;如果答案是一个需要高可用的中心授时服务,TSO 的模型是自洽的。边界不在协议里,在账单寄到的地方。
- 点赞
- 收藏
- 关注作者
评论(0)