【独立思考】分布式事务相比单机事务,有何难点?应该怎样解决呢?

为了让下面的阅读不那么枯燥,先抛出几个问题大家可以思考一下:

  1. 分布式事务相比单机事务,有何难点?如何解决?

  2. 为什么在同一个 Spanner 事务中,提交之前完全读不到自己的写入?

  3. 什么是外部一致性?

  4. Spanner 事务有哪些类型?分别适用于什么场景?

  5. Spanner 读取是加锁还是 MVCC?

  6. Spanner 每个 Paxos Group 的每秒事务吞吐量是否有限制?

分布式事务原理

要理解一个特定技术,就先理解这个技术所基于的通用原理,所以这里不马上开始讲 Spanner 的事务实现,而是先讲分布式事务的原理。

1. 分布式事务所面临的问题

在事务的 ACID 中,对于单机数据库,A(原子性)、C(一致性)是很容易被满足的,基于 undo log 满足 A,基于 redo log、加锁满足 C,但是对于分布式数据库却比较困难,不止是由于从单机到多机,复杂性的提高,还因为中间有 “网络” 这个不稳定因素。

挑战 1:原子性

事务需要满足 A:要么全部失败,要么全部成功,对于单机数据库,只要将事务写入日志,就能够保证此事务可以全部回滚或者可以全部提交,而分布式事务是多机事务,每个机器的情况不同,有的机器可能写日志成功,有的机器可能写日志失败(比如因为获取锁失败),也就是会出现部分成功部分失败的情况,因此分布式数据库该如何保证原子性,是一个挑战

挑战 2:事务顺序

后开始的事务要能够读到在它开始前已经结束的事务的写,为了正确的 MVCC,事务需要有单调递增(Monotonically Increasing)的时间戳或者事务序号保证逻辑顺序。

在单机数据库要保证单调递增不是个问题,但是分布式数据库多点的,要如何保证为冲突事务分配的序号是单调递增就是个问题。

挑战 3:不稳定的网络

单机的通讯是稳定的,一个操作无论失败还是成功,调用者都知道自己能够在未来的某个点得到确定的回复,而分布式数据库则只能通过网络通讯,网络是不稳定的,可能对方收不到你的指令,或者你收不到对方的回复,该怎么保证分布式事务的可用性,也就是在用户能够容忍的时间内得到确定的答复。

2. 分布式事务解决思路

二阶段提交(2PC)

对于上面说的原子性与不稳定的网络这两个挑战,目前分布式事务比较通用的解决方案是二阶段提交(Tow-Phase Commit),缩写为 2PC,Spanner、TiDB 都使用二阶段提交,它将提交分为准备和最终提交两个阶段,注意这里说的 “最终提交” 和“提交”的区别,为了避免混淆,“提交”还是原本的语义,代表着 client 发出的 commit,而 “最终提交” 是指 2PC 中的提交阶段,是被 DB 发起的,不是被 client。

分布式事务往往意味着有 2 个或以上的 DB 参与,2PC 中会有不同的角色分工,其中有一台机器是协调者(Coordinator),可以理解为Team Leader,其余的都是参与者(Participant)

第一阶段:准备(prepare)

当 client 发起提交后,开始进入 2PC 的第一阶段——准备,也叫投票(Vote),协调者通知参与者开始准备,每个参与者都在本机执行事务,注意,这里只是执行,不是提交,执行意味着可能只是获取锁、写日志,而不是 apply 这些变更(mutation),参与者负责确认属于自己的写入能否成功,如果可以,则需要向协调者返回 yes,如果无法在本机成功执行事务,则返回 no。

【独立思考】分布式事务相比单机事务,有何难点?应该怎样解决呢?_第1张图片 准备(投票)阶段

在协调者发出 prepare 的指令后,会根据参与者的情况作出提交(commit)还是中止(abort)的决定,有两个因素决定最后的选择:

  1. 等待期

  2. 参与者的回复

关于等待期,由于网络的不确定性,协调者发出的指令可能不能被每个参与者都收到,或者参与者发出的回复不能被协调者收到,并且这个不稳定性是一直存在的,不能确定在 client 可接受的时间内一定能得到答复,超出 client 可接受的时间是不满足可用性(CAP 中的 A)的,因此协调者不能一直等下去,需要设置一个等待期,超出等待期还未得到所有参与者的回复,就直接认为未回复的参与者是 say no 而不是 yes,协调者会直接作出 abort 的决定。

关于第二点——参与者的回复,如果协调者在等待期内如果成功收到所有参与者的答复,则开始下一步判断,如果全部 say yes,也就是回答 “可以提交”,并且协调者自己也处于可提交状态,那么就会作出 commit 决定,如果有任何一个参与者返回了无法提交,或者协调者自己无法提交,那么协调者会作出 abort 决定。

第二阶段:最终提交(final commit)

协调者作出决定后进入第二阶段——最终提交,注意这个 “最终提交”,并不是说它一定会 commit,而只是说这是一个最终执行决定的阶段,最终提交阶段可能有两种结果:commit 与 abort,也就是提交或者中止,提交会 apply 所有变更,中止则仅仅释放锁,不 apply 变更,就像事务没有发生。

对于参与者来说,当它对第一阶段作出了 yes or no 的响应后,它就已经进入了第二阶段,等待协调者的决定,并执行决定。这个阶段可以是协调者主动将决定发送给参与者,也可以是参与者主动轮询,2PC 是一种算法,具体实现不同。

协调者作出决定后进入第二阶段,它会将决定写入日志,写入成功后,提交自己的事务,然后向 client 返回成功。注意这里,协调者无需等待参与者成功,可以直接向 client 返回,参与者通过接收协调者指令或者自己轮询获得协调者的决定后,异步提交即可。

为什么可以允许参与者异步提交?

很简单因为每个参与者都已经成功执行了事务写入了日志,一旦日志落盘,就代表事务已经保证 100% 可以成功,那么协调者的决定只是将事务的变更正式 apply 并释放锁,释放锁与 apply 都可以是异步,不影响这个事务能够 “成功” 的保证,因此可以放心地返回给 client。

2PC 主要的思想就是先确定大家都能成功,才作出提交决定,如果有任意参与者可能无法成功,就作出中止决定。

Spanner 事务实现

Spanner 事务模型是 2PC 的实现,所以在看下去之前,务必对 2PC 有一定的理解。

1. 外部一致性(External Consistency)

我们先由远及近地看 Spanner,从 Spanner 的表现到 Spanner 的实现。**外部一致性(External Consistency)**是 Google 提出来的概念,这里所说的外部可以理解为 client,也就是对于 client 来说,它观察到的两个事务的顺序一定与它们的提交顺序相同,因此如果事务 1 在事务 2 之前 commit,那么事务 2 一定能够读取到事务 1 的变更。如下图:

【独立思考】分布式事务相比单机事务,有何难点?应该怎样解决呢?_第2张图片 外部一致性

虽然事务 2 在事务 1 提交前就已开始,但是它的 commit 更晚,一定能够读到事务 1 的变更。

外部一致性只是基于 Spanner 事务模型最后的结果,是表现,而不是原因,因此这里只需要知道 Spanner 事务最后是这样的表现即可,为的是让大家自顶向下带着问题去反向思考如何实现这个效果。

2. TrueTime

分布式事务一定需要解决上面说到的 3 个挑战,这里先来说说挑战 2——事务顺序,在分布式中如何保证事务序号是递增的呢,很容易想到授权一个单点去分配事务序号,每个事务都从这个单点拿序号,在 Google Percolator 这个事务模型中,就有一个全局的授时服务器(TSO),所有事务都从 TSO 获取一个时间戳作为自己的序号,因此它一定是单调递增的,当然为了保证可用性,这个单点本身也可以是一个集群,比如 TiDB 就将 PD 集群作为 TSO。

但是 Spanner 并不使用全局发号器的方式,因为他有一个黑科技——TrueTime,这是靠硬件实现的,每个数据中心都有一些 time master,大多数 master 都配置了 GPS,剩下的少数 master 配置了原子钟,通过一定的算法与校准,它们的时间与真实的绝对时间几乎完全相同,所有 DB 会每 30 秒去向这些服务器校准一次自己的时间,以保证自己的时间也与真实绝对时间几乎完全相同,这就保证了每台 DB server 的时间与真实绝对时间的误差都在几毫秒内,而传统 NTP 校准的误差则在几百毫秒,因此 TrueTime 保证了 DB server 都几乎接近于准确时间,因此完全不需要全局发号器,因为每台 server 的时间都几乎相同。

TrueTime 是一套 API,调用 TT.now() 则会返回一个 [earliest, latest],表示此时真实的绝对时间最早是什么时候,最晚是什么时候,时间一定落在这个区间内。

【独立思考】分布式事务相比单机事务,有何难点?应该怎样解决呢?_第3张图片 TrueTime

3. 事务分类

Spanner 将事务分为两类读写事务(Read Write Transaction)与只读事务(Read Only Transaction)。

只读事务又分为

  • Snapshot Transaction(强读)

  • Client-chosen Timestap Read(client 指定过期的某个 timestamp 去读取)

  • Client-chosen Bound Read(client 指定一个不能超过的最早 timestamp 去读取)

只读事务都是基于 Snapshot 的 Transaction,它们都是无锁(lock-free)的。

读写事务中无论读写都会上锁,基于悲观锁。

【独立思考】分布式事务相比单机事务,有何难点?应该怎样解决呢?_第4张图片 事务类型与实现

3. 读写事务(Read Write Transaction)

Spanner 并不是所有事务都使用 2PC,针对 Single Split 事务,可以退化为单机事务,使用 1PC,在效率上是一种优化,因此对于 Single Split 与 Multiple Split 事务,Spanner 分别采用了 1PC 与 2PC,下面我们分别来讨论。

Single Split Transaction(1PC)

1PC 与 2PC 本质上并没有区别,都将提交分为了两步走(这里不说两段,只说两步,是为了与二阶段提交中的阶段区分开),第一步是获取锁、写日志,第二步是 apply 变更、释放锁,唯一的区别是 2PC 在第一步与第二步之间有一个复杂的决定与通知的过程,要保证所有 Split 都做出相同的响应:提交或中止。而 1PC 只是少了中间这个复杂的决定与通知的过程,其他几乎没有区别。

对于 Single Split Transaction,其实与一般的单机数据库的事务已经很相似,不过还有一个明显的不同点,那就是 Spanner 事务模型中,每次 Write 不是在 client API 那边执行了 SQL 就被发送到 DB server 的,而是全部被缓存在本地,这与 MySQL 等单机数据库完全不同,对于 MySQL,每执行一条 Write SQL,都会马上发送到 server 并且尝试加锁、写日志,而 Spanner client 会缓存在本地,只有当 client 调用 commit 方法时,才会一次性将所有 Writes 发送到 Spanner。由于这种缓存机制,在事务被commit前,都不能读到它自己的任何Writes

【独立思考】分布式事务相比单机事务,有何难点?应该怎样解决呢?_第5张图片 Writes 将被缓存,在 client commit 时发送给 Spanner

来看一下 Spanner 的 1PC 流程:

  1. Client 读数据,立即将读取请求发送到 Spanner

  2. Spanner 收到读取请求,为数据加读锁,加锁时如果发现此数据已经被上了写锁,则不能立即上锁,需要等待上了写锁的事务提交后才能继续上锁

  3. Client 写数据,此时只写到本地 buffer

  4. Client 完成读写,也就是在 SDK 中调用 commit 时,会由 client 将 buffer 中的所有 Writes 发到 Spanner

  5. Spanner 开始获取写锁、为此次事务生成一个 commit timestamp 并写日志、写数据

  6. 写锁获取完成并写完日志后,再次检查读锁是否还存在(因为可能已经被伤停等待机制给 abort 掉了),如果不存在,此事务应该 abort

  7. Leader replica(读写事务只能发生在 Leader)将 log 复制给其他 replica,同时进行 commit-wait,因为 TrueTime 是一段时间区间,是有误差的,为了保证外部一致性,也就是它提交后,对所有服务器,这个事务都是过去时了,才能提交,commit-wait 的实际开销并不大,因为在 commit-wait 的同时也在进行 log 的复制,其中空转的时间是非常少的,当大多数 replica 都复制成功后,并且 commit-wait 结束,可以向 client 返回事务已经成功 commit,同时 apply 事务变更到状态机并释放锁。

伤停等待(Wound-Wait)

Spanner 是不可能出现死锁的,得益于伤停等待机制。

在获取锁的过程中可能会遇到有的记录已经被加上锁,如果被持有的是写锁,则需要等待持有锁的事务 commit 或 abort,如果被持有的是读锁,则使用伤停等待(wound-wait),首先Spanner会进行死锁检测,如果这里不存在死锁,那么事务等待这个读锁释放,如果存在死锁,那么判断目前持有读锁的事务是比自己年轻还是年老,如果比自己年轻,则直接abort这个读锁,如果比自己年老,则需要等待年老事务主动释放锁。伤停等待完全避免了死锁,同时也避免了饿死现象。

Multiple Split Transaction(2PC)

Multiple Split Transaction 使用 2PC,但是和 1PC 差别并不大,主要是由于单 Paxos Group 的事务变成了多 Paxos Group 事务,需要增加协调者,会增加以下步骤:

  1. client 会负责发起 2PC,在发送 Writes 到 Spanner 时,client 会先查找到自己应该分别把 Writes 发送到哪些服务器,将 Writes 分别发送给他们,并且 client 会从这些 Leader Replica 中选一个作为 Coordinator(读写事务只能发生在 Leader,因此这里不讨论其他 replica)。

  2. 在每个 Participant 成功获取所有写锁后,会生成一个 prepare timestamp,然后通过 Paxos 将 log(其中包含了锁),复制到大多数副本,然后将 prepare timestamp 发送给 Coordinator,表示自己 ok

  3. Coordinator 收到所有 Participant 的 prepare timestamp 后,会生成一个 commit timestamp,这个 timestamp 一定会大于所有 prepare timestamp,并大于 TT.now().latest

  4. Coordinator 会在 commit-wait 结束之后才将 commit 指令发给 Participant

commit-wait 局限性

因为选择 commit timestamp S 时必须保证 S 大于 TT.now().latest,然后真正的提交时间又需要大于 S.latest,如果 TrueTime 的误差为 ε ,因此这里进行了 2ε 的 wait,TrueTime 的平均误差是 4ms,因此一个Paxos Group每秒吞吐量是 1000 / 2*4 = 125 事务/s,TrueTime误差越小,吞吐量越高

这里也引发一个思考,由于吞吐量被 commit-wait 限制,如果不恰当的表设计导致热点(Hotspot),那么大量的读写事务会到达同一个 Split,因此会导致每秒吞吐量很低,对于较多读写事务的表,应该特别注意避免热点

4. 只读事务(Read Only Transaction)

只读事务都基于时间戳,也就是基于 MVCC,因此它们都是无锁的(lock-free),其中强读(Strong Read)不需要 client 提供 timestamp,因此 Spanner 是需要在 Replica Leader 先获取目前最新数据的 timestamp,然后在任何 replica 读取。除强读之外,其他都可以称作过时读取(Stale Read),都由 client 指定了想要读取的 timestamp 或者可以容忍的最早 timestamp,因此都不用先经过 Leader 获取 timestamp,可以在任意 replica 完成。

不过以上 3 种读取,在完成读取前,replica 会先检查自己是否有足够新的数据能够满足读取,如果不够,会向 Leader 同步。

并不是整个事务周期中只有读取就算是只读事务,而是需要client事先声明开启的这个事务是只读的,否则会被当做读写事务执行。例外情况是,执行不被显式放进事务的单条读取,不需要显式声明,也会被当作只读事务对待。

5. 盲写(Blind Write)

当一个读写事务中没有任何读取,只有写入时,称为盲写,Spanner 对于盲写有优化,它不为盲写加排他(exclusive)写锁,加的是共享写锁(share write),共享写锁完全兼容,它们按照 commit timestamp 排序执行,共享写锁也与读锁兼容,因此不会锁住其他事务的读取,提高并发。

【独立思考】分布式事务相比单机事务,有何难点?应该怎样解决呢?_第6张图片 兼容矩阵

由于 Spanner 的公开资料较少,并且受限于本人的知识水平,文中可能有不准确之处,欢迎交流。

作者:AmazingBillions

来源链接:

https://www.jianshu.com/p/45d10cee1773

关注我!Java从此不迷路!

你可能感兴趣的:(分布式,数据库,java,zookeeper,opensource)