本文做者: Eric Fuhtml
本文连接: https://ericfu.me/timestamp-i...java
时间戳(timestamp)是分布式事务中绕不开的重要概念,有意思的是,如今主流的几个分布式数据库对它的实现都不尽相同,甚至是主要区分点之一。git
本文聊一聊时间戳的前世此生,为了把讨论集中在主题上,假设读者已经对数据库的 MVCC、2PC、一致性、隔离级别等概念有个基本的了解。github
自从 MVCC 被发明出来以后,那个时代的几乎全部数据库都抛弃(或部分抛弃)了两阶段锁的并发控制方法,缘由无它——性能太差了。当分布式数据库逐渐兴起时,设计者们几乎都选择 MVCC 做为并发控制方案。面试
MVCC 的全称是多版本并发控制(Multi-Version Concurrency Control),这个名字彷佛暗示咱们必定会有个版本号(时间戳)存在。然而事实上,时间戳还真不是必须的。MySQL 的 ReadView 实现就是基于事务 ID 大小以及活跃事务列表进行可见性判断。spring
事务 ID 在事务开启时分配,体现了事务 begin 的顺序;提交时间戳 commit_ts 在事务提交时分配,体现了事务 commit 的顺序。
分布式数据库 Postgres-XL 也用了一样的方案,只是将这套逻辑放在全局事务管理器(GTM)中,由 GTM 集中式地维护集群中全部事务状态,并为各个事务生成它们的 Snapshot。这种中心化的设计很容易出现性能瓶颈,制约了集群的扩展性。数据库
另外一套方案就是引入时间戳,只要比较数据的写入时间戳(即写入该数据的事务的提交时间戳)和 Snapshot 的读时间戳,便可判断出可见性。在单机数据库中产生时间戳很简单,用原子自增的整数就能以很高的性能分配时间戳。Oracle 用的就是这个方案。并发
MVCC 原理示意:比较 Snapshot 读取时间戳和数据上的写入时间戳,其中最大但不超过读时间戳的版本,即为可见的版本intellij-idea
而在分布式数据库中,最直接的替代方案是引入一个集中式的分配器,称为 TSO(Timestamp Oracle,此 Oracle 非彼 Oracle),由 TSO 提供单调递增的时间戳。TSO 看似仍是个单点,可是考虑到各个节点取时间戳能够批量(一次取 K 个),即使集群的负载很高,对 TSO 也不会形成很大的压力。TiDB 用的就是这套方案。异步
MVCC 和 Snapshot Isolation 有什么区别?前者是侧重于描述数据库的并发控制 实现,后者从隔离级别的角度定义了一种 语义。本文中咱们不区分这两个概念。
可线性化(linearizable)或线性一致性意味着操做的时序和(外部观察者所看到的)物理时间一致,所以有时也称为外部一致性。具体来讲,可线性化假设读写操做都须要执行一段时间,可是在这段时间内必然能找出一个时间点,对应操做真正“发生”的时刻。
线性一致性的解释。其中 (a)、(b) 知足线性一致性,由于如图所示的时间轴即能解释线程 A、B 的行为;(c) 是不容许的,不管如何 A 都应当看到 B 的写入
注意不要把一致性和隔离级别混为一谈,这彻底是不一样维度的概念。理想状况下的数据库应该知足 strict serializability,即隔离级别作到 serializable、一致性作到 linearizabile。本文主要关注一致性。
TSO 时间戳可以提供线性一致性保证。完整的证实超出了本文的范畴,这里只说说直觉的解释:用于判断可见性的 snapshot_ts 和 commit_ts 都是来自于集群中惟一的 TSO,而 TSO 做为一个单点,可以确保时间戳的顺序关系与分配时间戳的物理时序一致。
可线性化是一个极好的特性,用户彻底不用考虑一致性方面的问题,可是代价是必须引入一个中心化的 TSO。咱们后边会看到,想在去中心化的状况下保持可线性化是极为困难的。
Google Spanner 是一个定位于全球部署的数据库。若是用 TSO 方案则须要横跨半个地球拿时间戳,这个延迟可能就奔着秒级去了。可是 Google 的工程师认为 linearizable 是必不可少的,这就有了 TrueTime。
TrueTime 利用原子钟和 GPS 实现了时间戳的去中心化。可是原子钟和 GPS 提供的时间也是有偏差的,在 Spanner 中这个偏差范围 εε 被设定为 7ms。换句话说,若是两个时间戳相差小于 2ε2ε ,咱们就没法肯定它们的物理前后顺序,称之为“不肯定性窗口”。
Spanner 对此的处理方法也很简单——等待不肯定性窗口时间过去。
在事务提交过程当中 Spanner 会作额外的等待,直到知足 TT.now()−Tstart>2εTT.now()−Tstart>2ε,而后才将提交成功返回给客户端。在此以后,不管从哪里发起的读请求必然会拿到一个更大的时间戳,于是必然能读到刚刚的写入。
Lamport 时钟是最简单的逻辑时钟(Logical Clock)实现,它用一个整数表示时间,记录事件的前后/因果关系(causality):若是 A 事件致使了 B 事件,那么 A 的时间戳必定小于 B。
当分布式系统的节点间传递消息时,消息会附带发送者的时间戳,而接收方老是用消息中的时间戳“推高”本地时间戳:Tlocal=max(Tmsg,Tlocal)+1Tlocal=max(Tmsg,Tlocal)+1。
Lamport Clock 只是个从 0 开始增加的整数,为了让它更有意义,咱们能够在它的高位存放物理时间戳、低位存放逻辑时间戳,当物理时间戳增长时逻辑位清零,这就是 HLC(Hybrid Logical Clock)。很显然,从大小关系的角度看,HLC 和 LC 并无什么不一样。
HLC/LC 也能够用在分布式事务中,咱们将时间戳附加到全部事务相关的 RPC 中,也就是 Begin、Prepare 和 Commit 这几个消息中:
HLC/LC 并不知足线性一致性。咱们能够构造出这样的场景,事务 A 和事务 B 发生在不相交的节点上,好比事务 TATA 位于节点 一、事务 TBTB 位于节点 2,那么这种状况下 TATA、TBTB 的时间戳是彼此独立产生的,两者以前没有任何前后关系保证。具体来讲,假设 TATA 物理上先于 TBTB 提交,可是节点 2 上发起的 TBTB 的 snapshot_ts 可能滞后(偏小),所以没法读到 TATA 写入的数据。
T1: w(C1) T1: commit T2: r(C2) (not visible! assuming T2.snapshot_ts < T1.commit_ts)
HLC/LC 知足因果一致性(Causal Consistency)或 Session 一致性,然而对于数据库来讲这并不足以知足用户需求。想象一个场景:应用程序中使用了链接池,它有可能先用 Session A 提交事务 TATA(用户注册),再用 Session B 进行事务 TBTB(下订单),可是 TBTB 却查不到下单用户的记录。
若是链接池的例子不能说服你,能够想象一下:微服务节点 A 负责用户注册,以后它向微服务节点 B 发送消息,通知节点 B 进行下订单,此时 B 却查不到这条用户的记录。根本问题在于应用没法感知数据库的时间戳,若是应用也能向数据库同样在 RPC 调用时传递时间戳,或许因果一致性就够用了。
上个小节中介绍的 HLC 物理时间戳部分仅供观赏,并无发挥实质性的做用。CockroachDB 创造性地引入了 NTP 对时协议。NTP 的精度固然远远不如原子钟,偏差大约在 100ms 到 250ms 之间,如此大的偏差下若是再套用 TrueTime 的作法,事务延迟会高到没法接受。
CockroachDB 要求全部数据库节点间的时钟偏移不能超过 250ms,后台线程会不断探测节点间的时钟偏移量,一旦超过阈值当即自杀。经过这种方式,节点间的时钟偏移量被限制在一个有限的范围内,即所谓的半同步时钟(semi-synchronized clocks)。
下面是最关键的部分:进行 Snapshot Read 的过程当中,一旦遇到 commit_ts 位于不肯定性窗口 [snapshot_ts, snapshot_ts + max_clock_shift]
内的数据,则意味着没法肯定这条记录究竟是否可见,这时将会重启整个事务(并等待 max_clock_shift 过去),取一个新的 snapshot_ts 进行读取。
有了这套额外的机制,上一节中的“写后读”场景下,能够保证读事务 TBTB 必定能读到 TATA 的写入。具体来讲,因为 TATA 提交先于 TBTB 发起,TATA 的写入时间戳必定小于 B.snapshot_ts + max_clock_shift,所以要么读到可见的结果(A.commit_ts < B.snapshot_ts),要么事务重启、用新的时间戳读到可见的结果。
那么,CockroachDB 是否知足可线性化呢?答案是否认的。Jepsen 的一篇测试报告中提到如下这个“双写”场景(其中,数据 C一、C2 位于不一样节点上):
T3: r(C1) (not found) T1: w(C1) T1: commit T2: w(C2) T2: commit (assuming T2.commit_ts < T3.snapshot_ts due to clock shift) T3: r(C2) (found) T3: commit
虽然 T1 先于 T2 写入,可是 T3 却看到了 T2 而没有看到 T1,此时事务的表现等价于这样的串行执行序列:T2 -> T3 -> T1(所以符合可串行化),与物理顺序 T1 -> T2 不一样,违反了可线性化。归根结底是由于 T一、T2 两个事务的时间戳由各自的节点独立产生,没法保证前后关系,而 Read Restart 机制只能防止数据存在的状况,对于这种尚不存在的数据(C1)就无能为力了。
Jepsen 对此总结为:CockroachDB 仅对单行事务保证可线性化,对于涉及多行的事务则没法保证。这样的一致性级别是否能知足业务须要呢?这个问题就留给读者判断吧。
最近看到 TiDB 的 Async Commit 设计文档 引发了个人兴趣。Async Commit 的设计动机是为了下降提交延迟,在 TiDB 本来的 Percolator 2PC 实现中,须要通过如下 4 个步骤:
为了下降提交延迟,咱们但愿将第 3 步也异步化。可是第 2 步中获取的 commit_ts 须要由第 3 步来保证持久化,不然一旦协调者在 二、3 步之间宕机,事务恢复时就不知道用什么 commit_ts 继续提交(roll forward)。为了避开这个麻烦的问题,设计文档对 TSO 时间戳模型的事务提交部分作了修改,引入 HLC 的提交方法:
Prewrite
Finalize
(异步):计算 commit_ts = max{ min_commit_ts },用该时间戳进行提交
上述流程和 HLC 提交流程基本是同样的。注意,事务开始时仍然是从 TSO 获取 snapshot_ts,这一点保持原状。
咱们尝试代入上一节的“双写”场景发现:因为依赖 TSO 提供的 snapshot_ts,T一、T2 的时间戳依然能保证正确的前后关系,可是只要稍做修改,便可构造出失败场景(这里假设 snapshot_ts 在事务 begin 时获取):
T1: begin T2: begin T3: begin (concurrently) T1: w(C1) T1: commit (assuming commit_ts = 105) T2: w(C2) T2: commit (assuming commit_ts = 103) T3: r(C1) (not found) T3: r(C2) (found) T3: commit
虽然 T1 先于 T2 写入,但 T2 的提交时间戳却小于 T1,因而,并发的读事务 T3 看到了 T2 而没有看到 T1,违反了可线性化。根本缘由和 CockroachDB 同样:T一、T2 两个事务的提交时间戳由各自节点计算得出,没法确保前后关系。
上个小节给出的 Async Commit 方案破坏了本来 TSO 时间戳的线性一致性(虽然仅仅是个很是边缘的场景)。这里特别感谢 @Zhifeng Hu 的提醒,在 #8589 中给出了一个巧妙的解决方案:引入 prewrite_ts 时间戳,便可让并发事务的 commit_ts 从新变得有序。完整流程以下,注意 Prewrite 的第 一、2 步:
Prewrite
Finalize
(异步):计算 commit_ts = max{ min_commit_ts },用该时间戳进行提交
对应到上面的用例中,如今 T一、T2 两个事务的提交时间戳再也不是独立计算,依靠 TSO 提供的 prewrite_ts 能够构建出 T一、T2 的正确顺序:T2.commit_ts >= T2.prewrite_ts > T1.commit_ts,从而避免了上述异常。
更进一步,该方案可以知足线性一致性。这里只给一个直觉的解释:咱们将 TSO 看做是外部物理时间,依靠 prewrite_ts 能够保证 commit_ts 的取值位于 commit 请求开始以后,而经过本地 max_ts 计算出的 commit_ts 必定在 commit 请求结束以前,故 commit_ts 取值落在执行提交请求的时间范围内,知足线性一致性。
另外,关注公众号Java技术栈,在后台回复:面试,能够获取我整理的 Java/ 分布式系列面试题和答案,很是齐全。
近期热文推荐:
1.600+ 道 Java面试题及答案整理(2021最新版)
2.终于靠开源项目弄到 IntelliJ IDEA 激活码了,真香!
3.阿里 Mock 工具正式开源,干掉市面上全部 Mock 工具!
4.Spring Cloud 2020.0.0 正式发布,全新颠覆性版本!
以为不错,别忘了随手点赞+转发哦!