This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,276 @@
<audio id="audio" title="12 | 正确性分级(上):单机无备份有哪几种不同的一致性?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/24/07/24689b79d6da9a92f8cfd0b12f797507.mp3"></audio>
你好,我是任杰。从今天开始,我们进入到最后一个模块:分布式正确性及高可用。
在前面两个模块里,我们一起学习了金融业务,以及如何实现正确的金融系统架构。不过我们前面所讲的正确性,主要侧重点是金融业务实现的正确性。但是这些正确性还远远不够,你原来正确的代码运行在多线程或者分布式环境下,依然有可能出错。
所以,这一个模块我们主要探讨的内容是如何保证与业务无关的正确性。按照从简单到复杂的顺序,我们先来看看单机情况下会出现哪些不正确的情况。
## 冲突
如果一个东西永远都不会变,那么我们在使用它的时候一定不会出错,这就是我们在[第8节课](https://time.geekbang.org/column/article/330288)说过的函数式编程优势。
可惜现实并不完美,程序的状态还是会被修改。如果多个人在没有良好沟通的情况下独自修改状态,这样就可能会出错,而这种错误就叫冲突。
当然了,这么定义太过于模糊,我们还需要对冲突做准确的定义。为了方便后面做分析,我们先对要研究的问题做一些假设。
我们假设机器上存在着一些共享资源,用 `x``y``z` 表示,对这些资源有读和写两种操作,用 `R``W` 来表示。对资源的读操作不会改变资源的状态,但是写操作会改变。
有很多人会对这些资源做读或者写的操作。每个人的操作叫作事务,我们用 `T`来表示。所以一个事务`T`里会有一系列读写操作。下面是一个时序图,展示了两个事务随着时间推移的情况:
<img src="https://static001.geekbang.org/resource/image/19/c7/19829eeb7bbaef447519a3de4ceyyac7.jpg" alt="">
需要注意的是,事务在运行到一半或者结束后,可以选择保留所有操作的影响,或者取消所有操作的影响,对应的术语是事务的提交和回滚。由于提交和回滚不会影响我们接下来的分析,这节课里我们会省略这一步。
定义好了资源、操作和事务之后,我们再来看看前面说的冲突。
如果两个操作对应的是不同的对象,那么这两个操作不会有任何冲突。所以两个操作**如果要有冲突,一定是它们操作了同一个对象。**另外,如果两个操作都是读操作,也不会出现任何冲突。
所以我们可以这样理解冲突,**两个操作如果冲突,一定有一个操作是写操作**;发生冲突的时候,一定是一个操作先发生,另一个后发生(**我们称后面的操作依赖于前面的操作**)。
我们可以用排列组合算一算。有两个操作每个操作都有读和写两种情况一共就有4种情况。现在排除两个操作都是读的情况就**只剩下了3个情况分别是读写、写读和写写**这三种。
下面这幅图给你展示了这3种冲突类型和操作之间的操作关系
<img src="https://static001.geekbang.org/resource/image/0a/4a/0a1a4781a981f5a528e2a1bbecf5a04a.jpg" alt="">
## 隔离级别分类
现在很多数据系统在加上了一定的正确性保障之后,会宣称自己支持了事务。你也许会觉得这个事务,就是平时在使用关系型数据库时最高的正确性保障。其实不是这样,事务也分很多种不同的正确性级别,就算是同一个名字,也有可能意味着不同的东西,你一定要多加小心。
我们接下来就一起看看都有哪些正确性的级别。
当我们定义好冲突之后就可以看看都有哪些不正确了。在数据库理论里对不正确的分级叫作**隔离级别**Isolation Level
有关隔离级别的经典论文是1995年发表的"A Critique of ANSI SQL Isolation Levels"。这篇论文抨击了当时SQL标准的事务指出了这个标准不够完备又提出了隐含的假设。论文对隔离级别做了详细的定义和梳理你有兴趣可以仔细去研究。
这里我们主要了解一些常用的隔离级别重点是最后的可串行化和MVCC。
最低的隔离级别 **Read Uncommitted** 解决了**脏写**Dirty Write的问题。脏写指的是两个事务写了同一份资源这样后写的事务会覆盖先写的内容。其实**脏写就是上面提到的写写冲突**,示意图如下:<br>
<img src="https://static001.geekbang.org/resource/image/8f/9a/8f5b4a2d62ee40e2445a90d4953bd39a.jpg" alt="">
稍微高一级的隔离级别叫 **Read Committed**。这个隔离级别除了解决脏写问题以外,还解决了**脏读**Dirty Read问题。
脏读指的是当一个未结束的事务写了一个值之后,另一个事务读取了这个值。一旦前面的事务通过回滚取消了自己的所有操作,那么后面的事务就会读取到一个不应该存在的值,也就是读了一份脏数据。
其实**脏读就是我们在上面提到的写读冲突。**示意图如下:
<img src="https://static001.geekbang.org/resource/image/ed/b9/edd3801884384eca49bec771c529c7b9.jpg" alt="">
再高一级的隔离级别是 **Repeatable Read**。它相对于前一个级别也多解决了一个模糊读Fuzzy Read的问题**其实就是前面提到的读写冲突。**
读写冲突和写读冲突刚好相反。读写冲突发生的时候需要负责写的事务提交,而写读冲突需要写的事务回滚。那为什么要叫这个名字呢?
原因是读的事务如果再读一次的话,会将另一个事务写入的值读回来,因此前后两次读到的结果会不一致。示意图如下:
<img src="https://static001.geekbang.org/resource/image/29/0c/293d945bfd380d7eyy744ab9e237270c.jpg" alt="">
隔离级别最高的是**可串行化**Serializability。它解决了两个事务之间的所有冲突。我会在后面详细讲解可串行化。
在这里我把所有介绍过的隔离级别的层级关系画出来,结果就是下面这幅图:
<img src="https://static001.geekbang.org/resource/image/d3/50/d39014de7e14164380c7bae09096f650.jpg" alt="">
## 可串行化Serializability
事务可串行化基本上能解决所有的冲突。因此在多个事务在同时操作数据库的时候,我们都会要求事务具有可串行化的属性,这样就能避免出现错误的结果。那到底要怎么理解可串行化呢?
### 如何理解可串行化?
你如果用过关系型数据库的话应该知道多个事务是可以并发执行的这样数据库就能有效利用CPU和存储设备。
可串行化规定了这些同时在运行的事务的结果,**它要求这些并发执行的事务的最终结果永远等同于它们某个顺序执行的结果。**
这个定义比较拗口,让我们来逐一分解。首先,我们看看什么叫“顺序执行”。比如下面的图上有两个事务,这两个事务交互地读写 `x``y`
<img src="https://static001.geekbang.org/resource/image/b3/f9/b377dd9df6b5f2944b3456182a4a39f9.jpg" alt="">
这时候我可以调整这两个事务的读写操作,把第一个事务里所有的操作都放到第二个事务的前面,就像下面这幅图展示的一样:
<img src="https://static001.geekbang.org/resource/image/c0/72/c04bff0d21yy8f7a7cc861f39ea26472.jpg" alt="">
当我们调整了这两个事务的操作之后,第一个事务所有的操作会在第二个事务开始前全部结束,**这两个事务在时间上没有任何的重合。这时候这两个事务就是顺序执行。**
那什么叫“永远等于”呢?你可以再看看上面这幅图,就会发现我们调整了事务的执行顺序之后,最后的读写结果和调整前完全一样。这就是**“等于”的定义,我们的调整不改变结果**。“永远”意味着对于**任何** `x``y` 的初始值,调整之后的结果都相等。
注意,我们在可串行化中还有一个关键的定语是“某个”。这意味着我们只要找到一个等价的顺序执行结果就可以,这个结果不一定唯一。这也说明可串行化也具有一定的随机性,我在[第14节课](https://time.geekbang.org/column/article/336686)里会说到**严格可串行化**Strict Serializability的隔离级别它可以消除这种不确定性。
那为什么可串行化这么重要呢?这是因为一旦多个事务可以被串行化,我们就可以当作这些事务是一个一个分开执行,每个事务会成为一个原子的单元。
在没有其他事务的干扰下,我们很容易就能知道每个事务执行的结果是不是正确的。所以**可串行化相当于把一个大的正确性问题,分解成了以事务为单位的小正确性问题,通过分而治之的办法来降低正确性成本**。
### 如何理解冲突可串行化?
除了前面说的可串行化,我们通常使用的关系型数据库用的是另一种叫作**冲突可串行化**Conflict Serializability的调度方案。
这里的“冲突”就是我们开始提到的读写、写读和写写这3种冲突。冲突可串行化依然要求等价于某个事务串行化的结果。
但是它和可串行化不一样,可串行化只需要你找到一个等价的串行结果就行,而冲突可串行化要求你通过一系列无冲突的互换过程将原来的执行序列变为等价的串行执行。
**如果两个操作之间没有冲突,你可以互换他们的顺序,也叫无冲突互换过程。**所以一共有两种情况。一种是两个操作的对象不一样,这样不管是读写都不会有冲突,你可以随便调整。另一种情况是两个操作都是读操作。
下面这幅图展示了我们怎么通过调整顺序来证明冲突可串行化。还是之前的例子我们看看这两个事务的中间两个操作。这两个操作的对象不一样所以没有冲突我们可以调整它们俩的顺序。一共调整4步我们就可以从最开始的情况调整为最后的可串行化结果。
<img src="https://static001.geekbang.org/resource/image/f1/79/f1f3c1b70a7ec94e86afbdf031960979.jpg" alt="">
### 冲突可串行化的局限
那我们怎么来理解可串行化和冲突可串行化的关系呢?准确来说,冲突可串行化是可串行化的充分条件:如果一个事务是冲突可串行化,那么它一定是可串行化。反过来,如果一个事务是可串行化,那它可能不是冲突可串行化。
所以冲突可串行化的集合是可串行化的子集,就像下面这幅图展示的一样:
<img src="https://static001.geekbang.org/resource/image/55/f5/55b7e18yyc0d60c9d543109ae350a8f5.jpg" alt="">
我们还是来举个例子。系统中有3个事务它们的操作之间的时间关系如下图
<img src="https://static001.geekbang.org/resource/image/2d/2a/2da21f5f2a3d15131b0ccb6597833b2a.jpg" alt="">
你如果仔细分析一下就会发现上面这3个事务是可以被串行化的就像下面这幅图展示的一样
<img src="https://static001.geekbang.org/resource/image/54/dc/54da123a2a0447cd1578f71563d769dc.jpg" alt="">
但是你无法通过无冲突互换的过程将这3个事务的执行顺序变为串行化的结果。原因是中间两个对 `x` 的写操作导致 `T1``T2` 无法调整为串行执行,就像下面这幅图解释的一样:
<img src="https://static001.geekbang.org/resource/image/6f/8e/6fa013948f53bfc59e784b31e060fb8e.jpg" alt="">
### 如何通过2PL解决冲突可串行化
虽然我们有两种不同的串行化的定义,其实我们真正需要的是可串行化,而不是冲突可串行化。那为什么还要提冲突可串行化呢?
这是因为在数据库的实现过程中,一般会通过锁的方式调整事务执行顺序,而用**锁的方式一般能实现冲突可串行化。**
**锁Lock在这里是排它锁**,表示一旦你锁住了某个共享资源,其他人都无法访问,直到你释放这个锁。
**通过锁来实现可序列化的方式叫作2PLTwo Phase Lock。**请注意2PL和2PCTwo Phase Commit不一样什么是2PC我们[下节课](https://time.geekbang.org/column/article/335994)再讲。
2PL的过程很简单它要求**对于任何一个事务,这个事务会先对所有访问的资源加锁,然后再访问所有资源,最后再释放所有的锁,加锁和解锁的过程不能有交替。**
我们用 `L` 来表示加锁, `U` 来表示解锁。下面这张图展示了一个事务在2PL的情况下的加锁解锁过程
<img src="https://static001.geekbang.org/resource/image/e8/26/e8ae56e3edf44deda0dcyy92efa6b026.jpg" alt="">
为什么2PL可以实现冲突可串行化呢接下来我们一起证明一下。证明的过程其实就是冲突可串行化的无冲突操作互换过程。
假设系统中有很多事务都遵循着2PL在运行。我们选择所有这些事务中最早释放第一个锁的事务。下面我们来证明通过无冲突操作的互换过程可以将这个事务的所有操作放在最早执行。
假设所有事务中第一个释放锁的是第一个事务 `T1` ,释放的锁是 `z` 。和它冲突的是第二个事务,冲突在 `x` 。那有没有可能出现下面这幅图的情况呢?
<img src="https://static001.geekbang.org/resource/image/12/c5/127c768a1698ffab4fe6975e80e386c5.jpg" alt="">
答案是不可能。因为按照2PL的要求每个事务在访问 `x` 之前,都需要对 `x` 上锁。由于锁之间是互斥的,后面的事务想要成功对`x` 加锁,需要前面的事务先释放锁。
所以,按照上面的时序图, `T1` 需要先释放对 `x` 的锁,然后 `T2``x` 加锁,像下面这幅图展示的一样:
<img src="https://static001.geekbang.org/resource/image/7d/4c/7dfe78a075a30a9cbd931afa998d1b4c.jpg" alt="">
这样我们就推断出 `T1` 需要先释放对 `x` 的锁 ,然后释放对 `z` 的锁,这与我们最开始假设所有事务最早释放的是 `z` 的锁矛盾。
所以,在第一个事务释放第一个锁之前,它和其他所有的事务的所有操作都没有冲突,因此可以通过无冲突操作互换的过程,将第一个事务的所有操作提前到其他事务之前。这样,我们就可以把第一个事务和剩下的事务独立开来。
接下来,我们可以用同样的操作把剩下的事务一个一个向前调整,最终把所有事务分解为串行化的执行过程。
在证明过程中我们利用了操作之间的冲突,以及用锁来解决冲突,因此最后的串行化结果其实是冲突可串行化。
### 2PL局限性
2PL是一个理论上很美好的结论但是在实践过程中用得不多。
这是因为2PL要求我们提前知道所有访问的数据都有哪些这样才能在解锁前锁住所有内容。我们**一旦释放了任何一个锁,就不能再新增其他锁住的资源**这就是2PL的局限性。
幸运的是我们还有另一种隔离级别。它比冲突可串行化弱一些但是实现起来特别方便运行速度也比较快它就是快照隔离Snapshot Isolation
## 如何理解快照隔离Snapshot Isolation
快照隔离的核心思路是**在一个事务开始的时候给当前所有正确的数据打一个快照**Snapshot
这个快照一旦生成就不会改变,所以事务在运行的时候不会被其他事务干扰,也不会出现因加锁导致的等待,就会运行得很快。所以快照隔离的优点是,不同事务之间的读写互不干扰。
快照隔离的优势在于,**它放弃了一些可序列化的能力来换取事务执行的速度,同时不同事务之间的读写无冲突,比较适合需要运行时间特别长的事务。**
因为快照隔离有很多优势,所以它是现在很多数据系统默认支持的隔离级别。当你听到一个新的数据系统支持了事务,首先要想到它支持的是不是快照隔离这个隔离级别。
这里需要提醒你的是对正确数据的定义。快照不能包含当前所有数据,因为有可能有进行到一半的事务已经修改了一部分数据。所以,我们需要把所有还没有完成的事务所对应的数据都排除在快照之外。
说完核心思路之后,我们还是要讲一下实现。因为理解了实现。你才能更深入地理解快照隔离究竟做了什么。
另外,在一些情况下,你也可能需要自己给一些数据系统加上事务能力,这时候快照隔离就是默认选项。
### MVCC
快照隔离一般用 **MVCC**Multi-Version Concurrency Control来实现实现的方法也多半参考PostgreSQL的经典实现。
我们先看看MVCC的存储。**存储有一个特点是数据只会增加,不会修改**。所以它和我们[第7节课](https://time.geekbang.org/column/article/326583)讲的事件溯源存储很类似。
因为数据库的数据会被删除这些要被删除的数据并不会马上从数据库删掉而是会打一个删除的标签。当确认没有任何人使用之后再择机删掉这一点和Java里的垃圾回收器很像。
其实,在实现的时候,真的会有一个垃圾回收器,它会定期回收这些不可能再被访问的数据。
如果你要修改数据的话,**修改的操作会被分解为删除和新增这两个操作**,因此你对数据的修改也不会改变原来的数据,只会**增加数据的版本**这也是MVCC里Multi-Version的由来。
接着我们再看看事务开始的时候应该做什么。
前面讲快照隔离时说过,快照需要包含正确的数据,所以事务在开始的时候,我们需要找到哪些事务是合理的,这些合理的事务就包含了正确的数据。
按照时间来划分所有事务一共分为3大类
1.已经全部完成的事务,比如已经提交或者回滚。<br>
2.正在进行中的事务。<br>
3.还没有开始的事务。
所以我们需要选择已经完成的事务,同时忽略正在进行中或者还没有开始的事务。
你可能有个疑问,为什么我们需要忽略还没有开始的事务呢?这是因为当事务开始运行之后,还会有新的事务会陆陆续续进来,这些新来的事务对应的数据不应该包含在快照之内,所以也需要排除。
下面这幅图解释了,如何根据事务开始时间来选择快照应该包含哪些数据:
<img src="https://static001.geekbang.org/resource/image/1d/06/1d68f9df7233319d926c537697969e06.jpg" alt="">
最后,我们再来看看怎么生成快照?其实快照只是个逻辑的概念,我们并没有真正把数据拷贝出来作为快照。快照的生成实际是通过修改查询语句实现的。
数据库的每个数据都会增加一个隐藏列,里面记录了是哪个事务对数据做了修改。
当你查询数据的时候,数据库会在你的查询语句里自动增加与正确性相关的查询条件,要求返回数据的隐藏列里只能包含正确的事务。
总结一下MVCC的架构设计思路。**它通过多版本将数据变为了只读状态,从而在查询数据的时候,可以通过事务的开始时间来判断应该使用哪些数据的版本**。
这个架构属于一个叫做**只读架构**Immutable Architecture的设计思想第7节课提到的事件溯源也属于这个大的架构类型。
到这里,常见的一致性分类我们就讲完了。下面这幅图是对前面重点内容的一个总结:
<img src="https://static001.geekbang.org/resource/image/df/66/df27362eb6ff783d83fe1eb8fe34fe66.jpg" alt="">
## 小结
这节课我给你介绍了在单机情况下,事务都有哪些隔离级别。
首先,我们定义了什么是冲突。冲突有读写、写读和写写这三种类型。
然后我们学习了事务的隔离级别。最低级的是Read Uncommitted它解决了写写冲突。
高一级的是Read Commit它解决了写写和写读冲突这两种。再高一级是Repeatable Read它解决了写写、写读和读写这三种冲突。级别最高的是可串行化它解决了两个事务之间的所有冲突。
我们日常使用中还有一个级别是快照隔离。快照隔离比Read Committed级别要高但是比可串行化要低。
接下来我们一起分析了可串行化的定义以及它和冲突可串行化的区别。我们日常使用的可串行化是冲突可串行化一般用2PL来实现。冲突可串行化要求事务提前知道自己要用到哪些数据因此对使用场景有一些限制。
快照隔离基本上是最常用的隔离级别。它的核心思路是在事务开始前给所有数据打一个快照,这样事务之间就不会干扰,所以执行速度快。
我们一般用MVCC的方法来实现快照隔离。在MVCC里数据的修改会创建新的版本因此数据都是在只读状态。事务在刚开始的时候需要查询到有哪些事务已经成功结束然后根据这些已经结束的事务来选择数据的正确版本。
<img src="https://static001.geekbang.org/resource/image/ff/47/ff12809018ab5b9d55891944c3decb47.jpg" alt="">
## 思考题
快照隔离虽然比可串行化的级别要低一些但我们稍做调整就可以达到可串行化的能力这个做法叫作串行化快照隔离SSISerializable Snapshot Isolation
SSI主要需要解决的是事务的回滚。2PL是一种用悲观的态度实现的可串行化它假设事情会出问题因此提前用锁的方式避免问题发生。
SSI则是一种乐观的态度。它假设事情不会出问题大家都很开心地运行下去。当事务要提交的时候才检查是不是可以提交如果不可以就回滚。
如果事务之间的冲突特别小那么SSI能显著增加系统性能。但是当事务之间冲突很频繁的时候SSI会导致事务在运行很久之后才会被回滚这样会浪费资源效果不好。所以SSI是一种在系统压力不大情况下的良好选择。
SSI主要需要检查的是读写和写读冲突像下面这幅图展示的一样。那你怎么才能正确地找到这些冲突呢
<img src="https://static001.geekbang.org/resource/image/55/10/55f577d073e1da1832897d204e530d10.jpg" alt="">
欢迎你在留言区记录你的思考或者疑问。如果这节课对你有帮助,也欢迎你分享给同事、朋友,和他共同进步。

View File

@@ -0,0 +1,211 @@
<audio id="audio" title="13 | 正确性分级(中):多机无容灾有哪几种不同的一致性实现?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/98/2c/98bec581c9a0e998a2592776f5b4592c.mp3"></audio>
你好,我是任杰。这一讲我想和你聊一聊怎么在多机无容灾的情况下保证一致性。
我在[前一节课](https://time.geekbang.org/column/article/334878)里给你介绍了在单机情况下的5种不同的一致性级别。在分布式环境下由于网络存在很大的不确定性金融系统首要关心的不是如何在这些一致性中做选择而是理论上有没有可能达到最高的正确性。那么这节课我们就来学习一下最常用的两个方法。
## 背景
在分布式环境下,每个节点上的数据库都会保证这台机器的数据操作具有可串行化或者快照隔离的事务隔离级别,但是这只是本地机器局部的事务保证,是分散的信息。
如果想要具有分布式事务Distributed Transaction的能力就需要有个方法把局部的信息收集起来做集中决策。这个收集的过程和做集中决策的过程也需要有事务的保证。通过**单机事务来达到多机之间的事务协调,通过单机事务的正确性来保证全局事务的正确性**,你在后面的学习中一定要注意这个核心思路。
分布式事务的实现也分为两种不同的级别。一种是偏底层的实现由数据库自己来实现分布式事务比较著名的有两阶段提交2PC和三阶段提交3PC。另一种是偏上层实现业务系统自己来实现分布式事务在国内比较常见的是TCC。接下来我们先看看两阶段提交。
## 两阶段提交2PC
### 假设
前面提到过,分布式事务需要有人能收集信息后做集中决策,这个人就是两阶段提交的**协调者**Coordinator
想理解2PC你要先知道分布式环境的两个假设
第一,每台机器是独立的,它有自己独立的事务控制机制。两台机器间没有直接交流。
第二,协调者是唯一和所有其他机器交流的角色。它负责给所有机器发指令。机器收到指令后一定需要执行,但是执行不一定会成功。另外,不稳定的网络可能会造成指令丢失,或者指令的返回状态丢失。
协调者只是一个角色,它既可以是一台单独的机器,也可以是集群里的某一台机器。下面这幅图列举了这两种不同的协调者选择方式:
<img src="https://static001.geekbang.org/resource/image/69/1d/69e4e9387e0d75db438ec6b2b8d7991d.png" alt="">
### 协议
顾名思义,两阶段提交一共分为两个阶段。
在第一阶段,协调者向所有参与的数据库发送**准备提交**的消息。每个数据库在收到协调者的消息之后,对自己本地的数据库进行预处理,比如给数据加锁、修改数据等等。
如果预处理成功,本地数据库返回**准备成功**的消息给协调者。如果预处理失败,则返回**准备失败**的消息。请注意,**这时候本地的数据库事务还没有完成,也就是既没有提交事务,也没有回滚事务。**
在第二阶段,协调者会收集所有参与者的准备状态。如果所有人都返回了准备成功的消息,那么协调者发消息让所有参与者提交本地事务,这时候整个分布式事务属于提交状态。如果出现了任何问题,协调者就会发消息让所有参与者回滚本地事务,这时候整个分布式事务处于回滚状态。
导致分布式事务回滚的问题有很多种,比如至少有一台机器返回了准备失败,或者一段时间之后没有收到一台机器的准备情况,我在后面还会提到。
### 举例:两个账号跨机器转账
我们还是举个例子来加深理解。分布式事务最常用的例子,就是账务系统中两个账号之间的跨机器转账。
我们假设用户 `x` 给用户 `y` 转100元钱。最开始用户 `x` 刚好有100元存在一台数据库 `A` 上。用户 `y` 最开始没钱,信息存在另一台数据库 `B` 中 。
转账后 `x` 的余额为0`y` 的余额为100。转账的代码用SQL代码写出来就是下面这个样子
```
begin transaction
update A set balance= 0 where accountID='x'
update B set balance=100 where accountID='y'
end transaction
```
下面这幅图给你展示了我们在分布式环境下转账想要达到的结果:<br>
<img src="https://static001.geekbang.org/resource/image/1e/a0/1e4cbd4fddf39de48066c91c1a6015a0.jpg" alt="">
由于数据库 `x``y` 在两个不同机器,所以我们要用分布式事务来保证整个转账不出问题。
首先,协调者要在自己的本地数据库记下来全局事务状态,里面记录了分布式事务到达了第一阶段的准备提交状态。
接着,协调者分别给数据库 `A``B` 发送准备提交的细节,`A` 需要将 `x` 变为0`B` 需要将 `y` 变为100。这一步的示意图如下<br>
<img src="https://static001.geekbang.org/resource/image/4y/fc/4yyf1a4e03801b7b4107b0df8fe5defc.jpg" alt="">
数据库 `A` 在收到协调者消息后,会对自己本地的数据库进行操作,将 `x` 变为0返回给协调者准备成功的消息。
同样,数据库 `B``y` 变为100后也会返回给协调者准备成功的消息。然后协调者将这两个数据库的返回状态记录到自己的全局事务状态表里。这一步示意图如下
<img src="https://static001.geekbang.org/resource/image/60/21/60cfa468b802a6a7249b7671ae24eb21.jpg" alt="">
协调者收到所有数据库的成功消息后,两阶段提交的第一阶段就顺利结束了。协调者在本地记录这个事实,然后开始第二阶段的提交过程。这时候协调者给每个数据库发出提交事务的消息。这个过程的示意图如下:
<img src="https://static001.geekbang.org/resource/image/55/fc/554b41936d3391806a8e4b4663a784fc.png" alt="">
数据库 `A``B` 提交了本地事务之后,会将提交成功的消息返回给协调者。协调者在本地记录分布式事务第二阶段执行状态,整个分布式事务结束。这一步的示意图如下:
<img src="https://static001.geekbang.org/resource/image/b1/5b/b14735ff25a3d426fyyc61bf867e455b.jpg" alt="">
我们来看看两阶段提交的成本。首先,通过上面的例子我们可以看到,**分布式事务至少需要两次网络沟通**,这个是无法再减少的时间成本。
另外,在分布式事务第一阶段,每个数据库都没有提交事务,事务会在第二阶段才提交。因此第一阶段和第二阶段之间的时间,所有数据库都需要对访问过的数据加锁。这个锁的时间可能会很长,这是另一个时间成本。
到这里分布式事务基本就讲完了。不过我们只解决了正常的情况,两阶段提交还需要考虑好,一旦出了问题之后要怎么应对。
如果有任何一台机器在第一阶段出了问题,协调者会在第二阶段通知所有数据库回滚在第一阶段的操作。
你有没有发现一个悖论?对于在第一阶段操作成功了的数据库来说,这些操作已经提交了,那已经提交了的事务怎么可能在第二阶段回滚呢?事务不是要求已经提交的事务不能回滚吗?
这就涉及到两阶段提交的实现细节了。我们前面说过,**两阶段提交是偏底层的实现,数据库需要修改自己的逻辑后才能支持这个功能。单机版的数据库事务有开始和完成两个状态,两阶段提交需要增加一个新的状态叫作“准备成功”。**
至于数据库究竟做了哪些改变,我会在第三模块的加餐里给你详细介绍。
## TCC协议
讲完了偏底层的两阶段提交我们再看看偏上层的分布式事务实现方法TCC。
TCC全名是Try-Confirm-Cancel和两阶段提交一样它也分为两个阶段也有一个协调者负责协调整个分布式事务的流程。和两阶段提交不同的是业务系统需要负责整个分布式事务的执行而不能全权交给底层的数据库。
在TCC的第一个阶段协调者要求所有数据库尝试Try进行所有本地事务。本地尝试之后将尝试的结果返回给协调者。**在两阶段提交的第一阶段事务并没有提交而是到达了“准备成功”的状态而在TCC的情况下事务会真正提交。**
TCC第一阶段结束之后协调者知道了所有节点的状态。如果所有节点的本地事务提交都成功那么协调者会给所有节点发送`确认`Confirm消息。节点在收到 `确认` 消息之后进行确认操作。
另外,如果有任何一个节点在第一阶段出了问题,协调者就会给所有节点发送`取消`Cancel的消息。节点在收到 `取消` 消息之后,会对第一阶段的事务做逆向操作,取消掉第一阶段的影响。
请你注意TCC的取消操作不是事务的回滚而是业务的回滚。因为第一阶段已经提交了事务所以不能对已经提交的事务进行回滚操作。
这时候用到的是事务补偿也就是说用一个反向业务来对冲正向业务的效果。因此你如果想要实现TCC的话需要把每个业务实现两遍。一遍是正向的业务另一遍是反向的业务。
### 举例TCC情况下的跨机器转账
我们还是举同样的转账例子看看它在TCC的情况下会有什么不同同时也让你感受一下什么是反向业务。
假设和前面一样,一个用户 `x` 的账户开始有100元钱账户信息存储在数据库 `A` 中。另一个用户 `y` 的账户里最开始没钱,账户信息存储在数据库 `B` 中。然后系统发起了一笔从 `x``y` 的转账金额为100元。所以转账后 `x` 的余额为0`y` 的余额为100。
我们先看看第一阶段对用户 `x` 的操作。这一步和两阶段提交基本相同,都是将用户 `x` 的余额变为0。
和两阶段提交不一样的地方在于对用户 `y` 的操作。在两阶段提交的情况下,用户 `y` 会在第一阶段就增加100元钱。**但是在TCC的情况下用户** `y` 在第一阶段的金额不变。下面这幅图给你展示了第一阶段的情况:
<img src="https://static001.geekbang.org/resource/image/98/57/98395e9f0624f6d3960d822c74dff957.jpg" alt="">
TCC第一阶段结束后就需要进行第二阶段了。由于第一阶段两个数据库的事务提交都成功了所以协调者在第二阶段给所有人发`确认`的消息。
因为数据库 `A` 在第一阶段已经完成了对账户 `x` 的修改,所以数据库 `A` 收到 `确认` 之后什么都不用做。
相反,数据库 `B` 在第一阶段什么都没有做,所以在第二阶段收到 `确认` 之后需要对账户 `y` 进行入账操作。这时候数据库 `B` 通过一个正常的数据库事务来完成对账户 `y` 的100元入账操作。下面这幅图展示了第二阶段的流程
<img src="https://static001.geekbang.org/resource/image/17/f4/177b66879c19677fff9e349372e743f4.jpg" alt="">
下面这幅图展示了从时间的维度来看TCC成功时的两个阶段流程
<img src="https://static001.geekbang.org/resource/image/98/58/98a603917e052082cf657554923e0958.jpg" alt="">
细心的你也许已经发现了在TCC第一阶段结束后 `x``y` 账号的钱都为0因此在这一瞬间整个系统掉了100元钱。不过不用担心因为在**协调者的全局事务数据库里记录了当前TCC的状态之后会在第二阶段把缺失的100元钱再补回来。**
我们在初学数据库事务的时候,老师都会说转账需要是个原子操作,钱不能丢失,但这是一个宏观的结果。从这个例子我们可以看到,从微观上来讲转账并不是一个原子操作,而是由多个原子操作组成。而且,转账也不是一瞬间完成,而是有中间阶段。钱在这个中间阶段也会部分丢失,但是最终是正确的。
在单机版和两阶段提交的情况下数据库隐藏了所有上面这些中间细节因此你会感觉事务有原子性。但是在TCC的情况下由于业务系统控制了分布式事务的进程这些中间状态会暴露给业务系统因此你才能感受到一些临时的不一致状态。
其实我们还可以从更高的角度看待金融业务的分布式正确性问题。一个完整的金融业务会涉及到非常多内部和外部的系统组件,每个组件提供一定的事务能力。
在进行顶层架构设计的时候金融业务需要先通知所有系统做自己应该做的事情然后通过第二阶段对各个组件的结果进行调整。这就是宏观的TCC过程我们在[第2节课](https://time.geekbang.org/column/article/324117)提到的对账系统就是协调者在第二阶段的代表。
### 第二阶段的取消处理
我们在前面介绍的是TCC的正常流程也就是所有节点在第一阶段都能成功提交。但是也会出现异常情况比如第一阶段的提交失败这时候协调者会在第二阶段给所有节点发送取消事务的消息如下图所示
<img src="https://static001.geekbang.org/resource/image/28/d0/28e4d6f0c480yy7bf18abba7e3eb27d0.jpg" alt="">
还是看一下我们之前的例子。如果最开始数据库 `A` 里用户 `x` 的余额只有50元那么在第一阶段用户 `x` 进行100元出账就会失败。这时候用户 `x` 的账务金额没有变动,之后在第二阶段进行的事务回滚也很简单,什么都不用做。
但是如果用户 `x` 余额足够,用户 `y` 由于账户锁定无法入账,那么第一阶段结束后,用户 `y` 提交失败,这时候需要取消用户 `x` 的结果。数据库 `A` 收到取消消息之后,会通过一个新的事务将用户 `x` 的余额再加回来。
对于用户 `y` 来说比较简单。由于用户 `y` 是入账的账户在尝试提交阶段和取消阶段它什么都不用做只有在确认阶段需要通过一个事务给账户增加100元钱。
这两个账户的确认和取消流程示意图如下:
<img src="https://static001.geekbang.org/resource/image/2e/65/2e9beaec135970f2725310b2d1709765.jpg" alt="">
### 异常处理
协调者在收到一个出问题的反馈后,就会进入第二阶段的错误处理流程。但是这时候其他节点并不一定出现了错误。更特殊的一种情况是,有的节点根本就没有收到第一阶段的消息,这是异常处理最复杂的情况。
因为分布式环境下网络不稳定,第一阶段的消息可能很久以后才会发到一个节点,但是这时候这个节点可能早就收到了协调者第二阶段的消息。就像下面这幅图展示的一样,一个节点可能会先收到第二阶段取消的消息,然后才收到第一阶段尝试提交的消息:
<img src="https://static001.geekbang.org/resource/image/b8/74/b8984c0984654f5acc51a083a6851f74.jpg" alt="">
为了解决尝试提交和取消这两个消息的乱序问题业务系统在进行TCC的时候需要做3处加强
1.允许取消一个不存在的事务,也叫**空回滚**。<br>
2.空回滚需要在系统里留下记录。<br>
3.第一阶段的尝试提交如果发现有空回滚标识的话,尝试提交需要失败。这个过程也叫作**防悬挂**。
需要说明的是,两阶段提交也要解决类似的问题,但一般是数据库底层解决,而不是把问题暴露给业务系统。
## 正确性反思
在讲完分布式事务的两个不同实现后,你应该发现了协调者的重要作用。协调者负责跟每个节点沟通,并将每个节点的局部信息汇集到一起之后做全局判断,所以协调者的全局事务数据库里保存了所有分布式事务的信息。
有了集中信息之后,协调者就能做出正确的全局判断,所以我们可以说**协调者的本地数据库的事务能力保证了整个分布式事务的事务能力**。
协调者的信息收集和事务处理是先后两个过程,过程的中间会出现状态不一致的情况,**协调者通过最终一致性来解决集群最终的状态正确性。**
那这里又出现了一个新的问题。在分布式环境下机器节点可能会出问题的,万一协调者的全局事务数据库出了问题怎么办呢?这就是**分布式事务的单点问题**,我们后面会讲到该怎么解决,这里你先有个印象就行。
## 小结
这一讲我们学习了怎么在多机无容灾的情况下保证一致性,也就是实现分布式一致性,即分布式的可序列化。
我们先了解了两阶段提交。两阶段提交是由数据库实现的分布式事务,整个过程分为两个阶段。
第一阶段协调者通知所有节点准备提交,所有节点将自己的准备情况反馈给协调者。第二阶段协调者根据第一阶段的结果来判断要提交所有事务,还是回滚所有事务,并将结论发给所有节点。节点收到第二阶段命令后加以执行。
接着我们学习了TCC。TCC是国内互联网用得最多的分布式事务实现方式。它和两阶段提交不一样的地方在于上层的业务系统需要自己管理分布式事务的进度。上层业务系统需要实现3个方法尝试提交、确认提交和取消。
TCC的整个过程也分为两个阶段。第一个阶段由协调者和所有节点之间进行尝试提交。之后在第二阶段协调者根据第一阶段的结果来判断是确认提交还是取消。
TCC和两阶段提交的不同在于TCC的每个阶段都是完整的本地数据库事务而两阶段提交只有在第二阶段完成后本地事务才真正结束。因此TCC的好处是事务的加锁时间短对应的代价是业务系统复杂需要感知分布式事务的存在还需要通过空回滚和防悬挂来解决乱序问题。
<img src="https://static001.geekbang.org/resource/image/f9/c0/f9b70c26663c2b535a2fa9241f2520c0.jpg" alt="">
## 思考题
在两阶段提交的情况下,协调者的全局事务数据库可能会出现两种问题。一种是数据库重启。这样数据还没有丢失,协调者可以根据恢复好后的数据情况判断接下来应该怎么做。另一种是数据库整个消失不见了,这时候需要用到后面的课程知识来完美地解决。
但是,大部分情况下就算全局事务数据库的数据丢失,协调者也是可以根据所有节点的情况来反推出自己应该做什么。你知道协调者应该怎么做吗?
欢迎你在留言区提出疑问或分享思考。如果这节课对你有帮助,也欢迎转发给同事朋友,和他一起交流讨论。

View File

@@ -0,0 +1,195 @@
<audio id="audio" title="14 | 正确性分级(下):多机有容灾有哪几种不同的一致性?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e4/99/e4d1c86fe957c5fb41cfe98c53d10799.mp3"></audio>
你好,我是任杰。这一讲我想和你聊一聊,在多机有容灾的情况下怎么保证一致性。
在前面两节课里,我们已经学习了在没有容灾的情况下,如何在单机和多机的情况下保证数据一致性。由于没有容灾,每一份数据只会出现在一个地方,因此我们可以集中对所有数据访问进行控制。
但是,我们在搭建架构的时候一定会对数据进行容灾,会将数据复制到多个地方,这时候就会出现数据访问不一致的问题。
这种情况下前面两节课的内容就不适用了我们需要用新的理论来分析所有会出现的问题。首先让我们来看一下我们“看起来熟悉”的CAP的理论。
## 为什么不要用CAP来描述一致性的选择
CAP由三个性质组成一致性Consistency、可用性Availability、分区容错性Partition tolerance。其中分区容错性指的是网络出现了问题把原本通过网络连接在一起的机器分成了几个独立的部分也叫作**脑裂**。
首先我来说说CAP第一个容易让人误解的问题。我们会觉得CAP这三个性质不能同时拥有最多只能有两个那么三选二之后排列组合一共有三种选择分别是CA、CP和AP其实这个理解是错误的。
CAP真正的假设是当出现了脑裂后你只能在一致性和可用性当中选择一个从而放弃另一个。也就是说你只能选择CP或者AP。在一些早期的文章中你还能看到这个常见的错误。所以CAP并不是像下面这幅图左边展示的三选二而是像右边展示的那样二选一
<img src="https://static001.geekbang.org/resource/image/6e/56/6e9ab631dd21d2494017d9cc5b37ae56.jpg" alt="">
对于CAP我们还有另一个常见的误解。通常我在介绍名词的时候都会先给个定义但是对于CAP却没有。那你知道一致性的具体定义是什么吗
我们一般对一致性都有一个模糊的认识,知道一致性描述了一个正确的数据读写场景,但是很少有人能说清楚,具体怎样才算是正确呢?我们会在这节课的后面告诉你,其实**CAP的一致性指的是可线性化**Linearizability。这就是CAP的第二个问题定义不明确容易产生误解。
CAP理论在出现之后很快就变成了分布式系统一个脍炙人口的术语。但是因为CAP会带来一些误解慢慢地在学术界已经不太建议用这个术语了。
在反对的声音当中对于CAP三选二的误解争论不大原因是这个误解比较容易澄清。现在**反对比较强烈的是CAP对一致性的定义太过于简单。**
因为分布式环境不但有一致和不一致中间还有很大的选择空间。CAP将多种不一致选择变成了单一的选择非常不利于我们清晰描述分布式环境下会出现的问题。所以你可以用CAP来给第一次学习分布式理论的人启蒙但是在真正做架构设计的时候要尽量少用。
接下来,我们来看看分布式环境下的一致性分类。已经总结出来的一致性有好几十种分类,在这里,我们重点学习一些常见和重要的一致性。
## 最终一致性
我们在[第2节课](https://time.geekbang.org/column/article/324117)讲支付系统的时候提到过,支付系统会碰到信息流和资金流不一致的情况,因此需要用到异步系统对接的方式,最终信息流会和资金流一致。这里有一个关键的术语是**最终一致性**。
最终一致性是分布式系统中一个常见的一致性级别。基于消息系统的架构在宣传自己正确性的时候,一般会声明自己是最终一致的。
顾名思义,最终一致性指的是最终会一致。那问题又来了,什么叫最终,什么又叫一致呢?
为了说明一致性,我们要先弄明白什么叫作**可见性**Visible。假设有两台机器A和B这两台机器之间互相做备份。
如果你在机器A上对数据的修改经过一段时间之后反映在了机器B上这时候你的修改在机器B上就是可见的。一旦在机器B上是可见的之后你就可以在机器B上使用在机器A上的修改结果。下面这幅图展示了可见性的意义
<img src="https://static001.geekbang.org/resource/image/6b/yc/6bd5afbb7bc8771e820a48095a616yyc.jpg" alt="">
我们再回到对最终一致性的定义。这里的**一致性指的是你的修改在所有机器上都是可见的。**如果你的修改在一台机器上被看到了,那么这台机器就和原始的机器是一致的。
“最终”则定义了一致性的时间范围。它用到了数学上的极限(∞)概念。在有容灾的情况下,你对一台机器的数据修改会被慢慢复制到其他的机器。随着时间的推移,没有复制到数据的机器数目会越来越少。当这个时间是无穷大的时候,没有复制到数据的机器数目会降为零。
跟CAP没有对一致性做出准确的分类一样最终一致性对于最终的定义也没有提出准确的、工程可用的定义所以它的实际指导意义也不大。
## 从会话角度看一致性分类
接下来的4个一致性都和会话Session有关。会话是个使用者的概念而不是服务器端的概念。**会话是用户的唯一标识符**,通过会话可以判断是不是同一个用户。
在单机或者没有容灾的情况下,能不能判断出是同一个用户的作用不大。但是在有容灾的情况下,多台功能一样的机器会作为彼此的备份节点。这时候同一个用户的不同请求可能会被发送到不同的机器上处理。虽然这时候是多台机器在处理你的请求,但是从用户的角度来看,你需要保证最后的处理结果,和在一台机器上处理的结果是一样的。
这里的一样并不要求完全一样因此也会有一些选择的余地这就是为什么我们会有4个不同的和会话相关的一致性。为了你理解起来更方便在正式讲解之前我们先来看看简化版的容灾模型。
在简化版的容灾模型里,用户会往集群的主节点写入数据。主节点负责将数据复制到备份节点。在这里对于复制的同步和异步没有任何要求,对于复制节点的个数也没有要求,只要多于一个备份节点就行。
用户的读取请求比较复杂。用户既可以从主节点上读取数据,也可以选择从备份节点读取数据,也可以有时候从主节点读,有时候从备份节点读。读取哪个节点取决于用户和服务器之间的协议,也可能有一定的偶然因素。
下面这幅图展示了一个同步备份的例子。用户把数据写到主节点后,并不会直接返回,主节点会将数据同步写入两个容灾节点。只有这两个容灾节点都写入成功之后,主节点才会通知用户说数据已经写入成功。
<img src="https://static001.geekbang.org/resource/image/91/96/91d5016f45626b3f0b6906b4db5e7d96.jpg" alt="">
在定义了会话和容灾之后,让我们来看看都有哪四种不同的会话一致性分类吧。
### 单调写一致
单调写一致的英文名是Monotonic Write。如果你往有容灾的集群里写了多次数据单调写一致要求**所有的节点的写入顺序和你的写入顺序完全一致。**这样我们就能保证对于任何一个节点,它看到的别人的写操作和自己的写操作是完全一致的。
我给你举个例子下面这幅图展示了一个不是单调写一致的情况。用户有连续三个写操作。主节点的写入顺序和用户发起的写操作顺序一致。但是主节点在复制数据到容灾节点1的时候前面两个写操作顺序发生了错位因此整个集群不满足单调写一致。
<img src="https://static001.geekbang.org/resource/image/ba/3c/ba350626371471e2fd19a00eaaa7373c.jpg" alt="">
### 单调读一致
和单调写一致对应的是单调读一致英文名是Monotonic Read。你要注意的是单调读一致并不表示所有机器上的读顺序都是一致的。单调读一致依然和写的顺序有关。它要求**新的读操作不能读到老的结果**。比如说,你如果从集群里读到了一个值,那么如果你再读一次的话,一定不能读到之前的值。
单调读不一致的情况一般发生在读取的节点发生变化的时候。如果你的两次读发生在两个不同的备份节点,那么由于备份的速度不同,很有可能你的后一次读取会读到更早一些的结果。
下面这幅图展示了单调读不一致的例子。用户写入两个值之后读取了两次结果。第一次读的时候是从主节点上读的,因此读的是最新的写入结果。
第二次读发生在容灾节点1上面。由于主节点将数据备份到容灾节点1需要很长的时间第二次读的操作发生的时候容灾节点还没有最新的数据因此第二次读返回了第一次写入的数据这样就违反了单调读一致的要求。
<img src="https://static001.geekbang.org/resource/image/44/ff/44353c27e556c9d018f50e38a2a628ff.jpg" alt="">
### 自读自写
自读自写的英文名是Read Your Write也就是说你**能把自己写入的值读回来**。它不仅仅要求能把写入的值读回来,还要求能把**所有**过去写入的值读回来。
为了能保证自读自写的一致性要求,服务器的节点在处理你读请求的时候,需要确保自己节点上有这个会话过去所有的写入记录。这样你才能确定所有写入的结果都没有丢失,而且在读的时候前面的写入都已经完成了。
注意,所有写入都已经完成并不表示写入的顺序是正确的,因此如果你想要得到正确的写入结果,还需要单调写一致来保证。
下面这张图里展示了一个不符合自读自写一致性的例子。用户在连续写入两个值后进行了读取操作。读操作发生在容灾节点1上。由于主节点到容灾节点1的备份速度过慢容灾节点1在处理读请求的时候还没有收到第二个写请求因为读取结果漏掉了一个写入的结果所以不是自读自写。
<img src="https://static001.geekbang.org/resource/image/c1/61/c1446d95e1d35f93403d77273e763361.jpg" alt="">
### 先读后写
先读后写的英文名是Write follow Reads。前面三个一致性规定了一个会话的行为应该是怎样的。先读后写不同它规定了多个会话之间互动应该满足怎样的一致性要求。
先读后写要求比较严格。假如你曾经读到了另一个人写入的结果,那么你想再写数据的话,你的写入一定要在另一个人的写入之后发生。也就是说,**你们俩之间的写入有个先后顺序。**
**你如果看到了另一个人的结果,就表示另一个人的写入是过去发生的事情,这时候如果你想再写点新东西进去,那么整个集群需要保证你们俩写入的先后顺序**
下面这个例子展示了一种不是先读后写的情况。这个例子里有两个用户和两个节点。
用户1向主节点写入了第一个数据接下来用户2马上从主节点读到了这个写入的数据然后又立刻写了一个新的数据。由于主节点复制第一个数据到容灾节点1的速度太慢导致容灾节点1先保存了后一个写入的数据然后才保存前一个写入的数据因此不是先读后写。
<img src="https://static001.geekbang.org/resource/image/05/d5/0585e623b1f3f11fbf3ae58cd2dc08d5.jpg" alt="">
## 线性一致性
线性一致性的英文名是Linearizability。线性一致性是分布式系统里最重要的一致性。你可以理解为线性一致性是分布式环境下的可串行化Serializability
线性一致性所定义的环境里有一些程序,这些程序会执行一系列的操作,每个操作都有开始和结束的时间。
对于单个程序来说它所有的操作之间没有时间上的重叠也就是说属于同一个程序的两个操作不会并发执行。但是属于不同程序的操作可以在执行时间上有所重叠比如说下面这幅图展示了3个程序一共6个操作的时序图
<img src="https://static001.geekbang.org/resource/image/9a/51/9aacae3676b3f08c560be9ea5dc4b351.jpg" alt="">
**线性一致性要求我们可以调整这些程序的操作开始和结束时间,调整的结果是所有程序的所有操作之间没有任何时间上的重叠。**
和我们在[第12节课](https://time.geekbang.org/column/article/334878)讲的冲突可串行化一样,线性一致性对时间的调整也有一个要求,那就是如果两个操作之间没有时间上的重叠,那么这两个操作之间的时间先后顺序不能发生改变。
下面这幅图展示了对前面例子的分析。前面的例子一共有3个地方有时间重叠因此这些彼此重叠的操作可以随意调整先后顺序。例子里还有两个地方有操作的先后关系因此在调整顺序的时候我们不能把这几个有先后关系的操作顺序搞反。
<img src="https://static001.geekbang.org/resource/image/a2/c5/a2yyd29e2b16201b610yyee82bfee7c5.jpg" alt="">
下面这幅图展示了一个可能的线性一致性调整结果:
<img src="https://static001.geekbang.org/resource/image/bd/50/bd84e6a6f225ebd15e8985fee529b550.jpg" alt="">
那调整之后就是线性一致性了吗?其实还不是。**你还需要对调整之后的结果进行正确性验证**。这里的正确性指的是业务逻辑的正确性。
当你把所有操作按照线性一致性的要求进行调整之后,所有操作可以看作是先后进行的,没有任何并发。所以,你可以按照业务逻辑来分析所有程序的所有操作是否合理,比如说加减钱是否正确,或者消息入栈出栈的顺序。
如果你发现逻辑不正确,就需要尝试另一种线性一致性调整的顺序。要是你尝试了所有调整的排列组合后,还是找不到一个正确的结果,那么整个过程就不是线性一致性了。
线性一致性是分布式环境下最重要的一致性。它在分布式环境下对所有操作进行了排序,因此能帮助我们分析最后结果的合理性。线性一致性的实现还需要用到接下来两节课的内容,你先有个印象就行。
## 严格可串行化
在第12节课我给你介绍了**单机情况下最强的一致性是可串行化**。而这节课我们又学了**分布式情况下最重要的一致性是可线性化**。那么把这两者结合起来,就得到了**分布式情况下最强的一致性,叫作严格可串行化**Strict Serializability
我们再来重温一下可串行化的定义。可串行化表示两个事务里所有操作的执行结果等价于这两个事务的某一个顺序执行结果。这里对“某一个”并没有做任何限定。
而严格可串行化则对这个“某一个”做出了规定,它要求两个事务的运行结果等价于唯一一个顺序执行结果。在这个结果里,原来谁的事务先结束,那么在顺序执行的情况下谁的所有操作先结束。严格可串行化虽然有着极强的正确性保障,但是它的运行效率特别低,所以一般很少用到。
## 小结
这节课我们学习了在有多机容灾的情况下如何保证一致性。
首先我给你简单介绍了分布式系统中最常见的CAP理论。CAP能方便初学者理解但是由于它容易被误解而且对于分布式问题的复杂度有一个过于笼统的结论建议你在真正讨论问题的时候尽量少用。
然后我们讲了最终一致性。异步处理架构一般具有最终一致性但是最终一致性和CAP理论一样没能对分布式环境下的复杂问题做更为准确的分析。
接着我们从会话的角度来看一致性一共有单调写一致、单调读一致、自读自写和先读后写这4种一致性分类。这4类之间都是并列关系没有高低强弱之分。
接下来我们了解了线性一致性。线性一致性是分布式系统中最重要的一致性级别。它对所有操作开始和结束时间进行合理的调整,最后的结果是所有操作能按顺序执行,这也是为什么叫作线性一致性的原因。
最后我们学习了严格可串行化。严格可串行化要求在分布式环境下的事务需要遵守线性一致性。由于它的执行效率低,一般很少使用。
<img src="https://static001.geekbang.org/resource/image/ef/f0/efa664772a65eb127a2ca7ed3c1b7ff0.jpg" alt="">
为了让你建立起更加系统、清晰的认知,这里我整体总结一下所有一致性的情况。
在第三个模块的前面三节课里,我给你介绍了单机、多机无备份和多机有备份这三种情况下对一致性的分类。很多情况下,我们并不是追究极端的一致性,而是根据我们的业务和经济情况来选择合适的一致性级别,这一点在你设计金融系统的时候尤为重要。
现在分布式数据解决方案众多,我们对这些技术宣传的内容一定不能盲从,而是要根据它们的实现做选择。当然了,一致性选择也不是完全没有规律可循。下面这幅图列出了我们前三节课大部分内容之间的层级关系和我们通常的选择。
<img src="https://static001.geekbang.org/resource/image/af/36/af6e65c8aecdb5b3f25d31fcd6f13e36.jpg" alt="">
分析的思路是这样的。首先我们要看是单机问题还是多机问题。如果是单机问题,那么首选快照隔离,一般不需要用到可串行化。
如果是多机问题,那么先解决的是多机容灾。这时候有多台机器需要提供同一份数据,你可以根据容灾后的正确性要求具体判断。
一种情况是你对容灾后的正确性要求不高,这时就要看看从客户端角度发起的会话是否需要有正确性。
这里还可以细分成两种更详细的情况,如果你只需要保证一个会话的正确性,那么一致性要求就是保证单调读一致、单调写一致和自读自写。如果需要保证多个会话之间的正确性,就要保证先读后写。
另一种情况是对容灾之后的数据访问正确性要求高,那么就要保证线性一致性。
最后如果你要解决的是在有容灾的情况下的分库分表问题就需要解决分布式事务。这时候每个分完的库和它的容灾机器组成的集群需要先满足线性一致性这样容灾集群对外才能表现得像单个节点一样。然后我们再用TCC或者2PL来实现分布式事务。
## 思考题
我们这节课的思考题很简单。如果让你来实现分布式环境下的严格可串行化,你能想到什么办法呢?
欢迎你在留言区提出你的思考或疑问。如果你身边的朋友、同事也对一致性的话题感兴趣,也欢迎你转发给他们,一起学习进步。

View File

@@ -0,0 +1,284 @@
<audio id="audio" title="15 | 分布式正确性的存在性(上):什么情况下不存在分布式共识算法?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/81/bd/8140cb578af0eab9cfffc648a4ab5fbd.mp3"></audio>
你好,我是任杰。这一讲我们聊一聊,什么情况下不存在正确的分布式共识算法。
对于金融行业来说,系统的正确性要远高于系统的执行效率。打个比方,当你在网上和朋友聊天的时候,漏掉了一两条消息其实无所谓。但是如果你给朋友网上转钱,钱转丢了就是件大事了。
金融行业的信息系统和互联网企业一样,也是由很多台机器组成的集群提供服务。机器一旦多了就会出现分布式系统常见的各种问题,比如宕机、网络中断。
那这种情况下,我们怎么才能保证金融系统的正确性呢?套用一句知乎上经常看到的评论,我们在回答为什么之前,先要问问是不是。你有没有想过,万一正确的分布式系统并不存在呢?
这些问题其实是对分布式系统的深入思考,也是金融级软件对架构师的要求。只有知其然并且知其所以然了,客户才能对你做出来的金融系统有信心。
## 核心思路&amp;小结
共识是指多台机器之间达成统一的结论。这节课我们会证明可能是分布式系统里最重要的一个结论,那就是不存在共识算法。准确来说,**在一个完全异步的分布式系统里,如果至少有一台机器可能会出问题,那么就不存在非随机的共识算法。**
从结论可以看出,想要共识算法不存在,需要同时存在两个现象:一个是机器出问题,另一个是完全异步。只要我们能让任何一个条件失效,就存在共识算法。
很显然我们无法保证机器不出问题,所以重点要放在怎么让系统不是完全异步。幸运的是**完全异步这个条件非常容易去除,只需要给每台机器增加一个时钟来判断发送的消息是否丢失。**
这也是为什么常见的共识算法比如Paxos和Raft里面一定会判断消息是否超时。所以虽然理论上分布式系统不存在共识算法但是现实中很容易绕开这些理论约束实现正确的共识算法。
证明的过程分为三步。第一步证明,分布式系统一定存在某个特殊的开始状态,共识算法的最终结果与这个特殊状态无关,只取决于某台机器是否出问题。
第二步证明我们能构造出一些特殊场景,使得分布式系统从这个特殊状态开始运行后,还会进入下一个特殊状态。
第三步结合了前面两步的证明,我们不断构造特殊场景,系统会周而复始地进入特殊状态,从而永远无法做出共识结论。由于对于任何一个号称具有共识能力的算法,我们都可以构造出这些特殊的情境,也就不存在真正的共识算法了。
<img src="https://static001.geekbang.org/resource/image/06/41/06106c37111e6b26643ac2aa85a1ee41.jpg" alt="">
如果你想更深入地了解这个结论,可以看这几篇论文:
- 1985年三位科学家FisherLynch和Patterson的论文"Impossibility of Distributed Consensus with One Faulty Process"。这节课主要讲解的是这篇论文。
- 1996年的论文"Unreliable failure detectors for reliable distributed systems"。这篇论文证明了如果分布式系统存在一个能让你最终做出准确判断的不准确时钟,那么系统存在共识算法。
- 1996年的论文"The weakest failure detector for solving consensus"。这篇论文证明了这个不准确时钟是共识算法存在的充要条件。
好,现在我已经交代了这节课的核心内容,不过你要是有好奇心,可以仔细看看后面的证明过程。
算法课一般会讲如何实现一个算法,很少会谈到如何证明算法不存在。证明算法存在只需要举个例子就可以,但是证明算法不存在,就需要证明所有可能的算法都不行,难度非常高。你可以通过下面的证明,提高自己对分布式系统复杂度的理解。
## 背景及定义
### 背景
"Impossibility of Distributed Consensus with One Faulty Process"这篇划时代的论文在2001年被评为Dijkstra分布式系统最具影响力论文。因为这个结论太重要所以一般称为“FLP结论”。
这篇论文证明了分布式系统不存在共识算法。我们前面说过共识的定义,但不够准确。所以在证明开始之前,让我们重新定义一些专有名词。
### 定义
#### 分布式系统和机器
分布式系统由**至少3台**机器组成。每台机器都有自己的**初始状态**。为了方便证明我们假设状态是二元的即只能是0或者1。不是二元状态的情况和二元状态一样只是证明过程会稍微繁琐一点。
分布式系统是一个封闭的系统,没有外界输入。系统里的所有机器节点在最开始都有自己的状态,随后这些节点之间,会按照算法定好的逻辑给彼此发送消息。每台机器在收到消息之后可以做三件事。
第一,改变自己的状态。这时机器从初始状态变为中间状态。
第二是向其他机器发送消息。发送的机器数量不做限制,可以一个机器都不发,也可以发给很多机器。
第三是输出一个结果。这个结果只能从0或者1中选择而且机器只能输出一次结果。
通俗地说每台机器里面最开始含有一个数字0或者1。机器最后会输出一个数字也是0或者1。机器之间可以互相发送消息通过消息来改变彼此内部的状态。输入和输出示意图如下
<img src="https://static001.geekbang.org/resource/image/18/f7/1831464f2909450fe9a80db0a96d88f7.jpg" alt="">
和现实世界类似,分布式系统内的机器并不稳定。它们会死机,会重启,也会变得很慢。但是我们这里假设机器逻辑都是正确的,一旦我们写了一个算法,机器会老老实实地按算法执行,不会偷偷利用算法的漏洞来攻击算法。
用现在的区块链理论来说,我们这里假设碰到的是非拜占庭问题,而不是区块链或者比特币解决的拜占庭问题。如果你对拜占庭问题有兴趣可以自行查找相关定义,或者参考这篇[文章](https://time.geekbang.org/column/article/195662)。
最后说说论文对共识算法的一个隐含假设。这里假设**共识算法不是随机的**,主要是考虑结果可复现性。
#### 共识
有了前面对机器的准确定义共识就更好定义了。共识其实就是要求这组机器的输出都相同但是这还不够准确更加精准一点的定义是这样的共识需要满足以下3个条件
1.终止性termination<br>
2.一致性agreement<br>
3.有效性validity
其中,**终止性**指每个还能**正常运行**的机器**最终**都需要确定**唯一一个结果**。这里有两个重点。一是所有还能正常运行的机器都需要生成结果,一个不落。不能正常运转的机器没有任何要求。二是结论只能生成一次,一旦做出就无法更改。
**一致性**相对好理解一点。一致指所有结果都需要完全相同。**终止性**和**一致性**有一个更通俗的解释所有机器的状态初始可以在0和1中任意选择最后当共识算法结束的时候所有还活着的机器需要输出一样的结果要么都是0要么都是1。示意图如下<br>
<img src="https://static001.geekbang.org/resource/image/98/59/98d9bc82422957c5564a0b5b996a2159.jpg" alt="">
最后一个条件是**有效性**。**有效性**指的是所有结果都有可能是共识结果。举个例子你其实也可以参与分布式系统的决策。你可以写一个共识算法不管机器初始状态是什么最后都输出0。这样也算是一个共识但是这个共识算法毫无意义所以我们需要把这种极端简化的情况排除。
#### 消息
我们前面提到了机器之间是可以互相发消息的。消息需要仔细定义,因为分布式系统的复杂度是和消息传输方式的复杂度一一对应的。
消息的第一个假设是消息的**发送是异步**的。**异步**是指你给对面的机器发送了一条消息后,你不一定能收到反馈。
收不到反馈可能有很多种情况,这时候你不知道究竟是消息还没发送到那台机器,还是那台机器已经收到了,但是给你反馈前死机了。
收到消息的时间间隔也没有任何假设。你的消息可能几分钟后就能收到,或者很久才收到,甚至永远收不到。时间间隔是一个重点,我们在最后会再次提到。
消息的另一个假设是消息系统本身的**运行是完美**的。所有消息会被先存储在**消息队列**里。这个队列**不会丢失任何消息**。
另外,所有**消息只会被处理一次**,也就是说不会碰到消息重发的问题。一旦机器从消息队列里读取了一条消息,这条消息就会永远从消息队列里消失。这时候如果机器刚好又重启,那么这条消息就会永远在系统中消失。
虽然现实中不会存在这么完美的消息系统,但是由于我们解决的不是消息系统的问题,在这里对消息系统做完美假设,这有利于让你关注分布式系统本身。
消息的最后一个假设是消息的**接收是异步**的。消息的接收顺序是完全随机的,并不是先到先得。这意味着你给一台机器发送两条消息,先发的消息可能后到。
正在运行的机器会不断地从队列里拉取消息。当队列里没有和它相关的消息时,系统会返回一个**空消息**。机器也可以根据**空消息**的情况来改变自己的状态。
#### 举例
到这里为止我们明确了论文对分布式系统里机器、共识和消息这3个术语的定义。为了方便你理解我在这里举个例子。
假设分布式系统由A、B、C三台机器组成初始值分别为1、0、0。系统在运行了一定时间之后A给B和C发了两个不同消息同时A输出结果为0。
B过了一定的时间后收到了A给自己的消息。B这时候决定输出结果为0现在A和B达成了共识都输出了0。
C收到了A给自己的消息后没有给外面发任何消息也没有输出任何结果。C虽然一直在运行但是没有输出任何结果因此**不满足共识算法的终止性**。所以A、B和C这三个节点组成的分布式系统没能达成共识。
但是我们稍微改改对C的描述结果就会大不一样。如果这时候C死机了那么C就处于非运行状态。由于共识的定义对于非运行的机器没有任何要求此时A、B和C组成的分布式系统就达成了共识。你可以再仔细体会一下两者的区别下面是这个例子的示意图
<img src="https://static001.geekbang.org/resource/image/ac/0c/ac7a3d5d297f13683d8c4f9a945f480c.jpg" alt="">
## 问题定义和解题思路
完成上面的定义后,我们终于可以对要证明的问题做一个准确概括了:**在一个完全异步的分布式系统里,如果至少有一台机器可能会出问题,那么不存在非随机的共识算法**。证明的过程分为三步。
第一步证明分布式集群存在一个特殊初始状态。我们无法通过这个初始状态预先知道共识结果,具体结果取决于哪些机器会在什么时候出问题。我们把集群的这个特殊初始状态称为**非确定状态**。
第二步,证明一旦存在一个非确定状态,系统在运行了一段时间后,一定还会进入下一个非确定状态。
第三步是将第一步、第二步合起来。从一个非确定性的初始状态开始,系统会运行到第二个非确定状态,然后会运行到下一个非确定状态,最后一直无限运行下去。这样就违反了共识算法的终止性,也就证明了不存在共识算法。
证明过程用了反证法,比较精妙。接下来,你需要紧跟我的思路,仔细体会论证过程。
## 第一步证明
当分布式系统处于某种状态时,如果我们能提前计算出最后的共识结果,那么这个状态叫**确定性状态**。反之,如果最后的共识结果取决于机器是否在线,这个状态就叫**不确定性状态**。
我们在这里需要证明的是,**任意一个集群都存在一个非确定性的初始状态**,即我们无法通过这个初始状态,判断最后的共识结果。
现在反证法开始了。反证法需要将需要证明的结论反过来描述,所以**我们假设从所有初始状态开始,不管机器是不是出问题,我们都能提前计算共识结果。**
我们先假设有一个初始状态的集合CconfigurationC包含了集群内所有机器的初始状态。比如下图画了3台机器和它们的初始状态集合C
<img src="https://static001.geekbang.org/resource/image/19/63/1932bec4fdded5b9f9bc58ff4f186363.jpg" alt="">
根据反证法的假设我们能提前计算出最后的共识结果所以上图的初始状态也会有一个共识结果比如说为0
<img src="https://static001.geekbang.org/resource/image/3f/0c/3f5b863437e512339b727b3348cb6e0c.jpg" alt="">
接下来我们需要用到共识算法的第3个特性**有效性**。**有效性**表示当我们遍历所有初始状态时一定有的初始状态最终会产生共识结果0也一定会产生共识结果1。简单起见假设初始状态集合为C0的时候共识结果为0。当初始状态为C1的时候共识结果为1。
下面就是反证法最精妙的地方了。前面提到的C0和C1是关于集群的初始状态。这两个初始状态虽然不一样但我们可以一步步将它们变成一样的过程很简单。
先选出C0里的一台机器它在C0和C1的状态不一样。然后将C0的所有状态复制一份到新建的初始状态C2并将C2里这台机器的状态变为它在C1里的状态。
接着在C2里找一台和C0状态不一样的机器建立一个新的初始状态C3并将这台新机器的状态改变。以此类推由于机器数目是有限的最后一定会构建出一系列初始状态它们之中只有一台机器的状态不一样。
举个例子如下图所示假设C0里的3台机器初始状态都是0C0的共识结果为0。C1的所有初始状态都为1共识结果为1。C2将C0里的第1台机器的初始状态从0变为1。C3将C2里的第2台机器的状态变为1。这样通过3次变化我们最终可以将初始状态C0变为C1
<img src="https://static001.geekbang.org/resource/image/16/20/16ed9db74bb775218c3a0b92682ab620.jpg" alt="">
那么问题来了。在上面这个例子里C2和C3所对应的共识结果应该是什么呢其实不管它们对应的结果是什么你会发现对于上面的4个初始状态一定有相邻的2个初始状态它们分别对应了0和1这两种不同的共识结果。你可以试试枚举所有共识结果的排列组合来验证。
我们假设C2和C3对应的共识结果都为0这样C3和C1这两个相邻的初始状态集合就会有不同的共识结果
<img src="https://static001.geekbang.org/resource/image/45/35/4548a04a454a7749286554008b44fd35.jpg" alt=""><br>
现在就到考验你的时候了。C3和C1只有第3台机器的初始状态不一样。如果这第3台机器从一开始就死机了C3和C1的初始状态就会完全一样这时它们俩都会产生怎样的共识结果呢
下图展示了这个疑惑。反证法里假设过集群的共识结果只和初始状态有关。那么如果第3台机器一直有问题C1和C3的初始状态其实是一样的那么它们俩会产生一样的共识结果要么是0要么是1。
如果最后结果都是0那么C1在第3台机器不出问题时产生共识结果1但是当这台机器出问题后会产生不同的共识结果这和我们反证法假设矛盾。如果最后结果为1这样C3也会产生同样的矛盾。示意图如下
<img src="https://static001.geekbang.org/resource/image/d7/00/d7c1893510e58e9f9c1bf5ef1493c500.jpg" alt="">
按照同样的道理,我们可以证明任意数目的集群都会产生类似的矛盾。所以对于任意一个机器集群,一定存在一个特殊初始状态,它的共识结果取决于一台特殊机器是否正常运行。第一步证明结束。
## 第二步证明
第一步证明只用到了分布式系统的**初始状态**和**最终结果**,而第二步证明则需要用到分布式系统的**中间状态**。和这一节课最开始类似,在证明之前我们再做一个定义。
分布式系统里消息的接收是有顺序的,尽管接收消息的时间差可能会很短,但是依然有顺序差别。所以,我们可以给分布式系统状态的变化定个顺序,任何两个相邻的状态变化之间是新接收到的消息,这个状态变化的顺序叫作**路径**。
和第一步证明一样,分布式系统的状态也分为非确定性状态和确定性状态两种。系统可能从非确定性状态运行至确定性状态,但是反过来不行。路径和两种状态的示意图如下:<br>
<img src="https://static001.geekbang.org/resource/image/5a/39/5af6c0398530558326e16f556fbd4839.jpg" alt="">
这里还要说到一个新的操作。由于消息系统是异步的消息的接收可以任意延迟。下图展示了对于某一条路径将第一个消息e一步一步往后挪时系统的不同运行状态
<img src="https://static001.geekbang.org/resource/image/84/d4/84d563db22992b1786b353fa639072d4.jpg" alt="">
好,我们终于可以开始证明了。这里需要用到第一步的非确定性初始状态。
从这个状态开始我们先随便选择一个消息比如集群里可能会出现的任意一个消息e。接下来我们从集群所有可能的中间状态中选择两大类出来。
一类是从来没接收过消息e的状态我们称之为C另一类是刚刚接收过消息e的中间状态集合称之为D。剩下的中间状态跟证明无关可以忽略。
那么,我们接下来证明,**在集合D里一定存在另一个非确定性的中间状态。**示意图如下:
<img src="https://static001.geekbang.org/resource/image/f7/c1/f7c62b2ef99e8yy9d8f4f09b9a4258c1.jpg" alt="">
这里还是用反证法,我们**假设集合D里所有的状态都是确定性状态。**
非确定性的初始状态一定会有两条不同的**路径**分别产生0和1这两个共识结果。如果这个初始状态所有路径的最终共识结果都一样那么就没有非确定性了。对于产生共识结果0的路径如果这个路径没有穿过集合D那么表示路径在集合C里就结束了。
那么我们可以将消息e添加到这个路径的末尾。这样构造出的新路径会穿过集合D。由于路径在添加消息e之前共识结果为0那么添加消息e之后的共识结果也为0。这里用到异步消息的一个属性既然消息e出现过那么消息e的接收时间可以任意调整。
所以一定有一条穿过集合D的路径会产生共识结果0。同理也有一条会产生共识结果1。所以集合D里所有状态不仅仅是确定性状态它们一定能产生0和1两个共识结果。
接下来选取两条路径。一条是第一个消息是e的路径假设对应的共识结果为0。由于初始状态是非确定性的所以剩下的路径中一定有一条产生不一样的共识结果。
如下图所示我们选取另一条产生共识结果为1的路径。如果这个路径不穿过集合D那么就可以按照上面的步骤添加消息e到路径最后面这样这条路径一定可以穿过集合D。
然后我们调整第一个消息e的接收时间一步一步往后挪这时候会产生一些新的穿过集合D的路径。那么对于中间的几个可能的路径它们的共识结果是什么呢
<img src="https://static001.geekbang.org/resource/image/b4/19/b4f14b3be9a90yy8505f9f40b40d6f19.jpg" alt=""><br>
不管这些路径的共识结果是多少和第一步证明类似我们一定可以在集合D里找到两个相邻的路径而它们的共识结果刚好相反。
如下图所示我们假设状态C0在收到消息e后会进入状态D0D0最终输出共识为0。状态C0收到消息f后会进入状态C1C1收到消息e后会进入状态D1D1会输出共识结果为1。
<img src="https://static001.geekbang.org/resource/image/aa/26/aaf2797e2efcb172f6886fb945bdd226.jpg" alt=""><br>
到这里为止最关键的4个状态我们已经找到了。反证法的下一步是对上图消息f和e进行分析看看**这两个消息的接收方**是否一样。
我们接下来会证明,**不管这两个消息的接收方是否一样如果集合D的所有状态都是确定性状态最终都会有逻辑矛盾。**
下面进入分情况讨论的环节。首先我们先看看**消息的接收方不一样是什么情况。**
这时候我们将上图D0和D1之间增加一个消息f也就是把消息f的接收时间调整到消息e之后。这表示我们有两条从C0状态到D1状态的路径。第一条是先接收消息f然后接收消息e。第二条是先接收消息e然后接收消息f。如下图所示
<img src="https://static001.geekbang.org/resource/image/8e/89/8e156507c51922439b17fca2111d4589.jpg" alt=""><br>
这时候出现了一个悖论。消息e和f对应了两台不同的机器它们互不影响所以e和f的接收顺序并不影响最后的共识结论。那么上图从C0到D1的两条不同路径最终应该导致同一个共识结果。这样看来这个菱形的关系是正确的。
但是请你注意我们在反证法里假设了状态D0是个确定性状态它不能既产生共识结果0又在接收消息f后产生共识结果1所以这个菱形关系和我们之前的反证法假设矛盾。
那我们再来看看**如果e和f的接收方一样又会出现什么情况。**我们假设从状态C0开始接收这两个消息的机器就出了故障无法运行。系统在经过了一条路径g后到达最终状态A并且产生共识结果。因为无限路径和共识算法的终止性相矛盾所以路径g的步数是有限的
如下图所示状态A究竟会产生什么样的共识结果呢
<img src="https://static001.geekbang.org/resource/image/15/d6/15b6b8ef60d2c1af691cf5b60cf853d6.jpg" alt="">
先看上图的左边我们构造两个场景。第一个场景是这样的。假设分布式系统从状态C0通过路径g到达状态A之后原来一直出问题的机器突然恢复了。碰巧这时候消息e也刚刚到达分布式系统会到达一个新的状态E0。
另一个场景从状态D0开始。由于消息是可以任意延时的我们可以将路径g贴在状态D之后这样状态D0在经过路径g后也会到达一个状态。那么问题来了如下图所示这两个场景最终会达到同一个状态E0吗
<img src="https://static001.geekbang.org/resource/image/d4/80/d4abab9815db9844de0a8941543f4780.jpg" alt="">
答案是会的。接收消息e的机器在路径g中一直无响应所以消息e和路径g没有共同的机器两者之间互换顺序不会影响最终结果。因此上图左边的菱形是合理的。两条不同的路径都会到达同一个状态E0。
按照反证法的假设D0是一个确定性的状态最终生成的共识为0。所以E0最终会达到共识0这也意味着状态A也应该达到共识0。
同理我们也可以将右边用两条新的路径补全。一条是从状态A开始添加一条f+e的路径。另一条是从状态D1开始增加一条路径g。这样右边也会达到同一个状态E1。由于状态D1会达到共识结果1状态E1也会到达共识1。下图画出了补全之后的情况
<img src="https://static001.geekbang.org/resource/image/7d/10/7dd6139a76ea22d542157b9fcab10e10.jpg" alt="">
所以A这个状态既可以达到共识结果0也可以达到共识结果1。这意味着A是一个非确定性状态这和我们前面假设A是一个确定状态相矛盾。
好了到目前为止我们证明了不管e和f这两个消息的接收方是否是一样的如果集合D的所有状态都是确定性状态最终都会有逻辑矛盾。所以反证法的假设不成立也就是说**集合D里一定存在非确定性的状态。**
## 第三步构造
证明的第三步是构造出一个不会终止的共识过程构造过程很简单。按照第一步证明存在一个非确定性的初始状态和它对应的第一个接收消息e。如下图所示
<img src="https://static001.geekbang.org/resource/image/06/86/06a40d80b1ac024203f1be0b1d19c486.jpg" alt="">
按照第二步的证明我们能够找到下一个非确定性状态这个状态刚刚接收了消息e。如下图所示
<img src="https://static001.geekbang.org/resource/image/7a/3d/7a576c81a1d2436f69a8ea1626d6773d.jpg" alt="">
这样每当到达了一个不确定性状态我们可以将消息e往后挪从而制造出下一个不确定性状态。由于这个过程可以永远重复下去系统会永远处于非确定性状态这就违反了共识算法的第一个特性**终止性**。示意图如下:
<img src="https://static001.geekbang.org/resource/image/64/fb/64f5cfd4c9c79bd417e3a0693f0d8ffb.jpg" alt="">
## 思考题
1996年的论文"Unreliable failure detectors for reliable distributed systems"证明了,如果在分布式系统里,存在一个能让你最终做出准确判断的不准确时钟,那么系统存在共识算法。这个时钟起到的作用是在分布式环境下,检测机器是否出问题。
失败检测分为两种属性完整性和准确性。按照排列组合一共有4种可能的情况
1.强完整性。所有正确的节点都会最终怀疑每个出错的节点。<br>
2.弱完整性。一些正确的节点都会最终怀疑每个出错的节点。<br>
3.强准确性。所有正确的节点都不会被怀疑出了问题。<br>
4.弱准确性。一些正确的节点不会被怀疑出了问题。
论文指出,就算只有很弱的失败检测,也能实现共识算法。你觉得这里的“弱”是指哪几种情况呢?
欢迎你在留言区跟我交流互动。如果学完这节课让你有收获的话,也欢迎你转发给同事、朋友,一起学习、探讨共识算法不存在的证明过程。

View File

@@ -0,0 +1,199 @@
<audio id="audio" title="16 | 分布式一致性(下):怎么理解最简单的分布式一致性算法?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/45/y5/4501d5fa6b52a313497157b79accbyy5.mp3"></audio>
你好我是任杰。这一讲我想和你聊一聊怎么理解最简单的分布式一致性算法Raft。
在[第14节课](https://time.geekbang.org/column/article/336686)里我们学习了在有容灾的分布式环境下的各种不同一致性情况其中最重要的是线性一致性。线性一致性有非常多的好处但是它的实现却非常困难。20多年前计算机科学家终于找到了一个算法但是非常晦涩难懂。
直到2014年才出现了一个通俗易懂的算法它就是Raft。从此以后各种具有分布式一致性能力的数据系统便层出不穷普通人也有能力和大型互联网公司一样设计出一个在分布式环境下正确的系统。
既然Raft算法通俗易懂实现细节我就不展开了这节课我们重点来看看Raft算法究竟能做什么以及它应该怎么使用。
## 分布式一致性能解决的问题
### 文件同步
能实现分布式一致性的算法也叫作共识Consensus算法。我们在[前一节课](https://time.geekbang.org/column/article/337308)说过共识需要满足以下3个条件
1.终止性termination<br>
2.一致性agreement<br>
3.有效性validity
我们不是理论科学家,不需要去理解这些深奥的定义,而且就算我们理解了,也不一定能指导实际的开发工作。所以,我们需要的是一个更简单的结论。通俗一点来说,**共识表示在多台机器之间,能同步一个内容不断增加的文件。**
同步一个文件乍看起来非常简单,很多人都有远程拷贝文件的经历。但是,如果装有文件的机器出了问题怎么办呢?你可能会尝试找下一台有文件的机器重新拷贝。
不过这还没完,我们再深入想一想,你怎么确保你找的机器刚好就有完整的文件呢?而且文件内容还在不断增加,你又怎么能确定增加的内容也是正确的呢?
多亏有了共识算法,它帮我们解决了分布式环境下的这些不确定性问题,最后留给了我们一个确切的结果:那就是只要大部分机器都能正常工作,那么这些机器上的文件就是完全一样的。
这时候,机器上的文件可以分为两部分。一部分是确认已经同步好了的内容,这部分的内容你可以放心使用。另一部分是正在同步中的内容,这些都是临时内容,暂时不能使用。下面这幅图展示了这两部分内容的区别:
<img src="https://static001.geekbang.org/resource/image/b0/5e/b0afaeyydbb8429c351487ccf995f75e.jpg" alt="">
光说理论太抽象了,我们来看看共识算法最简单的一个应用场景,那就是跨机器的文件同步。比如你可以用共识算法来同步图片,但是这对于共识算法来说就有点小儿科了。**共识算法更多是被用来同步非常重要但是数据量不大的文件。**
那么,哪些数据是重要但不大的文件呢?
比如我们在进行云计算的时候,就需要知道机器都是哪些。这些描述信息都很重要,但是数据量不大,它们就可以存储在共识后的文件里。顺着这个思路,常见的配置文件信息、路由信息等等也都符合刚才说的特性,通常都会存储在有共识能力的数据系统里。
### 全序广播和线性化存储
不过,这么复杂的共识算法如果只是来解决文件的存储,依然还是有点大材小用。它真正的威力体现在能实现全序广播和线性化存储。
#### 全序广播
全序广播的学名叫Total Order Broadcast。从名字就可以看出来全序广播和广播的顺序有关系事实也确实如此。在分布式环境下的全序广播需要满足这两个条件
1.所有机器的消息都不能丢失。如果一台机器上出现了某个消息,那么这个消息一定能在所有其他机器上找到。<br>
2.所有机器记录的消息的顺序完全一致。
如果你把记录消息当作往文件末尾增加一行内容的话,全序广播的要求是不仅仅要同步文件,而且要求文件内所有内容的顺序也完全一致。
所以,**全序广播可以用来解决对文件内容顺序要求极其严格的场景。**比如在分布式环境下,数据库需要做数据的容灾备份。一种可行的做法是将数据库的日志文件通过全序广播的方式,广播到所有容灾节点。
在金融系统中,一个常见的对顺序要求很严格的场景是会计系统的账本。会计账本要求记账的顺序不能错,所以在对会计账本做数据容灾的时候,也可以采用全序广播的方式,这样可以保证账本能正确地备份到容灾节点内。
下面这幅图给你展示了全序广播。你要注意看里面的内容顺序:
<img src="https://static001.geekbang.org/resource/image/d9/10/d943f4dd4bb8b889c2b3924633236010.jpg" alt="">
顺便给你说一下,区块链的技术其实就是用共识算法生成了一个分布式的会计账本。
不同点在于,区块链的共识算法假设有的节点是恶意的,因此解决的是拜占庭问题。我们这个系列的假设是节点不是恶意的,因此解决的是非拜占庭问题。由于非拜占庭问题里有额外的正确性假设,算法可以用更少的资源来达成共识,速度也会更快。
#### 线性化存储
说完了全序广播我们再来看看线性化存储。线性化存储的学名叫Linearizable Storage。
你也许发现了线性化存储中的“线性化”和我们在第14节课讲的“可线性化”好像一样。没错其实它们俩就是一个东西只不过之前我们学的“可线性化”是理论线性化存储是满足这个理论的实现。
线性化存储是一个分布式的数据存储集群。这个集群给你提供一些数据操作,比如你可以修改数据,或者读取数据。线性化在这里保证了你的操作是可线性化的。
那什么叫“操作是可线性化”呢我们来重温一下第14节课的内容。如果一系列操作是可线性化的那么你就可以把这些操作重新排序。排序之后这些操作能先后顺序执行并且最后生成合理的结果。
既然我们花了这么大精力讲解这么深奥的定义,那它一定能解决很复杂的问题。没错,**线性化存储能解决的一个标志性问题是分布式锁。**
你如果学过数据库,那么一定知道单机版的锁。锁操作的顺序非常重要。谁先加锁,谁就能访问资源。后加锁的人必须要等前面的人释放了锁之后,才能加锁成功。
在单机版的情况下锁的实现非常简单一般会利用特殊CPU指令甚至用纯软件也可以实现。
但是在分布式情况下,锁的实现会变得非常复杂。这是因为如果只用一台机器来保存锁的状态,那么这台机器可能会出问题。
但是如果复制锁的状态到其他机器那么就会出现我们在第14节课讲到的各种不一致情况。
尽管线性化存储是由多台机器组成的,但你在所有这些机器上,操作的顺序都是完全一样的。因此,如果你在一台机器上获得了锁,那么你在其他所有机器上也获得了这个锁,这样就真正实现了分布式锁。
有了分布式锁之后,数据系统就有了很多高级的处理能力。比如你可以在云计算环境提供一个有容灾能力的锁服务,然后用这个锁服务来实现分布式事务。
#### 全序广播等价于线性化存储
分布式理论有一个很有用的结论是,共识能力等价于全序广播,也等价于线性化存储。因此全序广播和线性化存储之间也是等价的。
既然这两者是等价的,那么我们就可以放心地去使用了。不过,这里我还是给你简单做一下证明,在证明的过程中会用到**分布式状态机**这个概念。
分布式状态机是我们[第7节课](https://time.geekbang.org/column/article/326583)事件溯源设计的分布式版本,我会在下节课给你详细讲解。这里我们先看看它能做什么。
首先,我们说说怎么用线性化存储实现全序广播,实现方法很简单。线性化存储可以实现分布式锁,你在广播任何一条消息之前,会先获取一个分布式锁。
当你拿到锁之后,给所有机器发送这条消息。等你确认了所有机器都收到了这个消息之后,再释放锁。
接下来,我们再看看怎么用全序广播实现线性化存储,这里就需要分布式状态机了。你将想加锁的命令通过全序广播发给所有机器。每台机器上都有自动机。你的加锁操作是事件溯源架构里的命令,而所有已经获得了的锁是事件溯源架构里的状态。
如果命令是合理的,也就是说现在还没有其他人有这个数据的锁,那么自动机会生成的事件溯源架构里的事件,也就是你已经获得了锁,并接着更新状态,同时将结果通知给你。这样就实现了分布式锁。
## 怎么使用Raft算法
前面我们已经了解了分布式一致性能解决的问题但实际使用时想要用好Raft算法还是有不少注意点我们分别从客户端和服务端两个角度来看看。
### 客户端
由于Raft算法是一种共识算法所以我们可以通过Raft来实现文件同步、全序广播和线性化存储。一般来说我们会很少自己实现Raft算法而是通过云服务来访问集群提供的功能。
这时候作为客户端你需要了解应该怎么使用Raft才是正确的。否则尽管集群提供了共识能力你的使用方法不对还是会出错。
Raft算法有一个**主从**Leader and Follower的概念。在任何一个时间点整个集群最多只有一个主节点其他的都是从节点。
主节点是唯一会处理你请求的节点,所以你所有的请求都需要发送给这个主节点,主节点负责将你的请求正确地同步给剩下的从节点。
共识算法对同步的正确性有一个定义。当这个定义满足了之后,主节点会通知你请求已经处理成功。你需要注意的规律是**每台机器的主从角色是会一直变化的,但是共识算法会保证不管怎么变化,集群里最多只有一台机器是主节点。**
讲完主从的概念之后我就要说说想正确使用Raft算法我们第一个需要注意的事情那就是你只能访问主节点。
分布式环境下机器会宕机,因此主节点也会出问题。这时候你需要不断尝试集群剩下所有的节点,找到谁是主节点。
如果Raft实现过于简单你很难分出谁是主节点这时候你需要一台一台机器去问如果问不出来就一直轮询。
Raft实现得好的话如果你问的机器不是主节点它会告诉你它心中的主节点是谁。你按照这个提示去找的话有很大概率能找到真正的主节点。如果它恰巧不再是主节点了那么你会拿到下一个提示最终你会找到当前真正的主节点。
我们再来看看第二个注意点,那就是怎么判断自己的消息已经被处理了。我们在[第2节课](https://time.geekbang.org/column/article/324117)讲异步处理架构的时候提到过异步系统的请求有三种分别是成功、失败和不确定。Raft算法也有这三种状态。
如果Raft告诉你消息成功处理那么消息一定是通过全序广播保存到了所有正常的机器上你可以放心地处理下一个消息。
如果Raft告诉你失败那么绝大多数情况是因为你访问的机器不再是主节点。这时候你需要再次寻找主节点在哪里。
如果Raft什么都没有告诉你那有可能服务器端已经成功处理或者处理失败。这时候你需要假设处理失败然后重发请求。重发请求可能会造成同一个请求重复多次因此服务器端一定需要有去重的能力。
### 服务器端
从用户的角度来看服务器端主要是要考虑容灾能力。Raft协议的正常工作不需要所有机器全都在线只要多于一半的机器在线就可以了。
因此。我们通过Raft实现的全序广播或者线性化存储都具有一定的容灾能力。我会在后面的第20节课里详细讲解应该如何选择容灾的力度。
## Raft算法核心概念
说到这里你应该能正确地使用Raft算法了。
不过我还是建议你再稍微了解一下Raft算法的核心概念这样你在使用Raft算法的时候就能弄清楚为什么你的使用是正确的这就是我们在开篇词里提到的知其然而知其所以然。
Raft里每个节点都有三个状态主节点、从节点和候选节点Candidiate
主从节点我们在前面已经讲过了。候选节点是未来可能的主节点。如果某台机器发现集群里好像没有主节点了,那么它会把自己变成候选节点,然后尝试通过一个选主过程将自己变成主节点。因此,系统中可能会有多个候选节点存在。
下面这幅图展示了节点的这几个状态变化:
<img src="https://static001.geekbang.org/resource/image/fd/04/fd2e9b48d736b61f8c6889a9ffe25e04.jpg" alt="">
Raft算法里有一个重要的概念是**任期**Term。任期是一个不断递增的正整数。每个成功当选的主节点都有自己的任期数。随着时间的推移历任主节点的任期数一定不断在增加绝不会不变或者倒退。
在正常的情况下,主节点会通过心跳机制将自己的任期数定时发给所有其他节点。节点在收到主节点的心跳消息之后会保持在从节点状态。我们一般把这个心跳过程叫作**主节点的压制效果**。
一旦分布式系统出了问题,比如断网或者主节点消失,主节点就无法再压制其他节点。这时候节点会纷纷将自己的状态变为候选节点,参与选主过程。
选主过程很复杂,不过你跟着我的思路来理解,就能把它弄明白了。
简单来说,每个节点将自己收到的最后一个任期数加一,然后问其他节点自己的任期数是不是最高的。
如果有一半及以上的节点同意你的任期数是最高的,那么你就变成了主节点,同时通过心跳机制压制其他所有节点,阻止集群中再出现新的主节点。
最后我们再说一下Raft的存储。Raft实现了线性化存储因此在本地会维护一个自增不减的日志文件里面记录了所有的用户请求。这些请求的前面一部分是已经同步过了的内容而后面一部分是正在同步的内容。已经同步过的内容是安全的你可以放心访问。
Raft这个算法我们就说到这里。Raft虽然是最简单的共识算法但是它依然比较复杂主要体现在如何实现选主过程和主节点的压制过程上。如果你有兴趣可以去[Raft的官方网站](https://raft.github.io/)查看算法和论文的细节。
## 小结
这节课我们学习了怎么理解最简单的分布式一致性算法Raft。
首先,我们分析了分布式一致性能解决的问题。分布式一致性在分布式环境下能正确地同步文件,同时也能做全序广播和线性化存储。
接下来我们又讨论了如何使用Raft算法。Raft的客户端需要永远只访问主节点。如果主节点没有反馈消息处理成功那么你就需要一直重试。Raft的服务器端可以根据情况选择容灾能力的大小。
Raft的算法有两个核心概念。一个是节点的状态分为主节点、从节点和候选节点三种。另一个概念是任期。算法的运行阶段被分为一个个的任期任期由一个不断增加的任期数来表示。Raft算法能在分布式环境下计算出正确的任期数和节点状态。
金融系统的核心组件在分布式环境下一般要求具有线性一致性,因此无论是核心组件自己,还是周边的数据系统,都需要通过共识算法来实现线性一致性。
偏底层的基础架构需要用共识算法来实现线性化存储,以及用线性化存储实现的分布式锁和分布式事务。而偏上层的应用需要用共识算法来实现分布式状态机,保证业务在多机情况下的一致性。
<img src="https://static001.geekbang.org/resource/image/40/11/409abc25e8bcc6fa871c483c873dc511.jpg" alt="">
## 思考题
我们在第14节课讲了分布式事务。分布式事务的原理是通过协调者的本地事务来协调各个节点的事务执行状态。
因此,分布式事务能不能正确运行,这取决于协调者的本地数据库。这个本地数据库就是系统的单点,一旦出了问题,整个分布式事务就不能顺利进行。
所以,为了提高分布式事务的容灾能力,我们需要解决协调者的单点问题。那么问题来了:
1.你能分析一下,这个问题的本质是我们这节课讲的哪个问题吗?<br>
2.如果你要解决单点问题的话,可以怎么解决呢?
欢迎你在留言区记录你的疑问和收获。如果这节课对你有启发也欢迎转发给你的同事、朋友和他一起探讨Raft算法的应用。

View File

@@ -0,0 +1,161 @@
<audio id="audio" title="17 | 正确性案例(上):如何实现分布式的事件溯源架构?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/17/yb/17e5d52db7de6f908c3ea6633151byyb.mp3"></audio>
你好,我是任杰。这一讲我想和你聊一聊怎么实现分布式的事件溯源架构。
在[第7节课](https://time.geekbang.org/column/article/326583),我们讲了单机版的事件溯源架构。尽管这个架构处理能力快,但是单台机器的处理能力毕竟有限,而且也不能保证系统有容灾能力。
所以,这节课我们一起来看看,如何一步一步解决系统扩容和容灾的问题。这里我先做个提示,因为这节课会用到很多前面讲过的内容,必要的地方我会给你说明关联到前面哪一节课。我建议你先把握整体思路,有弄不懂的,可以再温习一下前面的内容。
这节课要讲的解决问题的思路,不仅仅适用于事件溯源架构,很多和计算及数据相关的系统也会碰到同样的挑战。所以,你在学习这节课时,重点要放在理解为什么会有这些问题,以及为什么有这些解决方案,而不是放在解决方案的细节上。
## 多机容灾
我们先来看看分布式环境下我们能解决的第一个问题,那就是容灾。
容灾的思路是花钱来换取服务质量。如果单台机器出问题之后无法对外提供服务,那么只要我们能把同一个功能部署在多台机器上就行,这些机器作为一个整体对外提供服务。如果一台机器坏掉了的话,只要集群里还有其他的机器,那么就能再找一台机器,替换掉前面那台坏掉的。
刚才的分析看似正确,但隐含着三个重要的假设。这几个假设会直接影响到我们的架构能达到的正确性级别。
我们先来分析一下这个思路里的假设。第一个问题是我们对正确性的描述很模糊。如果同一个功能可以由多台机器提供的话,那么就会出现在[第14节课](https://time.geekbang.org/column/article/336686)提到的单调读一致、单调写一致等各种弱一致性问题。
金融系统在分布式环境下,很多时候的要求会比较高,因此需要达到线性一致性的一致性级别。这时候常见的实现通常是整个集群的功能由一台机器来提供。这个特殊的机器就是主节点。
然后,我们来看看第二个假设。主节点是单台机器,因此会出现单点故障。当单点故障出现了之后,我们需要在剩下的机器里再找出一台机器来替换之前的主节点,所以这就是一个选主的过程。
再来看看第三个假设。我们不是简简单单地随便找一台机器来替换主节点。我们在这里有一个隐含的假设,那就是这个替换的机器是正确的,而且在主节点出问题后,它能够挺身而出,接手主节点之前所有的工作。更专业的说法是,我们希望替换节点和主节点完全一致。这个特点就是我们在[第16节课](https://time.geekbang.org/column/article/338389)提到的全序广播。
所以综合这三点假设,我们要求,在有线性一致性的情况下,容灾需要集群使用分布式一致性算法,这样就拥有了主节点,有换主过程,以及通过全序广播达到的所有节点状态一致。这就是为什么在对有状态机能力的系统进行容灾时,我们一定会选用一致性算法。
我们分析完了容灾的所有假设之后,再来看看为了支持容灾,我们需要对事件溯源架构做哪些修改?
一个很直接的修改方法是将事件溯源架构部署在多台机器上,通过一致性算法来复制命令队列,就像下面这幅图展示的一样。我们把这种部署方式叫作**复制状态机**Replicated State Machine
<img src="https://static001.geekbang.org/resource/image/68/13/68c6ab685532da39859b195962039b13.jpg" alt="">
这样做确实能对命令队列做多节点的容灾。但是又会出现一个问题。我们在讲事件溯源的时候提到过,**命令变为事件的过程可以具有随机性**。如果主节点出了问题,那么就算命令完全一样,新的主节点依然有可能生成不同的事件。**所以上面这个方法,只适合从命令到事件的转变没有任何随机性的情况。**
因此,为了保证事件溯源架构有全序广播的能力,我们需要用一致性算法来同步事件队列,而不是命令队列。下面这幅图的右边部分是正确的复制位置,你可以左右对比着看:
<img src="https://static001.geekbang.org/resource/image/yy/75/yy0ecf4f46e0dc03193704d627364775.jpg" alt="">
那架构升级到这里就结束了吗?其实还没有。
我们在第7节课学习单机版的事件溯源时没有说过用户如何知道自己的消息已经被正确处理了。这是因为单机版的情况下消息的处理和结果的返回都是由一台机器完成的没有什么不确定的情况。
但是,等我们用到了事件溯源的复制状态机版本后,同一时间可能会有多台机器在处理同一个命令。那怎样才能保证用户能收到正确的处理结果呢?
这时候就需要对状态机的能力做一些限制了。我们要求**只有在当前机器是主节点的情况下,这台机器才能对外进行通讯。**如果机器是从节点,那么它只能复制事件队列和更新内部状态,而不能返回执行结果给用户。
因此,在复制状态机版本的事件溯源框架里,只有主节点可以往集群外写消息执行状态,比如通知处理结果给用户。那问题都解决了吗?其实还没有。
集群的主节点并不是一直是一台机器,有可能计算到一半的时候,主节点换到了另一台机器。这时候有一个很实际的问题是,用户和原来的主节点之间的网络链接,它在换主之后就断掉了。新的主节点怎样才能通知到原来的用户呢?
这时候就需要有一个中间层,用来隐藏事件溯源是由一组机器组成的这个细节。这个中间层的学名是**反向代理**。
反向代理是服务器集群的一部分。对于用户来说反向代理就是集群服务器的代表。反向代理负责和用户之间维持TCP长链接这样用户和反向代理之间可以一直互通消息。反向代理负责将用户请求转给合适的节点并将节点的计算结果转还给用户。
下面这幅图给你展示了加了反向代理之后,分布式事件溯源架构是什么样子的:
<img src="https://static001.geekbang.org/resource/image/fb/00/fb76bc19378cc50b6f2ee064548ae500.jpg" alt="">
## 数据查询
分布式环境下,我们要考虑的第二个问题就是数据查询。我会从常规查询和一致性读两个方面带你分析。
### 常规查询
我在第7节课给你讲过如何实现事件溯源架构的查询当时说过我们需要用到**读写分离的架构**。读写分离的架构要求我们实现一个读模式的状态机。那我们来看看在分布式情况下,读模式状态机应该如何实现。
读模式状态机的核心原理是复制写模式状态机内的事件队列,通过复制事件队列来达到复制状态的结果。因此我们需要做的是,从复制状态机版本的事件溯源框架里复制事件。那问题来了,这么多台机器都有事件队列,从哪台机器上复制会比较好呢?
答案很简单,**从任何一台机器上复制都可以。**这里就需要用到Raft一致性算法的一个特性它能保证所有节点上的已同步数据都是正确的。每台机器可能同步的数据不一样多但是只要数据被标记为已同步那么数据就一定是正确的。
这样我们就很轻松地解决了第一个问题,那就是从哪里复制事件队列。这时候你有两种选择。一种是从主节点上复制数据。如果这样选择,你很有可能可以获取到最新的数据,但是问题在于这会加重主节点的压力。
为了避免主节点压力过高,我们就要说到另一种选择了,也就是到从节点上获取数据。因为从节点不需要处理命令,也不需要复制数据给其他节点,所以**从节点压力比较小**,多加一个数据访问不会有太大影响。但是**从节点有一个问题是它可能会有很大的数据延时。**如果从节点一直处在和主节点断开的状态,你就无法访问到更新的数据。
因此,你需要结合自己的情况来选择是从主节点还是从节点复制事件队列。下面这幅图给你展示了这两种不同的选择:
<img src="https://static001.geekbang.org/resource/image/f5/bb/f597fa61fe6c0a7576c959a947128bbb.jpg" alt="">
### 一致性读
刚才说的常规查询其实还不完善因为通过读模式的方法实现的查询可能会出现一些常见的分布式环境问题。我们在前面第14节课讲会话一致性的时候提到过单个会话有单调读一致和自读自写这两个一致性级别。这两个级别在读模式的情况下都不能满足。那怎样才能保证读的正确性呢
答案是要**一致性读**Consistent Read。一致性读解决问题的思路是在分布式环境下将读和写之间的操作进行排序从而达到线性一致性。由于线性一致性比单调读一致和自读自写的一致性要高所以也就解决了分布式环境下会话会出现的问题。
那具体应该怎么实现呢?过程分为两步。第一步是将查询发送给事件溯源的写节点。注意,这时候一定不要发给读模式的节点。
事件溯源需要命令和事件。对于查询请求来说,它的命令和事件什么事情都不做,因此是个空操作。我们需要的是,通过共识算法的线性一致性对查询请求进行正确的排序。
写节点处理完之后,我们就到了第二步。这时候写节点通过反向代理将查询结果返回给用户。你会发现一致性读和一般性的写过程完全一样,唯一变化是需要有特殊的命令和事件。
## 分库
### 业务处理
最后我们来看一下分布式事件溯源框架的分库处理。我会在[第19节课](https://time.geekbang.org/column/article/341048)给你讲解,如何做动态的分库,这里我们假设已经分库完毕,我们看看分库之后需要怎么处理。
为了方便你理解,我对架构和业务做了一些简化。接下来,我会用一个节点代表通过一致性算法实现容灾能力的多个节点。同时我们假设把一个节点分为了两个节点,各处理一半的情况。业务也选择了账务系统。
系统中一共有两个账号,分别是 `x``y` 。俗话说一生二,二生三,三生万物。如果我们能将节点一拆二,那么更多分库的情况我们也能够解决。下面这幅图展示了简化版的系统和业务情况:
<img src="https://static001.geekbang.org/resource/image/79/03/79441ed66b4889b1af588b9e74cdc903.jpg" alt="">
那我们来看看,分库之后的事件溯源架构应该处理哪些问题。事件溯源要求读写分离,所以我们也按照这个思路,先看看写的情况下有哪些需要考虑的地方。
先看看反向代理。在没有分库之前,反向代理的作用是作为用户和系统之间的一个桥梁。系统内的节点虽然有多个,但是这些节点之间的状态是完全一致的。反向代理只需要找到正确的主节点就行。
在分库之后,系统会出现多个不同状态的节点。这时候每个节点只能处理一部分的业务,不能处理所有的业务。因此**反向代理这时候需要有路由的能力**,它能够根据业务逻辑来选择哪些消息应该送往哪些节点。
所以这时候反向代理需要变成路由,它的内部要维护全局的路由信息。更新后的架构图如下:
<img src="https://static001.geekbang.org/resource/image/fc/3a/fc855e76e7fd2cc05b1284b311d56d3a.jpg" alt="">
### 分布式事务
在分库之后,我们可能会遇到一个事务跨多个节点的问题。比如上面这个例子,如果发生了一笔从 `x``y` 的转账应该如何处理呢?
我在[第13节课](https://time.geekbang.org/column/article/335994)给你说过这时候我们需要用分布式事务来解决跨节点的事务问题。常用的分布式事务实现方法2PC和TCC都需要用一个协调者来维护分布式事务的状态。这个协调者的作用非常重要但是它是个单点一台机器如果出问题会导致所有分布式事务都无法进行下去。
在第13节课的时候我们还没有足够多的理论基础来解决这个问题。不过现在我们已经准备好了所有的工具。那让我们来看看应该怎么解决协调者的容灾问题吧。
协调者的容灾和事件溯源架构的容灾一样,都需要用多台机器来解决单点问题,因此需要用到复制状态机,将单个节点的内容正确的复制到多台机器上。数据库没有命令或者和事件队列,但是一般会有操作日志。
所以,一种办法是将操作日志通过共识算法同步到多机,其他机器通过日志来恢复最新的状态。这就是一种常用的分布式数据库的实现方式。
## 小结
这节课我们学习了如何将单机版的事件溯源架构扩展到多机。
多机情况下,首先要解决的是单节点的容灾问题。我们需要用复制状态机来解决多节点的状态一致性问题,因此需要用共识算法来复制事件队列,用共识算法的全序广播能力实现复制状态机。
事件溯源的读写分离架构在分布式情况下改变不大。任何一个
节点都可以作为读模式节点的数据源。但是这个方案不能满足单调读一致和自读自写的要求。因此,我们可以选择对读操作进行跟写操作一样,通过共识算法同步到所有节点。这样,我们就能用共识算法的线性一致性,来达到会话中的读写顺序正确性了。
解决完单个节点的容灾问题之后,我们再通过分库分表解决扩容问题。分库分表要将反向代理升级为路由节点。分库之后的协调者本身也有单点问题,所以我们可以通过共识算法,把数据库的操作日志同步到多台机器上,这样就可以实现分布式数据库的容灾了。
<img src="https://static001.geekbang.org/resource/image/4c/2d/4c6f4c1b9a87f41dc748ae7cb87e692d.jpg" alt="">
## 思考题
不知道你还记不记得一首关于“推敲”的古诗:
>
题李凝幽居
>
<p>贾岛<br>
闲居少邻并,草径入荒园。<br>
鸟宿池边树,僧敲月下门。<br>
过桥分野色,移石动云根。<br>
暂去还来此,幽期不负言。</p>
传说作者贾岛在作诗的时候,对于是用“推”还是“敲”犹豫不决。我们在设计金融系统的时候,也会有类似的选择困难症。
比如说,读模式的状态机需要复制事件队列。在复制的时候一定有两个选择,一个是主动将事件队列拉过来,另一个是将事件队列推过来。那你会选择推,还是拉呢?你觉得不同选择都有哪些优缺点呢?
欢迎你在留言区分享你的思考和疑问。如果这节课对你有帮助,也欢迎转发给你的同事、朋友,跟他一起学习进步。

View File

@@ -0,0 +1,195 @@
<audio id="audio" title="18 | 正确性案例(中):常见分布式数据方案的设计原理是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c3/a2/c391f7880c0983edaac05ebea43995a2.mp3"></audio>
你好,我是任杰。这一讲我想和你聊一聊常见的分布式数据系统的设计原理。
**所有的业务系统归根到底都需要处理数据,因此从本质上来讲都是数据系统。**业务系统和一般数据系统只是在处理数据的逻辑上有所不同,它们对于数据的存储、读取、容灾等都有极大的相似之处。
因此,我希望在学完这节课之后,你既能了解常用数据系统的运作原理,更好地使用它们,同时也能举一反三,在今后设计金融系统架构的时候能借鉴一些思路。
大部分的原理我在前面的课已经讲过了,关键之处我还会再次提示,如果有不清楚的内容,你可以回到对应的文章去复习一下。
## Redis
我们先从最简单的K/V存储开始。Redis出现之后一举取代了Memcached成为首选的基于内存的K/V解决方案。Redis的核心竞争力是速度快那我们就来分析一下为什么Redis会拥有速度上的优势呢
首先我们看看Redis处理数据的方式。**Redis默认用单线程处理所有数据。**
单线程是一种能优化延时的解决方案不过单线程虽然适合处理数据但是不一定适合I/O。同一时刻可能会有多个客户端在访问Redis如果用多线程处理的话就会出现多线程造成的加锁冲突。
这时候,**Redis用了我们在[第11节课](https://time.geekbang.org/column/article/332958)讲网络优化时提到的epoll方法**用较少的计算资源来支持大量的I/O并发。
我们再来看看Redis的容灾和高可用。**Redis默认的容灾采用了主从备份的方法。**主节点将内容异步复制给从节点。从节点默认是只读所有从节点的写操作会失败之所以这么做主要是为了简化一些复杂的Redis使用场景比如处理数据过期的问题。
有了Redis的异步主从备份主节点就能更快地返回消息给客户端。同时如果你想让主节点运行得更快还可以取消主节点的本地备份功能。这样主节点不需要写本地文件处理的速度会更快。
Redis这种异步主从备份的方式极大减少了处理的延时。但是按照我们在[第14节课](https://time.geekbang.org/column/article/336686)讲的会话一致性分析,**异步备份和只读从节点的方式无法满足单调读一致和自读自写的一致性要求**。
如果为了**更高的一致性要求,需要读操作也在主节点发生,这时候就能满足线性一致性**。但是要注意,**这个线性一致性假设主节点不能出问题。**如果主节点出了问题的话,异步备份可能会丢失数据,所以整个集群依然不是线性一致性的。
接下来,我们再看看本地数据备份的两个优化选择。主节点可以选择将一部分数据保存在本地,之后可以用这些数据来恢复单机状态。**一个选择是RDBRedis会定期将内部状态保存到本地硬盘另一个选择是AOFRedis会将操作日志实时保存到本地硬盘**。
我们比较一下RDB和AOF在速度上的优缺点。AOF需要一直写文件而RDB只需要偶尔写文件所以**RDB写入数据量小频率低因此速度会更快一些。但是RDB牺牲了数据完整性两次RDB之间的数据无法恢复。**
最后我们来总结一下Redis关于速度上的一些优化思路
1.用单线程和epoll来处理数据。<br>
2.异步容灾,通过牺牲单调读一致和自读自写,换取消息返回速度。<br>
3.用RDB来实现定期的状态备份通过牺牲数据完整性来换取处理速度。
Redis的一个简单架构图如下
<img src="https://static001.geekbang.org/resource/image/4c/47/4ce05bb01a9d1046a751a7709956b647.png" alt="">
## RocksDB
数据系统一般有三件事情要处理分别是数据查询数据保存和数据处理也就是数据的读写和计算。Redis优化的是数据的读和计算接下来我们来看看RocksDB怎么来优化数据的写。
RocksDB和我后面要讲的Spanner、TiDB一样都属于同一类K/V的实现。这些实现都基于SSTable和LSM树。
SSTable的全称是Sorted String Table。其实**SSTable就是一个存储在文件的Map**这个Map的键的类型是字符串在存储的时候这些键是按从小到大的顺序排列的。下面这幅图是SSTable的一个简单例子
<img src="https://static001.geekbang.org/resource/image/98/6c/98e140a8158d916c06f4ddef09f4c16c.jpg" alt="">
你也许觉得单个SSTable没有什么特别之处但是多个SSTable放在一起之后会就出现一个特殊的数据结构它就是LSM树。
**LSM树用多个SSTable实现了一个Map。**LSM树将所有SSTable从上到下排列查询的时候先查最上面的SSTable如果查不到就查下一层以此类推。
我还是结合一个例子给你讲解。LSM树一共有两层。这两层有3个键是重复的分别是a、b和c。当我们想查询a的时候在第一层就发现了数据因此不需要访问第二层的SSTable。
<img src="https://static001.geekbang.org/resource/image/08/be/086dd4b236fd6536e16905af30e4ecbe.jpg" alt="">
但是如果我们换一下查询的条件。这时候查询的是h而不是a。第一层不包含键为h的数据因此查询会继续访问第二层。下面这幅图展示了这个查询过程
<img src="https://static001.geekbang.org/resource/image/4c/3e/4c8617dbc5afc8a1aaf1821f2fd3c93e.jpg" alt=""><br>
那LSM树的这个架构有什么优势呢前面提到过这种架构的写入速度非常快。其实写入速度无论多快肯定也比不上Redis因为Redis可以选择不写文件。所以**LSM树的真正优势是当K/V数据量超过内存大小时基于文件系统的LSM树结构提供了基本的K/V查询能力同时它还具有很高的数据写入速度。**
传统的基于文件的K/V结构是B+树。B+树在更新数据的时候需要对文件做修改。LSM树在更新数据的时候并不会对已有的SSTable进行修改而是在所有的SSTable之上再建一个新的SSTable。
这种方式和MVCC的方法类似MVCC的内容详见[第12节课](https://shimo.im/docs/rdQQHgg88KqRR6Gq)),都是**将一个修改操作变成了新增操作**,这样对文件的所有操作都是在末尾添加新的内容。
[第7节课](https://time.geekbang.org/column/article/326583)讲事件溯源架构的时候,我们说过这种在文件末尾添加内容的操作方式,它能最大程度地使用硬盘提供的写入能力,所以写入速度会很快。
但是**LSM树也有一个缺点就是它的查询速度慢**这是因为随着时间的推移系统可能有很多层SSTable。当你查询一些更新频率不高的数据时很有可能需要读很多层SSTable之后才能知道自己需要的值。
尽管我们有一些优化的方式比如使用bloomfilter来做缓存但是依然无法完全解决。因此从监控上可以看到LSM树的查询延时像一个锯齿很多时候延时都很低但是偶尔会有几秒甚至更高的延时。B+树就没有这个问题,延时都很平稳。
所以如果你对读写的速度要求不高但是希望延时可控那么你需要选择B+。如果你对查询的延时要求不高但是对写入速度要求很高那么你需要选择LSM树。
那RocksDB这些基于LSM树的K/V存储对于分布式系统有什么帮助呢
我们在这节课的最开始提到了Redis的K/V存储和它默认的异步同步机制。
**如果将Redis的RDS快照实现方式换成LSM树那么这个快照就可以实时生成并且时间开销小。这样我们就有了一个具有一定异步容灾能力的K/V集群这个集群能绕开内存大小的限制用更大的硬盘来提供存储能力。**
这就是基于内存和基于文件的两种不同的K/V集群实现方式。这些方式依然有一些问题那就是没法实现会话一致性。按照我们前面介绍的思路如果想要有所得就需要有所失。
如果我们想要有更高的正确性那么就需要牺牲一些速度。Google的Spanner数据库基本上是这方面的开山鼻祖。
## Spanner
Spanner是Google在2012年公布出来的一个全球性分布式关系型数据库它的横空出世惊艳了所有人里面有很多神奇的数据解决方案。我们在平时可能就接触过这些架构和思路但是Spanner是第一个将这些内容整合在一起的系统并且它证明了这个方案是可行的。
有了Spanner开路接下来开源领域就纷纷放手一搏将Spanner的论文吸收引进消化以后再创新出现了很多一致性分布式数据方案这些方案普通人也能使用。
Spanner将传统意义上的单机版关系型数据库按照组件拆分了出来。传统数据库用顺序增长的日志文件来存储数据库的操作这个日志文件可以用来恢复数据库的状态。既然日志文件可以用来恢复本地数据库那么它也可以用来恢复其他机器上的数据库而且如果日志文件同步得足够快那么其他数据库有可能实现实时同步。
但是,跨机器日志文件同步会碰到分布式系统的一致性问题。我在[第16节课](https://time.geekbang.org/column/article/338389)说过,如果在分布式环境下将文件正确地同步到多台机器上,这时候我们需要用共识算法来达到全序广播的能力。
在2012年的时候Raft共识算法还没有设计出来所以Google在当时用了另一种叫**Paxos**的共识算法。
算法虽然不一样但它们都有一样的共识能力。Spanner会先通过Paxos算法将日志文件同步到多台机器然后每台机器通过日志文件实时同步彼此的状态这个方式第[17节课](https://time.geekbang.org/column/article/339289)最后提到过)。
对于数据库来说它的状态其实就是数据库表。我们学习KDB列数据库内容详见[第10节课](https://time.geekbang.org/column/article/332304)的时候说过关系型数据库其实就是个Map也就是K/V结构。它的键是列的名字它的值是每一列的数据。这是一种将数据库表垂直划分的方式。
还有一种是水平划分的方式划分的结果也是K/V结构。数据库表一般都有主键这些主键就是Map的键每一行内容就是Map的值。**因此无论哪种划分方式数据库表最后的存储都是K/V结构。**
既然数据库状态是一个K/V那么只要我们在日志文件中记录了K/V的插入和修改操作就可以用共识算法来实现分布式状态机这样就能实现多个数据库的实时状态同步了。
数据库除了要生成关系型表之外,还需要支持很多重要的操作,比如数据库事务。
数据库事务的实现需要给数据加锁,这就意味着**分布式环境下数据库事务的实现需要分布式锁。**到目前为止,我们只说过一种实现分布式锁的方式,那就是用共识算法实现线性化存储,然后用线性化存储实现分布式锁。这里的内容如果你记不清了,可以回顾[第16节课](https://time.geekbang.org/column/article/338389)的内容。
Paxos算法的节点也有主从之分**。共识算法的主节点会承担起实现分布式锁的责任,同时它也负责维护集群的事务管理**。如果事务需要横跨多个不同的Spanner组那么每个组的主节点会互相沟通选一个主节点作为分布式事务的协调者用两阶段提交的方式来实现分布式事务。
通过前面的学习我们知道了两阶段提交需要有一个协调者这个协调者会成为分布式事务的单点。Spanner在设计的时候分布式事务的协调者其实是一个Paxos算法的主节点因此它本身就有一定的容灾能力不再是单点。
其实我们在上节课学习分布式的事件溯源架构的时候,采用的也是同一个思路。共识算法的主节点承担所有的写入操作和对外沟通工作,其他节点负责同步主节点的修改行为。
下图展示了简化版的Spanner架构图
<img src="https://static001.geekbang.org/resource/image/64/b6/6488115082ee73d6a3c4470465e586b6.png" alt="">
Spanner还有另一个著名的优化技巧那就是用原子钟实现的TrueTime API它用物理的方式优化了事务的实现。这个方法的普适性不高所以在这里不再详细介绍如果你有兴趣可以查看Spanner的论文。
在这里我要特别提到的一点是虽然现在看起来Spanner的设计比较平常用的都是我们常见的数据系统解决方案但是不要忽略先后顺序。现在的数据系统用到的成熟解决方案它们普遍来自于Spanner其实我们只是站在了巨人的肩上而已饮水不忘打井人。
## TiDB
江山代有才人出了解了Spanner我们再看看国内的代表方案。TiDB是国产分布式数据库的领军人物之一它在Spanner的基础上有一些功能的加强。那接下来我们来看看都是哪些架构优化的思路。
在设计Spanner的时候只有Paxos这一个共识算法。但当TiDB出现的时候已经有了Raft算法所以可以用新的算法实现日志文件的全序广播。Raft算法相对于Paxos来说更清晰易懂从工程的角度讲能节省不少的研发时间。
讲Spanner的时候我们已经说了数据库表其实就是一个K/V。TiDB将这个概念直接具现化抽象出了一个TiKV的组件这个组件通过Raft共识算法来实现分布式K/V。你也可以直接使用TiKV。对于TiDB来说TiKV存储了数据库的每一行的信息。
关系型数据库在设计之初是为了解决业务的事务问题,也就是解决每一行应该怎么操作。这样的优化结果却不方便我们分析数据,所以才有了用列存储方案的列数据库。
TiDB在设计的时候将自己定位为既能解决事务问题又能解决分析问题的数据库。那么按照我们之前的介绍TiDB需要有一个列数据库这样才能优化数据的分析。那这个列数据库应该怎么实现呢
这里我来给你讲讲这个架构的分析思路。我们在[第7节课](https://time.geekbang.org/column/article/326583)介绍事件溯源架构的时候提到过一个概念叫做CQRS也就是读写分离。读写分离的一个好处是可以将读和写拆分出来分别优化。
对于TiDB的设计目标来说事务处理是写的部分可以用基于行存储的TiDB来实现。TiDB的另一个设计目标是数据分析而分析显然是读操作因此可以用基于列存储的解决方案来实现这就是TiDB的另一个组件TiFlash。
所以在TiDB中TiKV负责用共识算法实现数据库表的事务功能TiFlash负责用列存储的方式实现数据查询功能。那么TiFlash需要用到共识算法吗
为了回答这个问题,你可以回忆一下[第17节课](https://time.geekbang.org/column/article/339289)讲分布式事件溯源架构时,我们提到的两种数据查询方式,分布式环境下的查询包括常规查询和一致性读两种。
数据分析不需要有一致性读的一致性能力,因此有常规查询的支持就足够了。**常规查询需要用到读模式的状态机**因此TiFlash可以从TiKV的节点上复制日志文件。TiFlash选择只从主节点复制主要是为了节省延时。
TiKV的存储方式和RocksDB类似也用的是LSM树因此写入速度快但是查询速度慢。读写分离的优势是可以分开优化既然写模式的LSM树查询慢那么读模式就需要改变文件的组织结构于是TiFlash用了一些B+树来优化列文件的查询。
下图是简化版的TiDB架构图。
<img src="https://uploader.shimo.im/f/ttLXh1KsnfaAnSq6.png!thumbnail" alt="">
## 小结
这节课我们一起学习了分布式数据系统的设计原理。所有业务系统从本质上来讲都是数据系统,因此我们可以从分析分布式数据系统的架构,来学习怎么设计业务系统架构。
首先来看Redis。Redis是一个基于内存的K/V存储用单线程和epoll来提高数据处理速度用异步主从备份的方式来牺牲一致性从而降低容灾延迟。Redis还可以通过RDB实现定期的状态备份牺牲数据完整性来换取处理速度。
RocksDB是基于文件的K/V存储。它用LSM树来提高数据的存储速度但代价是增加了查询的延时。
Spanner用基于文件的K/V存储实现了分布式数据库。它用Paxos共识算法实现了数据的线性一致性并用共识算法的主节点实现了分布式锁和分布式事务。
TiDB进一步优化了Spanner的设计。它将关系型数据库的存储抽象成了独立的分布式K/V存储负责解决数据库的写入操作同时用基于列存储方式的TiFlash来实现数据库的查询操作。TiFlash通过读模式状态机的方式从TiKV的共识算法主节点同步数据。
从这4个例子我们可以看出一些常用的架构取舍思路。在分布式环境下系统稳定性的提高会伴随着处理速度下降而处理速度的提升会伴随着正确性的下降因此**稳定性、正确性和速度不能三者同时兼顾。**
另外,写入的速度快,那么读取的速度就会慢,如果想读写都很快,那么需要用到多份存储,所以**读、写和存储空间也不能三者同时优化。**因此,在分布式环境下一般不会有正确的架构,只有满足业务需求的合适的架构,这就是分布式环境下架构的艺术性所在。
<img src="https://static001.geekbang.org/resource/image/76/dd/760604339286938350dd63dda03667dd.jpg" alt="">
## 思考题
在大数据之前的时代里,关系型数据库倾向于把所有功能都实现在一起,这样就能充分优化各个组件之间的交互,从而达到很高的单机吞吐量。
但是到了现在这个大数据的时代,单机再也无法承载这么大的数据量,因此需要把数据分散在多台机器上处理。分布式环境下一定存在网络延时,所以单机版的优化并没有之前那么好的效果。
这时候的数据库实现就有了另一个思路。有没有可能把原来的数据库组件都拆分出来,再做一个分布式版本,把每个组件进行独立的分布式横向扩容呢?
这样一来,从整体上看还是一个完整的数据库,但是从实现上来讲是分布式数据库。
关系型数据库有这几个关键的功能:
1.表存储<br>
2.主索引和二级索引<br>
3.缓存<br>
4.表的复制和容灾<br>
5.表查询<br>
6.单机事务<br>
7.分布式锁<br>
8.分布式事务
那么问题来了,如果要求你用现在市面上常用的数据解决方案,来拼凑一个分布式关系型数据库,你应该如何选择,如何搭配呢?
欢迎你在留言区记录你的疑问或思考。如果这节课对你有启发的话,也欢迎你转发给同事、朋友,和他一起交流讨论。

View File

@@ -0,0 +1,199 @@
<audio id="audio" title="19 | 正确性案例(下):如何在运行时进行数据系统的动态分库?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/11/65/11de986425ddb1d84ca4096803c65f65.mp3"></audio>
你好,我是任杰。这一讲我想和你聊一聊如何在运行时进行数据系统分库。
如果你需要进行数据系统分库,那么恭喜你,你的业务量又上了一个新的台阶。但是随之而来也有一个坏消息,那就是分库的过程想做得好的话会很困难。如果做得不好,可能你在每次集群的扩容前,都需要暂停业务,这样会带来一定的经济损失。
所以,为了解决这个问题,我们今天就来学习一下怎么做动态扩容。
## 支持分库功能的架构目标分析
在我们学习如何实现动态分库之前,先来看看我们的设计目标是什么。
最原始的分库方法是先将业务暂停,这样就不会有人修改数据系统。接着要完成数据系统备份,以防出错后回滚所有操作。然后按照预定的逻辑将数据切分到不同的机器上。如果测试结果没问题,那么就重启业务。
虽然这个方法清晰简单,但是带来的问题是业务需要暂停很久的时间。这个过程的示意图如下:
<img src="https://static001.geekbang.org/resource/image/02/e0/027a86710ecc6c79691yy6f91c8a01e0.jpg" alt="">
所以,分库并不是一个不能解决的问题,只是我们希望分库的时间越短越好。那前面说的这几个步骤在时间上还能不能再优化一下呢?其实也是可以的。
数据系统一般都会提供一个异步备份的容灾配置,你可以多备份一台机器。虽然这台备份机并没有所有数据,但是大部分数据都在,所以当你把业务系统停掉之后,只需要把备份机和主机之间的差别补全就可以了。
用备份机来进行分库的办法带给我们一个思路,那就是我们可以**在不停止业务的情况下,解决存量数据的复制问题,停止业务之后解决增量数据的完整性问题。**
但是,这个方法依然存在两个问题。第一个问题是人工干预过多,如果有可能的话应该自动化这个过程。
另一个问题是这个方法和业务强绑定,你需要能找出来主系统和备份系统之间的数据差别,而这一定程度上取决于你保存业务数据的方式。不同的业务可能需要不同的处理方式,所以这个方法通用性很低。
数据系统分完库并不表示整个过程就结束了。我们还要从整个系统来全面考虑分库带来的影响。
数据系统包括数据的生产者和消费者。在分完库之后,数据生产者需要能正确发现并写入新的库,同时,数据消费者需要从正确的库里读取数据。理想的状况是,这个发现并使用新库的过程尽量不需要停机,而且要尽可能自动化。
所以我们总结一下支持分库的架构设计目标。**我们需要尽量减少因为分库带来的相关系统的停机时间,整个分库过程尽量与业务无关,同时尽量自动化。**
## 分库方法
### 架构假设
因为数据系统的架构多种多样,我们并没有一个放之四海而皆准的方法,所以我们在这里要对能解决的数据系统架构做一些限制。
**下面讲的分库方案适用于用事件溯源架构实现的数据系统。**当然了,我们在[上节课](https://time.geekbang.org/column/article/340181)提到过,所有的业务系统从本质上来说都是数据系统,因此,我们的方案也适用于所有用事件溯源架构实现的业务系统。
我们来简单温习一下[第7节课](https://time.geekbang.org/column/article/326583)讲过的事件溯源架构。事件溯源架构的入口是一个按照时间排序的命令队列,以及从命令队列生成的事件队列。
在这里我们不具体区分命令和事件,假设我们在存储的时候选择将命令和事件放在一起存储。因此,我们可以假设事件溯源架构的入口是一个命令队列。
事件溯源架构里的状态机会处理这个命令队列,生成状态。这个处理的过程是完全可重现的,也就是说只要命令队列一样,最后生成的状态必定也一样。
事件溯源架构有一个优化是打快照。快照就是保存在硬盘的过去某个时间点的所有状态。我们可以从快照记录的事件点开始恢复最新的状态,而不需要从头开始执行所有命令。
好了,事件溯源架构的内容就说到这里。我先简单概括一下,接下来要讲的**事件溯源架构的分库过程,它的核心思路就是利用快照来解决存量数据的复制问题,用命令队列来解决增量数据的完整性问题,用状态机的计算能力来达到实时状态一致。**
<img src="https://static001.geekbang.org/resource/image/28/ee/2860e2284b2d49a9f5ee917f978066ee.jpg" alt="">
架构假设我们就说到这里。接下来,我们看看如何将事件溯源节点一分为二。如果能实时地将一个节点一分为二,那么我们就可以通过重复这个过程,把一个节点分成任意多个。
### 分库前
在分库前,我们需要做一些准备工作。首先需要在系统里增加一个新的节点,用来处理分出来的那部分数据。其实这里需要增加的是一个集群,也就是我们在[第17节课](https://time.geekbang.org/column/article/339289)讲过的,具有共识能力的一组分布式事件溯源节点。
假设原来的集群是A我们新增的集群是B。我们在**分库前的目标是将集群A的内容尽量同步到集群B。**
由于我们的目标是将整个过程自动化所以我们在系统中需要再新增一个协调者。这个协调者负责记录当前的分库阶段并且向各个节点发送分库指令。因此在分库前系统内一共有3组节点如下图所示
<img src="https://static001.geekbang.org/resource/image/ab/ab/ab270e6af5ec9c529d8b092yy62478ab.jpg" alt="">
好了,下面我们来看看分库前的数据准备工作要怎么做。
首先我们要发送分库细节给协调者。协调者收到分库命令之后会向集群B发送同步数据的指令。
集群B收到同步数据的指令后它会先从集群A里复制一份最新的快照文件并且按照这个快照文件恢复到过去某个时间点的状态。
我们在第7节课讲过快照文件里会记录这个快照对应的是哪个位置的事件所以集群B恢复状态之后就能知道自己恢复到了过去哪个时间点的状态。
接下来集群B会尝试从集群A中获取最新的信息。你还记得事件溯源架构有一个叫做读模式的部署详见第7节课的内容这时候集群B会将自己变为集群A的读模式节点通过事件溯源的架构从集群A中同步最新的命令队列消息。这些命令消息通过状态机处理后就可以更新集群B里的状态。
那什么时候集群B才算同步成功呢实际上完全同步是不可能的因为集群A还在源源不断地处理新的业务集群B永远都会有一个延时。
所以,我们要稍微放宽一下要求,**只要集群B足够新就可以了。**一般来说,如果两个集群之间的数据差只有几秒钟的时候,我们就可以判定两者已经同步好了。
判断的方法有很多种。一种是集群B可以将日志文件的时间戳和本地时间做对比另一种是通过共识算法里心跳机制的时间戳来判断。
当集群B已经同步好后集群B需要通知已经同步的消息给协调者。当然了协调者也可以一直不断问集群B是否已经同步好。不管怎样重点是**只有集群B才能知道自己是否已经足够同步。**我们可以用推送消息或者拉取消息的方式来获得同步状态。
这个过程的示意图如下:
<img src="https://static001.geekbang.org/resource/image/df/c9/df76f56ee280701f971c7ab7c44c5ac9.jpg" alt="">
### 分库中
#### 集群A的分库过程
当集群B同步好后协调者开始正式的分库过程。这时候协调者会给集群A发送开始分库的消息。
**集群A收到开始分库的消息之后在自己的命令队列中记录一个特殊的命令叫作分库命令。**这个分库命令记录的是详细的分库细节比如分完库后集群A能处理哪些事情集群B又能处理哪些事情。
其实集群A并不只是记录了分库命令而已。当集群A将这个分库命令写入到日志文件之后集群A的共识算法会将这个命令复制到其他容灾节点这样就能保证主节点出问题之后当前的分库步骤还能继续下去。
当集群A的节点通过共识算法同步了分库命令之后集群A的主节点内的自动机就会执行这个命令。命令执行后会产生两个结果。第一个结果是改变集群A能处理的消息类别也就是**改变集群A的内部配置。从此以后集群A将不能再处理今后属于集群B处理的事情。**
集群A收到新命令之后还是会继续做处理不需要停机。但是有个例外如果这个命令分库之后分到了集群B这时候A就会反馈给用户说消息发错对象了。我们会在后面介绍用户应该如何处理这种情况。
集群A执行分库命令的另一个结果是会返回分库完成的消息给协调者。协调者会将这个消息记录到本地数据库中。
下面这幅图展示了集群A的分库过程
<img src="https://static001.geekbang.org/resource/image/6b/93/6b8c8be6588ddb9ae9af7cf4b55f2f93.png" alt="">
#### 集群B的分库过程
在集群A正在分库的时候集群B也没有闲着。它还是处于集群A的读模式节点源源不断地从集群A中复制最新的命令。
由于分库前集群A和集群B已经处于非常接近的状态所以很快集群B就能读到这个特殊命令也就是集群A记录下的分库命令。
这时候集群B内的自动机也会执行这个分库命令。跟集群A一样集群B在执行后也会有两个结果。一个结果是**改变集群B的内部配置**从此以后集群B就能处理归他负责的消息了。当然了如果集群B收到了不属于自己应该处理的消息依然需要和集群A一样通知用户说消息发错对象了。
在集群B的自动机执行后另一个结果是通知协调者说集群B已经分库完成。协调者收到消息后也会更新自己本地的分库状态。
下面这幅图展示了集群B的分库过程
<img src="https://static001.geekbang.org/resource/image/1b/20/1bca1dbe328b3e64f664743f33756520.jpg" alt="">
#### 分库效果分析
好了前面我们分别看了集群A和B内部的分库过程现在我们再来分析一下整体的分库过程。
整个分库的过程由协调者发起发送方是集群A也就是我们想要拆分的数据节点。这个分库过程中协调者不会主动与集群B沟通。
集群A在整个分库过程中一直处于在线状态因此集群A可以实时处理业务逻辑整个过程没有停机时间。**这就是我们希望达到的实时分库效果。**
集群A在事件溯源的命令队列里记录的特殊命令它也有一定的特点。这个分库命令记录了今后哪些业务只归集群A处理哪些业务只归集群B处理。
不过,这个命令并没有涉及到业务应该如何处理,比如说数据的存储格式应该是怎样的,是新增数据还是修改数据等等。**因此这个分库命令几乎与业务逻辑无关。**
在分库的过程中协调者只是向集群A发送了一个分库消息然后等待各个组件的返回消息。**这个过程非常简单,很容易实现自动化。**
所以,我们的分库过程达到了最开始提到的架构设计目标,那就是**我们需要尽量减少因为分库带来的相关系统的停机时间,整个分库过程尽量与业务无关,同时尽量自动化。**
这个分库过程对业务的唯一影响在于集群B什么时候才能同步好分库命令。因为集群B在同步到分库命令之前这两个集群处在一个分库的中间状态。
在这个分库的中间状态集群A不能处理集群B的消息集群B也不能处理自己应该处理的消息。所以分库会影响的是属于集群B应该处理的消息而这个影响的时间长度取决于集群B多久能同步好分库命令。这就是我们这个分库过程对业务的影响。
不过我们在分库前的准备阶段已经将集群A和集群B之间的状态差缩短到了几秒钟以内所以对业务的影响也限制在几秒以内。这个延时几乎等于一个网络重发的时间基本在我们可以接受的范围。
### 分库后
事件溯源节点分完库之后,我们需要做的后继工作是,让上游和下游节点都能准确找到新的库。我们依然需要分情况来考虑。
比较简单的是上游系统。上游系统负责发送数据给数据溯源节点。
我们在分库中提到过当集群A或者集群B处理完分库命令之后这两个集群就知道自己可以处理哪些内容不可以处理哪些内容。对于自己不能处理的内容集群会返回错误状态给上游系统并给予一些重定向提示。这样上游系统下一次就知道应该访问哪个集群了。
这样就要求每个上游系统都要具备动态路由的能力显然这是给用户的一个额外的成本。因此我们可以参考在第17节课提到的架构在上游系统和事件溯源架构之间增加一层路由。这个路由会根据集群的错误提示动态更新内部的路由信息。
下游系统就没有这么简单了,这是因为下游系统无法实时知道事件溯源节点进行了分库。另外,上下游系统之间还有一个区别。**上游系统需要做的是找到一个正确的集群,重点在于它只需要一个。下游通常需要处理所有消息,所以它需要找到所有的集群。**
因此,下游面临的问题是如何找到所有集群的信息。在这里有主动和被动这两个不同的处理思路。
一个思路是从分库的协调者那里获取。协调者是分库过程的发起者,它的本地数据库维护了所有分库的描述信息。
因此,当分库结束之后,协调者可以将分库信息实时推送给下游系统。下游系统这时候**被动地**知道了事件溯源系统刚进行过分库,因此可以从新的库里读取数据。这种思路就是被动的思路。
另一个思路是下游系统从事件溯源节点直接获取分库信息。我们在分库的时候往命令列表里存了一条特殊的分库命令。下游系统在处理任何一个事件溯源集群的时候,一定也会处理到这个分库命令。由于分库命令里包含了所有分库信息,因此下游系统可以通过分库命令来**主动**发现新的库在哪里。
选择主动和被动取决于你预期下游系统会有多少种不同的类型。在被动的情况下,只有协调者一个人需要理解分库的情况,下游系统按照协调者的指示做就好了。
在主动的情况下,对每个对接分布式事件溯源系统的下游系统,它们都需要实现一遍分库命令的解析,所以在下游系统类型很多的情况下,这会有一定的开发工作量。
## 小结
这节课我们学习了如何实现实时的事件溯源架构的分库。
一般的分库方法是先暂停业务,然后做数据的切分。这样会造成一定的业务损失。
所以,我们希望能对分库过程进行一定的架构优化,尽量减少因为分库带来的相关系统的停机时间,整个分库过程尽量与业务无关,同时尽量自动化。这就是我们分库过程的目标。
我们这节课介绍的分库方法适用于基于事件溯源的架构,因此大部分重要的金融系统和数据系统都能适用。这个分库方法,我们可以按照分库前、分库中跟分库后三个阶段分别做理解。
分库前,我们需要做到新老库之间的数据同步。这时候新集群通过事件溯源的读模式来尽量同步老集群的数据。
当数据基本同步之后,协调者发起分库过程。协调者会向老集群发送一条分库命令。老集群在收到分库命令后会更新自己的内部配置。同时新集群也会实时复制这条分库命令。这条分库命令被新老集群都处理完之后,整个集群完成分库。
集群分完库之后,上下游也需要及时更新分库信息。上游系统可以通过错误重定向来发现正确的集群位置。
下游则无法通过错误重定向来找到正确的集群。这时候你可以选择用协调者通知下游来处理分库,或者下游通过分析分库命令来主动发现分库信息。
<img src="https://uploader.shimo.im/f/vx5E6VRUIvEYos1p.jpg!thumbnail" alt="">
## 思考题
在分布式环境下所有的机器都有可能出问题,协调者也不例外。协调者的本地数据库可以通过我们在前面介绍过的共识算法来解决单点的问题。但是这里还是有一些异常情况。
比如说协调者在发送给集群A开始分库的消息之后就出现了问题共识算法的主节点停机了。
这时候共识算法会选出一个新的主节点来代表协调者。主节点只能知道自己已经给集群A发送过一个分库命令但是它并不知道集群A有没有收到。因此保险的做法是协调者集群内新的主节点再给集群A发送同一个分库命令消息。
那么问题来了如果集群A陆续收到了多个分库命令整个分库过程正确性会有影响吗
欢迎你在留言区跟我交流,讨论。如果这节课对你有帮助的话,也欢迎你转发给同事、朋友,跟他一起探讨动态分库的问题。

View File

@@ -0,0 +1,173 @@
<audio id="audio" title="20 | 容灾(上)如何实现正确的跨机房实时容灾?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5c/66/5c802774088b8044439358f9b9000f66.mp3"></audio>
你好,我是任杰。这一讲我和你聊一聊如何实现正确的跨机房实时容灾。
这一讲我们主要从这三个方面的内容给你讲解容灾问题,它们分别是正确容灾、跨机房容灾和实时容灾。
因为“跨机房”和“实时”是两个标准的技术问题,所以有非常明确的方式方法和衡量标准,我们会在后面详细讲解。
相反,“正确”这两个字可能会因人而异,不同环境下可能会有不同的理解。因此我们在讲解技术问题之前,先看看怎么理解“正确”这两个字。
## 正确的定义
金融行业覆盖的面非常广,不同子行业对容灾的要求会不一样。一种分类方式是按照用户专业性来分类,这种分法会将用户分为一般性用户和专业用户两大类。
**一般性用户指的是非从事专业金融类工作的用户。专业用户指的是以金融类工作谋生的用户。**注意这里的用户并不限定为个人用户其中还包括企业等机构用户。通俗来讲一般性用户指的是C端用户专业用户是B端用户。
对于一般用户来说,日常使用最多的金融服务是手机支付。如果手机支付系统出现了问题,支付公司进行了容灾处理之后,我们能接受的“正确”的容灾结果一般有两个。
第一个是尽量在规定时间内恢复服务比如在10秒内恢复。第二个是如果有限时间内不能恢复服务那么要避免出现金额对不上的问题。比如说我这边已经显示扣款了商家那边却迟迟收不到钱的情况就一定要避免。
专业用户也有一般用户的需求,但是相比一般用户又多了一项。专业用户在和金融机构对接时会签署服务合同。在合同内会规定金融机构在无法履约时的赔偿条例。
比如说,对冲基金想通过证券公司的渠道抛售大量股票。如果此时证券公司系统崩溃,就会导致股票无法卖出。这个情况下,市场动荡带来的一部分经济损失可能需要由证券公司承担。
所以,我们综合两类用户需求的共性,可以这样来理解。从用户的角度来讲,正确容灾的第一个要求是**服务质量协议SLAService Level Agreement的要求**,规定一定时限内需要恢复服务。
另一个要求是**事务正确性的要求**特别是对事务ACID四个属性中A的要求即Atomicity原子性。因为金融系统一旦出现金额问题后要给客户赔偿。
那这两个要求之间是什么关系呢?如果金融机构无法满足服务质量协议,那么赔偿金额就会按照合同规定的金额来计算,风险可控。但是如果事务正确性出了问题,比如百亿债券过户到一半系统崩溃,或者员工工资重复打款,金融机构面临的是不可控的风险。
这两个正确容灾要求的解决是需要成本的。如果实现容灾的成本大于赔偿金额,那么可以用日常营业利润的一部分作为容灾赔付基金。**所以这里的正确并不是绝对的正确,而是相对的正确。你需要在服务质量、事务正确性和成本这三者之间做权衡。**
## 发现问题
### 人工监控
在解决容灾问题前,我们先要发现系统已经出了问题。
虽然在互联网领域现在的监控已经走向了自动化、甚至智能化金融系统依然存在人工监控的情况。一个原因是一些特定金融业务的SLA要求很低比如几天或者几个月这时实现系统容灾成本大于收益。
比如我们去实现资产证券化的业务可能整个计算过程就在业务人员办公电脑的Excel里。如果电脑出问题了重启一下就行如果重启不了就换一位同事的电脑而且这个过程可能一个月才做一次。一般来说适合人工监控的一般是业务量小、客户少、金额高的金融业务。
### 系统监控
让我们回到一般情况下的跨机房容灾监控问题。因为监控系统本身是一个很大的命题,在很多地方都有详细的介绍,所以在这里不做过多阐述。
我重点给你讲讲,在解决跨机房容灾监控问题的时候,监控系统方面你需要注意的地方,主要有三点。
**第一点是监控系统本身需要有跨机房容灾的能力。**一种方式是用业务系统的跨机房容灾的架构来实现监控系统。虽然也可以解决问题,但是成本较高。
这是因为监控数据是可以部分丢失的。我们在[第9节课](https://time.geekbang.org/column/article/331395)提到过数据具有时效性。监控数据和市场数据一样,时间越久,数据的价值越低。所以如果丢失了几秒钟的监控数据,你只要稍微等一下,刷新一下页面可能就会拿到最新数据。
这样看来,我们就不需要为了数据中心级灾难这种小概率事件,采用高成本的容灾方案。
因此,**一个性价比较高的方案是监控数据本地异步备份,不同数据中心的监控平台彼此之间互相监控。**如下图所示:
<img src="https://static001.geekbang.org/resource/image/ed/98/edfaa898eddc8fdb9114a10f508eed98.jpg" alt="">
**第二点是监控的延时。监控系统有推和拉两种获取监控数据的方式。**推指的是业务系统主动将监控指标数据推送给监控系统比如常见的心跳数据heartbeat。这么做实时性很强但是对监控系统的峰值计算和存储能力要求很高。
因此**一般采用另一种折中的方式,让监控系统定期从业务系统里拉取监控指标。**这样对硬件资源的要求基本固定但是监控延时会稍微长一些此时你需要和业务方仔细沟通金融业务需要的SLA。
第三点是监控会伪报。当服务变多、网络变复杂之后,所有可能出现的异常都会出现。
比如在防火墙配置出错的情况下,可能明明监控系统已经发现某服务不可用,但是该服务却在依然正确运行,想退出该服务的时候,退出指令也无法发送成功。因此,容灾处理需要考虑到在伪报情况下的正确性。
## 容灾过程
在基于服务的架构下SOAService Oriented Architecture系统一般分为3层用户应用层无状态服务层和数据层如下图所示。
<img src="https://static001.geekbang.org/resource/image/3d/f0/3d01d623afa0e939f5e2bfed3b4226f0.jpg" alt=""><br>
因此在出现数据中心级别灾难的情况下,我们需要处理服务的容灾和数据的容灾。接下来,我们先看看服务容灾。
### 服务容灾
#### 无状态服务容灾
无状态服务的容灾相对简单,基本上什么都不做就可以了。一般来说,服务调度算法会将服务请求随机调度到不同的数据中心,因此当一个数据中心出现问题的情况下,用户服务在多次尝试之后,就会被调度到没有出现问题的数据中心。
更进一步的优化方法是节省掉重复尝试的时间。如果数据中心内的服务均不可用,可以更新服务路由信息。这样新的请求不会被发送至出问题的数据中心节点,等到该数据中心恢复之后再加回到路由信息内。
#### 消息重发问题
在容灾的过程中,无状态服务需要处理消息重发问题。如果表面上消失掉的无状态服务,其实依然还在处理服务,那我们该怎么办呢?一个常见的情况是服务成功处理了请求,但是由于防火墙问题无法将成功状态返回给用户。
我们在[第8节课](https://time.geekbang.org/column/article/330288)一起研究过,怎么解决请求重发的问题。**解决重发问题要求我们实现业务操作的幂等性Idempotency**比如利用消息内的唯一标识符或者使用K/V这种具有天然幂等性能力的数据结构。
#### 有状态服务容灾
金融行业的低延时场景通常会采用有状态的服务,比如外汇交易所或者股票交易所。这时候可以将有状态服务节点当作数据节点来处理。
### 数据容灾
数据容灾一般需要数据系统自己来处理,容灾作为数据系统的一种自有的能力。这里我们重点看看金融系统常见的两个方案,分别是两地三中心和三地五中心的实时容灾方案。
### 单节点容灾
为了满足金融级的容灾要求,我们需要先保证单机节点具有一定的容灾能力。
单机节点容灾指的是,数据节点重启后能恢复之前所有数据。在这里有一些细节需要你注意。通常在写入数据文件时,数据并没有被写入到硬盘中,此时数据会保存在操作系统内的缓存。
当这个缓存满了或者等待了一定时间之后比如30秒钟操作系统会将这个缓存写入到硬件控制器的缓存。用户也可以通过fsync的系统调用来主动完成这个过程但是需要小心的是此时只写入到了硬件控制器缓存并没有写到了硬盘。如果这时候机器断电依然有可能出现数据丢失。
<img src="https://static001.geekbang.org/resource/image/a2/c8/a2b70bdf58196993683eebb83e5545c8.jpg" alt="">
那么怎么处理这种小概率事件呢?也有两种方式。**一种方式是用企业级硬件。**企业级硬盘可能会携带一小块电池。当机器断电之后,这块电池能保证硬件控制器内的数据都能写入到硬盘中,这是用钱来换正确性。
**另一种方式是不管不问。如果我们已经确定了,一定会用一组集群来实现多节点容灾,这种情况下,因为断电而产生的数据丢失可以看作是单节点故障来处理。**这也是用钱来换正确性的思路Kafka就是用了这种假设甚至Kafka连fsync都节省掉了。
所以单节点容灾到什么程度取决于你有多少钱。
### 多节点跨机房容灾
#### 跨机房部署
两地三中心或者三地五中心的容灾利用了共识consensus算法的能力。我们在[第16节课](https://time.geekbang.org/column/article/338389)详细讲过Raft共识算法如果你记不清了可以做个回顾。
Raft的部署一般有3个备份或者5个备份两种选项。偶尔也会出现9个备份的选择比如Google。
我们先来看看有3个备份的情况。这时候有两种选择。一种选择是一个数据中心有2个节点另一个数据中心只有1个节点。**这样只能保证单节点级别的容灾,不能保证数据中心级的容灾。**
比如下面这幅图展示了一个典型的部署。Raft算法要求有一大半的机器在线才能正常工作因此3个备份的情况下至少需要2台节点在线。如果下图的数据中心1整个出了故障那么只剩下一个节点在数据中心2算法无法正常工作。
<img src="https://static001.geekbang.org/resource/image/97/df/97e134ba9050da972dd19a51286621df.jpg" alt="">
3个备份的另一种选择是将3个节点放在3个数据中心内。这时可以将两个数据中心放在一个城市另一个数据中心放在另一个城市**这个做法也叫两地三中心。这样能满足单个数据中心级的容灾,但是不能满足城市级容灾。**
比如下图所示。如果城市1因为道路施工挖断了光纤城市2就不能保证Raft协议正常工作。
<img src="https://static001.geekbang.org/resource/image/e5/ba/e549d3b53f3fa8771fa3c87a8f8ac9ba.jpg" alt=""><br>
5备份的情况和3备份的情况类似但是选择更多。其中有一些选择能达到城市级别的容灾。
我们举个例子来理解。如下图所示这时候5个节点基本平分到了3个城市的数据中心。你可以算一算这时候任何一个城市网络出现了问题Raft协议都能正常运行。**这就是常见的三地五中心部署,也是普遍使用的较高容灾级别的部署方式。**
<img src="https://static001.geekbang.org/resource/image/82/74/82b42150b609ef31e1a7d4e6ff60ec74.jpg" alt="">
总结一下。**在使用多个节点容灾的情况下,两地三中心只能达到数据中心级别的容灾。如果需要达到城市级别的容灾,需要三地五中心部署。**
#### 客户端处理
我们在进行容灾分析的时候,一般会侧重处理服务器端的各种特殊情况,这时候很容易忽略客户端。
从原理上来说,如果服务器端容灾做得正确,就不会出现数据正确性的问题。但是我们在开头提到过,容灾除了正确性之外,还有一个服务质量协议的问题,我们还需要尽量减少无法提供服务的时间长度。
因此我还是要强调一下正确的客户端行为我们在第16节课说过。当服务器出现问题的时候Raft协议自动换主之后客户端一定要用正确的方式来找到新的主节点这样会大大减少容灾的延时。
## 小结
这节课我们学习了如何实现正确的跨机房实时容灾。
首先我们了解了正确性的定义。正确性分为服务质量协议SLA和事务正确性这两个方面。这两者的解决需要付出成本。我们很难达到绝对的正确因此需要在成本和正确性之间做一个权衡。
接下来,我们讲了怎么发现问题。如果金融业务的业务量小、客户少,而且金额高,那么可以选择人工监控。一般情况下应该选择用监控系统来监控集群状态。这些监控系统之间需要彼此监控,尽量采用拉取的方式来获取监控数据。
最后,我们分析了发现问题之后如何容灾。无状态服务容灾比较简单,只需要解决下游数据节点的重发问题。有状态服务的容灾和数据节点容灾一样,是最复杂的情况。
数据节点容灾先要提高单节点容灾的能力,可以通过使用更好的硬件和正确的方法来提高。多节点跨宿主中心容灾需要考虑对容灾的需求。数据中心级别的容灾可以采用两地三中心,城市级别的容灾可以考虑三地五中心。
<img src="https://static001.geekbang.org/resource/image/8b/bf/8b1190096908dyy46146f907354d80bf.jpg" alt="">
## 思考题
除了三地五中心之外还有一种容灾能力更高的部署方式那就是三地九中心Google曾经采用过这种部署方式。
三地九中心并不是直接部署9个Raft节点而是将Raft节点分为了两层。下面一层按照3个一组分为了3组分别放在3个数据中心。每个数据中心的3个节点刚好组成一个Raft集群通过Raft选主的方式选出来一个主节点。
这样3个数据中心就一共有3个主节点。这3个主节点之间刚好也可以形成一个Raft集群再选出一个级别更高的Raft主节点。这个唯一的主节点负责代表集群对外提供服务。下面这幅图展示了三地九中心的部署方式。
<img src="https://static001.geekbang.org/resource/image/14/79/14a82ee2e090ce95c015c12e9ee64679.jpg" alt="">
那么,你觉得这个三地九中心部署方案有哪些优点呢?
欢迎留言和我分享你的想法。如果学了这一讲你有所收获,也欢迎你把这篇文章分享给你的朋友,一起交流和讨论跨机房实时容灾的问题。

View File

@@ -0,0 +1,173 @@
<audio id="audio" title="21 | 容灾(下):如何通过混沌工程提高系统稳定性?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/24/c1/24004cc1c8cd30a7bed607c87ea121c1.mp3"></audio>
你好,我是任杰。今天我们来聊聊混沌工程。
这一讲是第三个模块“分布式正确性及高可用”的最后一讲。我们前面学习了实现金融系统正确性、可用性的许多方法。
不过,尽管你掌握了各种金融系统的屠龙之技,平时工作也兢兢业业,但是业务方并不一定懂技术。他们怎么才能放心让几百亿上千亿的业务跑在你的系统上呢?
所以,这节课我们就来看看,如何通过混沌工程来证明系统是正确的。这一块内容涉及面非常广,所以我还是会照例给你重点讲解**混沌工程背后的原理**,你了解了背后的原理之后,很容易就能在实践中加以应用了。
## 分布式系统正确性理论
我们在学习算法的时候老师和教材都会证明为什么算法是正确的。分布式系统也是一样有很多对于分布式算法的正确性证明。但是不知道你发现了没有我们从来没有说Raft共识算法是正确的
Raft算法已经在很多环境运行了很长的时间从来没有人怀疑过它的正确性。如果你看过Raft算法的论文也会觉得它应该是正确的。那为什么我们不会说Raft算法是正确的呢
原因是现在都是通过形式语言的方式来证明分布式算法的正确性。**形式语言证明有一个局限它只能证明有限状态的情况。由于Raft算法涉及到无限种可能的情况因此无法通过形式语言来证明正确性。**
既然我们无法从理论上证明Raft共识算法的证明性那么也无法通过实践来证明原因是逻辑推理过程比主观推理过程的正确性要高算法的实现不可能比理论的正确性要高。
所以从Raft共识算法的例子可以看出来我们需要换一种思路来证明金融系统的正确性。
## 用不正确来证明正确
我们可以选择“用不正确来证明正确”的思路吗?这不是绕口令,它是一种很巧妙的思维方式。
逻辑证明有一个原则是**证有不证无**。比如说,如果让你证明这世界上存在一只黑天鹅,你只要找到一只就可以了。但是如果让你证明这世界上没有黑天鹅,那么你需要跑遍全世界才可以,而且就算你跑遍了全世界,我也可以说你其实漏掉了一些地方。
系统的正确性证明也是一样。**我们只能证明系统中可能会出问题,而不能证明系统一定不会出问题。**
从数学的角度看,你能证明的场景是可数集,但是自然界的很多集合都是不可数的。再简单的软件,它运行时的周边环境也极其复杂。因此不管你再怎么努力,也无法穷举所有的可能性来证明系统没有问题。
这就说明我们对于正确性的证明需要换一种理解方式。我们需要放弃证明一个系统不会出问题也就是没有bug而是要证明我们可以找到bug。
由于寻找bug需要一定的人力物力和时间成本当你解决了一些显然易见的问题之后剩下的问题就会越来越难发现。
你还记得,我们在[开篇词](https://time.geekbang.org/column/article/323689)提到过金融系统需要考虑性价比么如果发现bug的成本过大但是带来的问题过小那么我们就会放弃寻找bug。这时候我们就会说系统出问题的可能性非常小达到了我们对于正确性的要求。
因此,**系统的正确性是一个概率问题,套用一个统计学的术语,正确性其实是系统不会出大问题这个结论的置信区间。**通俗一点来说,正确性其实是我们对系统的信心。
## 混沌工程原理
### 系统定义
混沌工程,就是顺着我前面说的概率思路来回答正确性问题。它通过改变环境来提高出问题的概率。如果这时候依然没有发现问题,那么平常的情况下的正确性可能会更高,我们也就对系统更有信心。
首先,我们来看看什么系统需要用到混沌工程。和我们在[第5节课](https://time.geekbang.org/column/article/327137)领域驱动设计的条件一样,只有当系统复杂的情况下才需要使用。在分析为什么之前,让我们先来看看什么是系统。
虽然我们一直在提系统,比如软件系统、金融系统,但是我们从来没有对系统进行过定义。系统是由相互作用、相互依赖的若干组成部分结合而成,是具有特定功能的有机整体。系统本身也是另一个更大系统的组成部分。
那我们应该关注系统定义里的哪些内容呢?这里就要提到系统复杂度了。**当系统的组成部分超过3个之后系统就很有可能会进入混沌Chaos的状态。**处于混沌状态的系统,它的行为是非线性的。我们可能不容易对非线性这个词产生直观的感受。通俗一点来说,非线性指的是无法预测和描述。
所以,**混沌工程的对象是混沌的系统。**
通常我们对软件系统的理解局限在软件上。一个系统要想生存,除了软件之外,还需要有周边环境的配合。因此,我们在这里的**系统指的不仅仅是软件,还包括硬件、运维系统、监控系统、开发人员、运维人员、值班人员等等。**这是一个容易忽略的地方,你一定要注意。
### 实验定义
我们在前面分析正确性证明的时候,提到过正确性证明其实是一个概率置信区间的问题。既然是一个概率统计题,那么我们就可以用统计的方法来解决问题。
这里我们要用到一些统计学的术语。我们在混沌工程开始前要定义一些我们关心的实验。
首先要定义的是实验的**假设**Hypothesis。这个假设里涉及到两大类主体一类是自变量一类是因变量。
**自变量是你能控制的东西**,比如你可以决定软件运行的方式和它周围的环境。
而**因变量是被自变量影响的变量。<strong>比如当你关掉数据中心硬盘之后,如果系统给你发了一条短信,那么关掉硬盘是自变量,发短信就是因变量。而**假设就是我们在实验前提出的,自变量和因变量之间的影响。</strong>
在这里还要解释一个常见的误解,那就是混沌工程实验和测试的区别。**测试是在我们已经知道正确答案是什么了的情况下进行的实验,测试的目标是为了肯定正确性。而混沌工程实验是在我们不知道会发生什么的情况下进行的实验,混沌工程的目标是为了更好地理解系统。**
正因为混沌工程不知道会发生什么实验会采用假设而不是断言Assert的方式。当然了如果混沌工程暴露了不好的结果我们也应该和测试bug一样做修复。
### 自变量定义
如何找到统计学的自变量没有一般规律,但是软件行业有自己的行业特殊性,我们可以总结出一些软件系统的自变量规律。这里就涉及到你对整个软件运行架构的理解深度了。
我们先看看最常见的情况,也就是软件系统运行环境。现代的软件工程都会把系统分成了很多层。上下层之间有比较清晰的功能访问区分。
处于最下层的是硬件。通常来讲硬件和输入输出有关。常见的硬件有CPU硬盘内存网卡显卡鼠标键盘等等。
再上一层是操作系统。操作系统负责管理所有硬件资源,并将这些硬件资源抽象成软件可以理解的概念,然后分配给多个用户。因此,操作系统需要处理所有前面提到的硬件资源。
同时操作系统还在硬件之上抽象出了更高一级的软件资源比如虚拟内存、文件系统、网络套接字Socket。这些软件资源也有一些更高一级的管理方法比如进程和线程。
随着云计算的崛起操作系统之上不再是软件而是虚拟机了。虚拟机提供了独立于操作系统的虚拟环境。有的虚拟机选择虚拟出一个不一样的运行环境比如VMWare有的虚拟机选择虚拟出一个几乎类似的运行环境比如Docker。
当然了,这里对于虚拟机的分类并不是特别完整和准确,主要目的是为了让你知道虚拟机是对操作系统更高一级的抽象。
在虚拟机之上是系统API。系统API提供了能访问操作系统的编程接口。
比系统API更高一级的是用户的应用程序。如果用户的编程语言选择了Java或者Python等解释型编程语言那么还有一个中间层是这些编程语言的运行时虚拟机。
下图展示了软件系统运行环境的各个层级:
<img src="https://static001.geekbang.org/resource/image/d5/f2/d5b45a7f50ebe807518c95b03a9b49f2.jpg" alt="">
那我们为什么要介绍软硬件的层次关系呢?这里有两个原因。第一个原因是这些都是你可以控制和调整的自变量。也就是说,你在实现混沌工程的时候,可以考虑对这些提到的对象进行调整。
第二个原因更重要,刚才的这个层次划分关系能给你提供分析问题的思路。**计算机软件是一个从复杂到简单,从底层到高层的抽象过程。每次抽象都是靠新增一个层次关系,因此你可以顺着这个思路来解决一些我们没有提到过的场景。**
比如说,在现在云计算环境下,一个程序不再局限在一台机器上。这种情况下,你应该怎么分析自变量呢?
我们也可以从划分层次的角度来思考这个问题。比如说从硬件的视角,你可以从底层到高层的划分机器为刀片、机柜、单个数据中心、城市容灾集群,然后再依次处理。
**自变量的划分决定了混沌工程可以进行哪些实验。**每一个自变量都定义了自己支持的操作类型。比如硬盘可以慢,也可以出错,网卡可以断网,也可以掉帧。自变量定义得越准确,实验就能设计得更精细。
### 因变量定义和假设定义
因变量就是你通过实验想检验的目标。这里就涉及到4个名词SRE、SLA、SLO和SLI。
SRE的全称是Site Reliability Engineer也叫作**站点可靠性工程**。这个工种的传统说法就是运维。但是现在随着云计算规模的扩大这个工种也有了更大的责权范围和专业性提高。简单来说他们负责帮助定义和实现SLASLI和SLO。
SLA、SLI和SLO是三个非常容易搞混的名词。SLA是Service Level Agreement也叫服务质量协议。SLO是Service Level Objective也叫服务质量目标。SLI是Service Level Indicator也叫服务质量对象。
SLA是整个服务质量的协议包含了SLO和SLI。SLI是监控的对象比如吞吐量、延时、性能等等。
SLO是我们认为这些监控的指标需要达到的目标。比如我们在提到网站高可用的时候都说要到达5个9也就是99.999%。这里的高可用就是监控对象而99.999%就是需要达到的目标。
对于混沌工程这个实验来说,**SLI就是因变量**。我们在混沌工程这个实验中通过改变自变量来影响监控对象。而**SLO就是实验假设**。
比如说,我想通过关掉一块硬盘来看看是否影响服务器的高可用。在这个实验里,硬盘就是自变量,高可用是因变量,高可用是否会因此发生变化是实验假设。
### 实验过程
当我们定义好自变量、因变量和实验假设之后,还不能马上开始实验。在实验前还要有一些准备工作。
**首先需要解决的是因变量的监控。**不知道你有没有想过一个问题。同样都是工程,我们为什么能比较准确地估计修一个楼房需要的时间,但是往往不能估计一个软件的开发时间?
答案在于建筑工程是肉眼可见的工程。我们既然能看到进度,就比较容易理解各个组件之间的关系,也就容易控制。
相反,软件是看不见摸不着的,和空气一样,我们只能间接了解软件的情况。软件的不可见性让我们很难理解软件究竟做了些什么。
因此为了能更好第了解混沌工程我们需要在软件中加上尽量多的监控指标覆盖所有相关的SLI因变量
另一个需要准备的是实验过程。混沌工程的目的不是为了故意让生产环境出问题,而是通过制造问题,来帮我们更好地理解生产环境的能力范围。因此我们只要能收集到会出现哪些变化就行,尽量不要太影响生产环境的正常运行。
因此**一个好的步骤是将实验慢慢升级。**最开始的时候从测试环境开始。当情况可控之后再逐步升级到回归环境、预生产环境。
最后在生产环境运行时我们也最好先在灰度测试配置下进行小规模的实验。我们把这个过程叫作伤害半径Blast Radius。我们要将这个伤害半径控制得尽量小。
## Jepsen测试
前面提到过混沌工程实验会监控SLO的变化。SLO通常和吞吐量、高容灾和延时等有关。**对于金融系统来说还有一个非常重要的SLI指标是正确性。金融系统正确性的目标通常是100%。**
有一类专门用来证明分布式系统正确性的工具它叫Jepsen测试。Jepsen是这个测试的发明人。Jepson测试和混沌工程一样也会定义一些自变量和实验。但是当实验完成之后它会从各个节点收集数据验证最后的结果是否正确。
Jepsen在[自己网站](https://jepsen.io/)上列举了他发现过的各种分布式系统问题,你有兴趣可以了解一下。你会惊讶地发现,很多熟悉的数据系统其实并没有它们宣传的那么正确。
## 小结
这节课我们学习了怎么通过混沌工程来提高系统的正确性。
分布式系统并不一定能证明是正确的,因此我们需要换一种思路,用不正确性来证明正确性。如果不正确出现的情况足够少,那么我们就有足够多的信心认为系统是正确的,因此正确性是一个概率上的置信区间问题。
混沌工程就是用概率的思路来解决问题。它通过改变系统运行环境来提高出混沌系统出问题的概率,因此混沌工程是个统计实验。为了进行这个实验,我们需要定义好实验的自变量、因变量和实验假设。
在实验的过程中,我们需要精心控制事故的伤害范围,实验以了解混沌系统的行为为目的,而不是刻意地损坏生产环境。
金融系统最关心的是正确性在分布式环境下有一类正确性测试叫Jespsen测试。这个测试和混沌工程几乎完全一样只是在SLO的定义上有些许不同。
<img src="https://static001.geekbang.org/resource/image/yy/9c/yy3df3dbbddec2183bdf77855802259c.jpg" alt="">
金融系统的正确性和金融业务的风控一样,都是投入巨大,但是产出不明显的工作。只有当潮水退去的时候才能知道谁在裸泳,同样的,只有在出了资金问题之后,人们才知道金融系统的正确性有多重要。
我们自己在做基于Raft共识算法的支付系统的时候整个研发只用了3个月但是混沌测试了一整年其中最后一个bug是在混沌测试半年之后才发现的。在这个期间已经有几家加密货币交易所因为代码问题造成了资损。
**所以正确性是金融系统开发人员的道德底线,一定不能先做大再做强,而要先做强再做大。**
## 思考题
我们的最后一道思考题也很简单。我在最开始说过Raft算法有无限种状态因此无法通过形式语言来证明这个分布式算法的正确性。那么Raft的无限种状态指的是什么状态呢
欢迎你在留言区晒出你的心得或者疑问。如果这节课对你有启发,也欢迎转发给同事、朋友,一起交流混沌工程这个话题。

View File

@@ -0,0 +1,189 @@
<audio id="audio" title="答疑集锦(三) | 思考题解析与数据库底层实现" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/90/f66d31794ed1457875176805ce69bf90.mp3"></audio>
你好,我是任杰。
到今天为止,我们最后一个模块,分布式正确性及高可用的内容就结束了。恒者行远,思者常新。积极思考是学习精进的重要一环,所以这里我要特别表扬积极留言、主动学习的同学,相信你一定会更有收获。
今天我为你准备了这篇加餐,把第三模块的思考题做一个系统梳理。我还是建议你先看完前面每一讲的内容,自己做了思考之后再看我这份参考答案。
## 思考题答案
[**第12节课**](https://time.geekbang.org/column/article/334878)
Q快照隔离虽然比可串行化的级别要低一些我们也是可以稍作调整就达到可串行化的能力这个做法叫作串行化快照隔离SSISerializable Snapshot Isolation
SSI主要需要检查的是读写和写读冲突像下面这幅图展示的一样。那你知道怎么才能正确地找到这些冲突吗
<img src="https://static001.geekbang.org/resource/image/12/00/124fef16369e0e6a3cbfa848f1d18000.jpg" alt="">
A数据库需要记录下来你都读了哪些数据。这样在其他事务写的时候就能判断是否会对你的读造成了影响。在这里我们要分两种情况考虑。
第一种情况是上图左边的情况。这时候数据库需要知道T1的写入操作会影响哪些还没有结束的事务。数据库需要保留读的信息。
第二种情况是上图右边的情况。这时候数据库需要记录T1的读操作忽略了哪些相关的、还没有结束的事务。这时候如果T1还有任何写的操作那么就需要考虑将T1回滚。需要你注意的是如果T1一直只是读取数据没有任何写的操作那么T1是不需要回滚的。
[**第13节课**](https://time.geekbang.org/column/article/335994)
Q在两阶段提交的情况下协调者的全局事务数据库可能会出现数据丢失这时候协调者也是可以根据所有节点的情况来反推出自己应该做什么。你知道协调者应该怎么做吗
A协调者这时候需要对所有节点的情况进行一次普查。
如果至少有一个节点进入了第二个阶段,那么所有节点都进入了第二个阶段。这时候如果有一个节点进入提交状态,所有节点都需要提交。如果有一个节点进入了取消状态,那么所有节点都需要取消。
如果所有节点都停留在第一阶段,那么就没有这么容易判断了。
如果至少有一个没有记录“准备成功”的标志,那么事务的第一个阶段还没有结束,因此可以回滚分布式事务。
最复杂的情况在于所有在线的节点都记录了“准备成功”。这时候可能有的节点并不在线,你不知道它们都处在什么阶段,因此需要灵活处理。
[**第14节课**](https://time.geekbang.org/column/article/335994)
Q我们这节课的思考题很简单。如果让你来实现分布式环境下的严格可串行化你能想到什么办法呢
A如果不考虑执行效率的话最简单的实现方式是利用分布式锁来锁定对所有分布式数据库的访问。全局只有一个锁。只有拿到了锁分布式事务才能开始。
[**第15节课**](https://time.geekbang.org/column/article/337308)
Q我们在最开始提到过1996年的论文“Unreliable failure detectors for reliable distributed systems”证明了如果分布式系统存在一个能让你最终做出准确判断的不准确时钟那么系统存在共识算法。这个时钟其实起到的作用是在分布式环境下检测机器是否出问题。
失败检测分为两种属性完整性和准确性。按照排列组合这两种属性一共有4种可能的情况
1. 强完整性。所有正确的节点都会最终怀疑每个出错的节点。
1. 弱完整性。一些正确的节点都会最终怀疑每个出错的节点。
1. 强准确性。所有正确的节点都不会被怀疑说出了问题。
1. 弱准确性。一些正确的节点不会被怀疑说出了问题。
论文指出来,就算是只有很弱的失败检测,也能实现共识算法。那么你觉得这里的“弱”指的是哪几种情况呢?
A需要弱完整性和弱准确性。
[**第16节课**](https://time.geekbang.org/column/article/338389)
Q为了提高分布式事务的容灾能力我们需要解决协调者的单点问题。那么问题来了
1. 你能分析一下,这个问题的本质是我们这节课讲的哪个问题吗?
1. 如果你要解决单点问题的话,可以怎么解决呢?
A这个问题的本质是数据库日志在分布式环境下的线性化存储。因此需要用共识算法来复制日志文件。
[**第17节课**](https://time.geekbang.org/column/article/339289)
Q读模式的状态机需要复制事件队列。在复制的时候一定有两个选择一个是主动将事件队列拉过来另一个是将事件队列推过来。那你是会选择推还是拉呢不同选择都有哪些优缺点呢
A选择有两个点。一个是延时一个是谁付出成本。
对于延时来说,推一般是实时推送数据,延时低。而拉是批量拉,吞吐量大,但是延时高。
成本有两个方面,一个是计算成本,一个是系统复杂度。从计算成本考虑,推需要写模式节点需要付出成本,而拉则是读节点。
从系统复杂度考虑,在推送的反思下,写模式节点需要维护数据消费者当前的数据消费偏移量,然后根据这个偏移量来选择是否重发。当消费者个数比较多的情况下,写模式节点的压力会比较大。在拉取的模式下,读模式节点只需要维护自己当前的消费情况。
[**第18节课**](https://time.geekbang.org/column/article/340181)
关系型数据库有这几个关键的功能:
1. 表存储
1. 主索引和二级索引
1. 缓存
1. 表的复制和容灾
1. 表查询
1. 单机事务
1. 分布式锁
1. 分布式事务
如果要求你用现在市面上常用的数据解决方案来拼凑一个分布式关系型数据库,你应该如何选择,如何搭配呢?
A表的存储和主索引用rocksdb。二级索引也可以用rocksdb。缓存可以用redis。表的复制用kafka。表的查询用clickhouse。分布式锁用zookeeper或者etcd。简单的单机事务可以用rocksdb实现。
两阶段提交的分布式事务没有开源方案但是TCC可以通过业务系统来实现。
[**第19节课**](https://time.geekbang.org/column/article/341048)
Q在分布式环境下所有的机器都有可能出问题协调者也不例外。比如说协调者可能因为网络原因给集群重复发送了多个分库命令。这样会影响整个分库过程的正确性吗
A答案是不会影响。分库命令唯一的副作用是修改集群内的配置信息。配置信息的修改具有幂等性因此多次操作和一次操作结果一样。
[**第20节课**](https://time.geekbang.org/column/article/341809)
除了三地五中心之外还有一种容灾能力更高的部署方式那就是三地九中心Google曾经采用过这种部署方式。
三地九中心并不是直接部署9个Raft节点而是将Raft节点分为了两层。下面一层按照3个一组分为了3组分别放在3个数据中心。每个数据中心的3个节点刚好组成一个Raft集群通过Raft选主的方式选出来一个主节点。
这样3个数据中心就一共有3个主节点。这3个主节点之间刚好也可以形成一个Raft集群再选出一个级别更高的Raft主节点。这个唯一的主节点负责代表集群对外提供服务。下面这幅图展示了三地九中心的部署方式。
<img src="https://static001.geekbang.org/resource/image/6c/93/6c5b1bcb50e41259195d2e8a006e2e93.jpg" alt="">
那么,你觉得这个三地九中心部署方案有哪些优点呢?
A最主要的优点是减少了跨城带宽使用量。三地九中心和三地五中心具有一样的城市级容灾能力。但是在三地五中心的情况下主节点需要负责将数据同步到3个其他城市的节点而在三地九中心的情况下主节点只需要同步2个其他城市的节点。
[**第21节课**](https://time.geekbang.org/column/article/342419)
Q我们的最后一道思考题也很简单。我在最开始介绍过Raft算法有无限种状态因此无法通过形式语言来证明这个分布式算法的正确性。那么Raft的无限种状态指的是什么状态呢
ARaft的日志文件是Raft的状态之一。日志文件可以不断增长因此长度是无限的。准确来说日志的长度是可数的Countable但是由于每个日志的内容可以有多种类型整个日志的状态是不可数的Uncountable
## 为什么分布式数据库需要数据库的特殊支持?
还记得我在[第13节课](https://time.geekbang.org/column/article/335994)给你留了一个彩蛋么?表面上看,我们可以在任何一种数据库上实现两阶段提交。其实并不是这样的,两阶段提交的实现需要数据库底层的特殊支持。
在这个加餐里,我来补充一下数据库的一些底层实现,以及如何加强这些底层实现来实现两阶段提交。如果你学有余力,可以拓展学习。
### 事务实现细节
现在数据库事务基本是靠**重做日志**Redo Log来实现。重做日志和我们[第7节课](https://time.geekbang.org/column/article/326583)讲的事件溯源的日志文件完全一样。数据库把所有操作记录到日志文件上,当机器出问题之后,机器的自动机逻辑会依次读取日志文件的内容,选择性地提交事务或者回滚事务,这样就能将机器恢复到最后的正确状态。
每个事务开始的时候,数据库会在重做日志上记录一行 `开始事务` 。接下来就是对数据库内容的修改,数据库并不会马上修改数据,而是先在重做日志上记录修改的操作,然后才真正在数据库内修改数据。最后,事务提交的时候会记录一行 `事务提交` ,或者当事务回滚的时候留下一行 `事务回滚` 的记录。
这里有一个细节,在重做日志上,数据库的修改并不会简单记录修改后的值,而是**会记录修改前和修改后两个值**。修改前的值用来做事务的回滚操作,而修改后的值用来做事务的提交操作。用事件溯源的观点看,可以这样理解:重做日志上会记录当前值的信息和对值的操作。
这里给你举个例子。假设我们通过一个事务将数据库 `A``x` 的值从100变为0 。伪代码如下:
```
begin transaction
update A set balance= 0 where accountID='x'
end transaction
```
数据库收到这个SQL之后会先在重做日志文件里记录一行 `开始事务` ,然后再记录一行 `x` 的前后变化,最后记录一行 `事务提交` 后返回。整个重做日志的内容如下图所示:
<img src="https://static001.geekbang.org/resource/image/4c/72/4c3df4f6f0c84ef05b377b42140a5f72.jpg" alt="">
这是一个最简单的常规情况,这时候重做日志似乎没发挥什么作用。但是当数据库出问题了之后,重做日志就能发挥巨大的作用。
还是这个例子一共可能在3个地方出现问题一个还是记录了 `开始事务` 之后,一个是在记录了 `x` 的前后变化之后,最后一个地方是在记录了 `事务提交` 之后。下面这幅图给你展示了这3个可能会出问题的时间
<img src="https://static001.geekbang.org/resource/image/28/yb/2877af8e47ce686d87324ed3c8407yyb.jpg" alt="">
接下来,我们分别来看这三种情况。如果问题是出在记录了 `开始事务` 之后,这时数据库还没有对数据做任何操作,所以数据库在恢复的时候可以什么都不做。
如果问题出在记录了 `x` 的变化之后 ,数据库就需要回滚所有对 `x` 操作。这时候数据库需要用重做日志里 `x` 的初始值100来覆盖现在数据库里的 `x` 的值。这时候不管 `x` 是什么值,覆盖的操作都是正确的。
如果问题是出在记录了提交事务之后,那么数据库需要重新执行所有操作。这时数据库需要用重做日志里 `x` 变化后的值0来覆盖现在数据库里 `x` 的值。
这就是重做日志里“重做”的真正含义,我们通过从头到尾重新执行一遍日志的操作,就能将数据库恢复到正确的状态。
### 两阶段提交实现细节
在[在第13节课](https://time.geekbang.org/column/article/335994)讲2PC的时候我提出过一个问题这里我们快速回顾一下。在第一阶段完成之后第二阶段开始之前单个节点的数据库可能会出问题这时候会出现本地数据库回滚这样会导致第二阶段执行的时候数据丢失。那该怎么办呢
当时我预告过,这个内容我会在第三模块的加餐里给你详细讲解,现在就是揭秘的时候了。
我曾经说过两阶段提交是偏底层的实现,原因是两阶段提交需要扩展重做日志的内容。原始的重做日志分别使用`开始事务``事务提交` 这两个特殊记号,表示事务执行的状态。**两阶段提交需要增加一个特殊标记,叫作** `准备成功`
`准备成功` 这个标记意味着数据库到达了一个中间状态。当数据库出问题准备恢复的时候,如果出现了 `准备成功` 的标记,那么这个事务不会在恢复的时候回滚,这一点和`事务提交`这个标记的作用类似。
`准备成功` 并不代表事务处于可以提交的状态,所以它的出现并不会让事务释放自己的锁。想要真正提交事务,还要等协调者发过来的第二阶段的提交消息。
下面这幅图展示了前面转账例子里,第一阶段在数据库 `A` 的细节重点在第5步。
当数据库 `A` 将对 `x` 的操作记录到重做日志之后,会在重做日志里记录一个 `准备成功` 的标记,然后返回给协调者自己已经准备成功的消息。数据库 `B` 的情况完全一样,所以这里我没有画出来。
<img src="https://static001.geekbang.org/resource/image/a3/e3/a3a64fb5a025aaaef5925de831f76ae3.jpg" alt="">
当第一阶段都成功完成之后,协调者开始第二阶段。下面这幅图展示了第二阶段在数据库 `A` 的细节重点在第2步。当数据库 `A` 收到了协调者给的提交事务的指示后,数据库 `A` 在重做日志内记录一个`事务提交`的记号,这表示本地事务真正提交了。数据库 `B` 的情况也完全一样,所以没有画出来。
<img src="https://static001.geekbang.org/resource/image/3f/5b/3f9032b85b6ab212a736d1ffa3cb4e5b.jpg" alt="">
好了,第三模块的思考题我们就说到这里,希望能给你带来新思路。也欢迎你继续积极思考,通过留言区跟我交流互动。