gitbook/分布式数据库30讲/docs/281671.md
2022-09-03 22:05:03 +08:00

11 KiB
Raw Permalink Blame History

12 | 隔离性:看不见的读写冲突,要怎么处理?

你好我是王磊你也可以叫我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

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。

因为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可以正式开始它的工作了。

在这个过程中,可以看到读等待的两个特点:一是偶发,只有当读操作与已提交事务间隔小于设置的时间误差时才会发生;二是等待时间的更长,因为事务在重启后可能落入下一个不确定时间窗口,所以也许需要经过多次重启。

小结

到这里,今天的内容就告一段落了,时间误差的问题比较抽象,你可能会学得比较辛苦,让我帮你整理一下今天内容。

  1. 时间误差是客观存在的,任何系统都不能获得准确的绝对时间,只能得到一个时间区间,差别仅在于有些系统承认这点,而有些系统不承认。
  2. 有两种方式消除时间误差的影响,分别是写等待和读等待。写等待影响范围大,所有包含写操作的事务都要至少等待一个误差周期。读等待的影响范围小,只有当读操作时间戳与访问数据项的提交时间戳落入不确定时间窗口后才会触发,但读等待的周期可能更长,可能是数个误差周期。
  3. 写等待适用于误差很小的系统Spanner能够将时间误差控制在7毫秒以内所以有条件使用该方式。读等待适用于误差更大的系统CockroachDB对误差的预期达到250毫秒。

总之处理时间误差的方式就是等待“waiting out the uncertainty”等待不确定性过去。你可能觉得写等待和读等待都不完美但这就是全球化部署的代价。我想你肯定会追问那为什么要实现全球化部署呢简单地说全球化部署最突出的优势就是可以让所有节点都处于工作状态就近服务客户而缺失这种能力就只能把所有主副本限制在同机房或者同城机房的范围内异地机房不具备真正的服务能力这会带来资源浪费、用户体验下降、切换演练等一系列问题。我会在第24讲专门讨论全球化部署的问题。

思考题

最后,我要留给你一道思考题。

今天,我们继续探讨了读写冲突的话题,在引入了时间误差后,整个处理过程变得更复杂了,而无论是“读等待”还是“写等待”都会让系统的性能明显下降。说到底是由多个独立时间源造成的,而多个时间源是为了支持全球化部署。那么,今天的问题就是,你觉得在什么情况下,不用“等待”也能达到线性一致性或因果一致性呢?

欢迎你在评论区留言和我一起讨论,我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对时间误差下的读写冲突这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。