你好,我是王磊,你也可以叫我Ivan。
我们今天继续聊读写冲突。上一讲我们谈的都是显式的读写冲突,也就是写操作和读操作都在同一时间发生。但其实,还有一种看不见的读写冲突,它是由于时间的不确定性造成的,更加隐蔽,处理起来也更复杂。
关于时间,我们在第5讲中已经做了深入讨论,最后我们接受了一个事实,那就是无法在工程层面得到绝对准确的时间。其实,任何度量标准都没有绝对意义上的准确,这是因为量具本身就是有误差的,时间、长度、重量都是这样的。
那么,时间误差会带来什么问题呢?我们用下面这张图来说明。
我们这里还是沿用上一讲的例子,图中共有7个数据库事务,T1到T7,其中T6是读事务,其他都是写事务。事务T2结束的时间点(记为T2-C)早于事务T6启动的时间点(记为T6-S),这是基于数据记录上的时间戳得出的判断,但实际上这个判断很可能是错的。
为什么这么说呢?这是因为时间误差的存在,T2-C时间点附近会形成一个不确定时间窗口,也称为置信区间或可信区间。严格来说,我们只能确定T2-C在这个时间窗口内,但无法更准确地判断具体时间点。同样,T6-S也只是一个时间窗口。时间误差不能消除,但可以通过工程方式控制在一定范围内,例如在Spanner中这个不确定时间窗口(记为ɛ)最大不超过7毫秒,平均是4毫秒。
在这个案例中,当我们还原两个时间窗口后,发现两者存在重叠,所以无法判断T2-C与T6-S的先后关系。这真是个棘手的问题,怎么解决呢?
只有避免时间窗口出现重叠。 那么如何避免重叠呢?
答案是等待。“waiting out the uncertainty”,用等待来消除不确定性。
具体怎么做呢?在实践中,我们看到有两种方式可供选择,分别是写等待和读等待。
Spanner选择了写等待方式,更准确地说是用提交等待(commit-wait)来消除不确定性。
Spanner是直接将时间误差暴露出来的,所以调用当前时间函数TT.now()时,会获得的是一个区间对象TTinterval。它的两个边界值earliest和latest分别代表了最早可能时间和最晚可能时间,而绝对时间就在这两者之间。另外,Spanner还提供了TT.before()和TT.after()作为辅助函数,其中TT.after()用于判断当前时间是否晚于指定时间。
那么,对于一个绝对时间点S,什么时候TT.after(S)为真呢?至少需要等到S + ɛ时刻才可以,这个ɛ就是我们前面说的不确定时间窗口的宽度。我画了张图来帮你理解这个概念。
从直觉上说,标识数据版本的“提交时间戳”和事务的真实提交时间应该是一个时间,那么我们推演一下这个过程。有当前事务Ta,已经获得了一个绝对时间S作为“提交时间戳”。Ta在S时刻写盘,保存的时间戳也是S。事务Tb在Ta结束后的S+X时刻启动,获得时间区间的最小值是TT1.earliest。如果X小于时间区间ɛ,则TT1.earliest就会小于S,那么Tb就无法读取到Ta写入的数据。
你看,Tb在Ta提交后启动却读取不到Ta写入的数据,这显然不符合线性一致性的要求。
写等待的处理方式是这样的。事务Ta在获得“提交时间戳”S后,再等待ɛ时间后才写盘并提交事务。真正的提交时间是晚于“提交时间戳”的,中间这段时间就是等待。这样Tb事务启动后,能够得到的最早时间TT2.earliet肯定不会早于S时刻,所以Tb就一定能够读取到Ta。这样就符合线性一致性的要求了。
综上,事务获得“提交时间戳”后必须等待ɛ时间才能写入磁盘,即commit-wait。
到这里,写等待算是说清楚了。但是,你仔细想想,有什么不对劲的地方吗?
对,就是那个绝对时间S。都说了半天时间有误差,那又怎么可能拿到一个绝对时间呢?这不是自相矛盾吗?
Spanner确实拿不到绝对时间,为了说清楚这个事情,我们稍微延伸一下话题。
Spanner将含有写操作的事务定义为读写事务。读写事务的写操作会以两阶段提交(2PC)的方式执行。有关2PC的内容在第9讲中已经介绍过,如果你已经记不清了,可以去复习一下。
2PC的第一阶段是预备阶段,每个参与者都会获取一个“预备时间戳”,与数据一起写入日志。第二阶段,协调节点写入日志时需要一个“提交时间戳”,而它必须要大于任何参与者的“预备时间戳”。所以,协调节点调用 TT.now()函数后,要取该时间区间的lastest值(记为s),而且s必须大于所有参与者的“预备时间戳”,作为“提交时间戳”。
这样,事务从拿到提交时间戳到TT.after(s)为true,实际等待了两个单位的时间误差。我们还是画图来解释一下。
针对同一个数据项,事务T8和T9分别对进行写入和读取操作。T8在绝对时间100ms的时候,调用TT.now()函数,得到一个时间区间[99,103],选择最大值103作为提交时间戳,而后等待8毫秒(即2ɛ)后提交。
这样,无论如何T9事务启动时间都晚于T8的“提交时间戳”,也就能读取到T8的更新。
回顾一下这个过程,第一个时间差是2PC带来的,如果换成其他事务模型也许可以避免,而第二个时间差是真正的commit-wait,来自时间的不确定性,是不能避免的。
TrueTime的平均误差是4毫秒,commit-wait需要等待两个周期,那Spanner读写事务的平均延迟必然大于等于8毫秒。为啥有人会说Spanner的TPS是125呢?原因就是这个了。其实,这只是事务操作数据出现重叠时的吞吐量,而无关的读写事务是可以并行处理的。
对数据库来说,8毫秒的延迟虽然不能说短,但对多数场景来说还是能接受的。可是,TrueTime是Google的独门招式,其他分布式数据库怎么办呢?它们的时间误差远大于8毫秒,难道也用commit-wait,那一定是灾难啊!
这就要说到第二种方式,读等待。
读等待的代表产品是CockroachDB。
因为CockroachDB采用混合逻辑时钟(HLC),所以对于没有直接关联的事务,只能用物理时钟比较先后关系。CockroachDB各节点的物理时钟使用NTP机制同步,误差在几十至几百毫秒之间,用户可以基于网络情况通过参数”maximum clock offset”设置这个误差,默认配置是250毫秒。
写等待模式下,所有包含写操作的事务都受到影响,要延后提交;而读等待只在特殊条件下才被触发,影响的范围要小得多。
那到底是什么特殊条件呢?我们还是使用开篇的那个例子来说明。
事务T6启动获得了一个时间戳T6-S1,此时虽然事务T2已经在T2-C提交,但T2-C与T6-S1的间隔小于集群的时间偏移量,所以无法判断T6的提交是否真的早于T2。
这时,CockroachDB的办法是重启(Restart)读操作的事务,就是让T6获得一个更晚的时间戳T6-S2,使得T6-S2与T2-C的间隔大于offset,那么就能读取T2的写入了。
不过,接下来又出现更复杂的情况, T6-S2与T3的提交时间戳T3-C间隔太近,又落入了T3的不确定时间窗口,所以T6事务还需要再次重启。而T3之后,T6还要重启越过T4的不确定时间窗口。
最后,当T6拿到时间戳T6-S4后,终于跳过了所有不确定时间窗口,读等待过程到此结束,T6可以正式开始它的工作了。
在这个过程中,可以看到读等待的两个特点:一是偶发,只有当读操作与已提交事务间隔小于设置的时间误差时才会发生;二是等待时间的更长,因为事务在重启后可能落入下一个不确定时间窗口,所以也许需要经过多次重启。
到这里,今天的内容就告一段落了,时间误差的问题比较抽象,你可能会学得比较辛苦,让我帮你整理一下今天内容。
总之,处理时间误差的方式就是等待,“waiting out the uncertainty”,等待不确定性过去。你可能觉得写等待和读等待都不完美,但这就是全球化部署的代价。我想你肯定会追问,那为什么要实现全球化部署呢?简单地说,全球化部署最突出的优势就是可以让所有节点都处于工作状态,就近服务客户;而缺失这种能力就只能把所有主副本限制在同机房或者同城机房的范围内,异地机房不具备真正的服务能力,这会带来资源浪费、用户体验下降、切换演练等一系列问题。我会在第24讲专门讨论全球化部署的问题。
最后,我要留给你一道思考题。
今天,我们继续探讨了读写冲突的话题,在引入了时间误差后,整个处理过程变得更复杂了,而无论是“读等待”还是“写等待”都会让系统的性能明显下降。说到底是由多个独立时间源造成的,而多个时间源是为了支持全球化部署。那么,今天的问题就是,你觉得在什么情况下,不用“等待”也能达到线性一致性或因果一致性呢?
欢迎你在评论区留言和我一起讨论,我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对时间误差下的读写冲突这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。
评论