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,281 @@
<audio id="audio" title="09原子性2PC还是原子性协议的王者吗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b4/b8/b4a6121abb42bcf75e7abcyy8df85cb8.mp3"></audio>
你好我是王磊你也可以叫我Ivan。今天我要和你讲一讲分布式事务的原子性。
在限定“分布式”范围之前,我们先认识一下“事务的原子性”是啥。
如果分开来看的话,事务可以理解为包含一系列操作的序列,原子则代表不可分割的最小粒度。
而合起来看的话事务的原子性就是让包含若干操作的事务表现得像一个最小粒度的操作。这个操作一旦被执行只有“成功”或者“失败”这两种结果。这就好像比特bit只能代表0或者1没有其他选择。
为什么要让事务表现出原子性呢我想举个从ATM取款的例子。
现在你走到一台ATM前要从自己50,000元的账户上取1,000元现金。当你输入密码和取款金额后 ATM会吐出1,000块钱同时你的账户余额会扣减1,000元虽然有些时候ATM出现故障无法吐钞系统会提示取款失败但你的余额还会保持在50,000元。
总之要么既吐钞又扣减余额要么既不吐钞又不扣减余额你拿到手的现金和账户余额总计始终是50,000元这就是一个具有原子性的事务。
显然吐钞和扣减余额是两个不同的操作而且是分别作用在ATM和银行的存款系统上。当事务整合了两个独立节点上的操作时我们称之为分布式事务其达成的原子性也就是分布式事务的原子性。
关于事务的原子性图灵奖得主、事务处理大师詹姆斯·格雷Jim Gray给出了一个更权威的定义
****Atomicity****: Either all the changes from the transaction occur (writes, and messages sent), or none occur.
这句话说得很精炼,我再和你解释下。
原子性就是要求事务只有两个状态:
- 一是成功,也就是所有操作全部成功;
- 二是失败,任何操作都没有被执行,即使过程中已经执行了部分操作,也要保证回滚这些操作。
要做到事务原子性并不容易,因为多数情况下事务是由多个操作构成的序列。而分布式事务原子性的外在表现与事务原子性一致,但前者要涉及多个物理节点,而且增加了网络这个不确定性因素,使得问题更加复杂。
## 实现事务原子性的两种协议
那么,如何协调内部的多项操作,对外表现出统一的成功或失败状态呢?这需要一系列的算法或协议来保证。
### 面向应用层的TCC
原子性提交协议有不少按照其作用范围可以分为面向应用层和面向资源层。我想先给你介绍一种“面向应用层”中比较典型的协议TCC协议。
TCC是Try、Confirm和Cancel三个单词的缩写它们是事务过程中的三个操作。关于TCC的适用场景嘛还记得我在[第1讲](https://time.geekbang.org/column/article/271373)中介绍的“单元架构 + 单体数据库”吗? 这类方案需要在应用层实现事务的原子性经常会用到TCC协议。
下面我用一个转账的例子向你解释TCC处理流程。
小明和小红都是番茄银行的客户现在小明打算给小红转账2,000元这件事在番茄银行存款系统中是如何实现的呢
我们先来看下系统的架构示意图:
<img src="https://static001.geekbang.org/resource/image/2a/5c/2a34990e1c95645f3942cd7d358f4c5c.jpg" alt="9.1">
显然番茄银行的存款系统是单元化架构的。也就是说系统由多个单元构成每个单元包含了一个存款系统的部署实例和对应的数据库专门为某一个地区的用户服务。比如单元A为北京用户服务单元B为上海用户服务。
单元化架构的好处是每个单元只包含了部分用户,这样运行负载比较小,而且一旦出现问题,也只影响到少部分客户,可以提升整个存款系统的可靠性。
不过这种架构也有局限性。那就是虽然单元内的客户转账非常容易但是跨单元的转账需要引入额外的处理机制而TCC就是一种常见的选择。
TCC的整个过程由两类角色参与一类是事务管理器只能有一个另一类是事务参与者也就是具体的业务服务可以是多个每个服务都要提供Try、Confirm和Cancel三个操作。
下面是TCC的具体执行过程。
小明的银行卡在北京的网点开户而小红的银行卡是在上海出差时办理的所以两人的账户分别在单元A和单元B上。现在小明的账户余额是4,900元要给小红转账2,000元一个正常流程是这样的。
<img src="https://static001.geekbang.org/resource/image/61/cd/613371c67df3e1910a77785320586acd.jpg" alt="9.2">
第一阶段事务管理器会发出Try 操作要求进行资源的检查和预留。也就是说单元A要检查小明账户余额并冻结其中的2,000元而单元B要确保小红的账户合法可以接收转账。在这个阶段两者账户余额始终不会发生变化。
第二阶段因为参与者都已经做好准备所以事务管理器会发出Confirm操作执行真正的业务完成2,000元的划转。
但是很不幸,小红账户是无法接收转账的非法账户,处理过程就变成下面的样子。
<img src="https://static001.geekbang.org/resource/image/01/f8/01fdc99cb9ba3233yyaa6a9cbee731f8.jpg" alt="9.3">
第一阶段事务管理器发出Try指令单元B对小红账户的检查没有通过回复No。而单元A检查小明账户余额正常并冻结了2,000元回复Yes。
第二阶段因为前面有参与者回复No所以事务管理器向所有参与者发出Cancel指令让已经成功执行Try操作的单元A执行Cancel操作撤销在Try阶段的操作也就是单元A解除2,000元的资金冻结。
从上述流程可以发现,**TCC仅是应用层的分布式事务框架**,具体操作完全依赖于业务编码实现,可以做针对性的设计,但是这也意味着业务侵入会比较深。
此外考虑到网络的不可靠操作指令必须能够被重复执行这就要求Try、Confirm、Cancel必须是幂等性操作也就是说要确保执行多次与执行一次得到相同的结果。显然这又增加了开发难度。
那还有其他的选择吗?
当然有我们来看看数据库领域最常用的两阶段提交协议Two-Phase Commit2PC这也是面向资源层的典型协议。
### 数据库领域最常用的2PC
2PC的首次正式提出是在Jim Gray 1977年发表的一份文稿中文稿的题目是“[Notes on Data Base Operating Systems](https://cs.nyu.edu/courses/fall18/CSCI-GA.3033-002/papers/Gray1978.pdf)”对当时数据库系统研究成果和实践进行了总结而2PC在工程中的应用还要再早上几年。
2PC的处理过程也分为准备和提交两个阶段每个阶段都由事务管理器与资源管理器共同完成。其中事务管理器作为事务的协调者只有一个而资源管理器作为参与者执行具体操作允许有多个。
2PC具体是如何运行的呢我们还是说回小明转账的例子。
小明给小红转账没有成功,两人又到木瓜银行来尝试。
木瓜银行的存款系统采用了分库分表方案,系统架构大致是这样的:
<img src="https://static001.geekbang.org/resource/image/4f/24/4f417c89459f5f2f30bf7148ea747824.jpg" alt="9.4">
在木瓜银行的存款系统中,所有客户的数据被分散存储在多个数据库实例中,这些数据库实例具有完全相同的表结构。业务逻辑部署在应用服务器上,通过数据库中间件访问底层的数据库实例。数据库中间件作为事务管理器,资源管理器就是指底层的数据库实例。
假设小明和小红的数据分别被保存在数据库D1和D2上。
我们还是先讲正常的处理流程。
<img src="https://static001.geekbang.org/resource/image/c1/1f/c1e92da6dbf1d6e92628383089a8ab1f.jpg" alt="9.5">
第一阶段是准备阶段事务管理器首先向所有参与者发送待执行的SQL并询问是否做好提交事务的准备Prepare参与者记录日志、分别锁定了小明和小红的账户并做出应答协调者接收到反馈Yes准备阶段结束。
第二阶段是提交阶段如果所有数据库的反馈都是Yes则事务管理器会发出提交Commit指令。这些数据库接受指令后会进行本地操作正式提交更新余额给小明的账户扣减2,000元给小红的账户增加2,000元然后向协调者返回Yes事务结束。
那如果小明的账户出了问题,导致转账失败,处理过程会是怎样呢?
<img src="https://static001.geekbang.org/resource/image/8c/c8/8c1cd6763c88b1fbf9be14402f3bfbc8.jpg" alt="9.6">
第一阶段事务管理器向所有数据库发送待执行的SQL并询问是否做好提交事务的准备。
由于小明之前在木瓜银行购买了基金定投产品按照约定每月银行会自动扣款购买基金刚好这个自动扣款操作正在执行先一步锁定了账户。数据库D1发现无法锁定小明的账户只能向事务管理器返回失败。
第二阶段因为事务管理器发现数据库D1不具备执行事务的条件只能向所有数据库发出“回滚”Rollback指令。所有数据库接收到指令后撤销第一阶段的操作释放资源并向协调者返回Yes事务结束。小明和小红的账户余额均保持不变。
### 2PC的三大问题
学完了TCC和2PC的流程我们来对比下这两个协议。
相比于TCC2PC的优点是借助了数据库的提交和回滚操作不侵入业务逻辑。但是它也存在一些明显的问题
1. **同步阻塞**
执行过程中,数据库要锁定对应的数据行。如果其他事务刚好也要操作这些数据行,那它们就只能等待。其实同步阻塞只是设计方式,真正的问题在于这种设计会导致分布式事务出现高延迟和性能的显著下降。
1. **单点故障**
事务管理器非常重要,一旦发生故障,数据库会一直阻塞下去。尤其是在第二阶段发生故障的话,所有数据库还都处于锁定事务资源的状态中,从而无法继续完成事务操作。
1. **数据不一致**
在第二阶段当事务管理器向参与者发送Commit请求之后发生了局部网络异常导致只有部分数据库接收到请求但是其他数据库未接到请求所以无法提交事务整个系统就会出现数据不一致性的现象。比如小明的余额已经能够扣减但是小红的余额没有增加这样就不符合原子性的要求了。
你可能会问:**这些问题非常致命呀2PC到底还能不能用**
所以网上很多文章会建议你避免使用2PC替换为 TCC或者其他事务框架。
但我要告诉你的是别轻易放弃2PC都提出40多年了学者和工程师们也没闲着已经有很多对2PC的改进都在不同程度上解决了上述问题。
事实上多数分布式数据库都是在2PC协议基础上改进来保证分布式事务的原子性。这里我挑选了两个有代表性的2PC改进模型和你展开介绍它们分别来自分布式数据库的两大阵营NewSQL和PGXC。
## 分布式数据库的两个2PC改进模型
### NewSQL阵营Percolator
首先我们要学习的是NewSQL阵营的Percolator。
Percolator来自Google的论文“[Large-scale Incremental Processing Using Distributed Transactions and Notifications](https://www.cs.princeton.edu/courses/archive/fall10/cos597B/papers/percolator-osdi10.pdf)”因为它是基于分布式存储系统BigTable建立的模型所以可以和NewSQL无缝链接。
Percolator模型同时涉及了隔离性和原子性的处理。今天我们主要关注原子性的部分在讲并发控制时我再展开隔离性的部分。
使用Percolator模型的前提是事务的参与者即数据库要**支持多版本并发控制MVCC**。不过你不用担心现在主流的单体数据库和分布式数据库都是支持的MVCC。
在转账事务开始前小明和小红的账户分别存储在分片P1和P2上。如果你不了解分片的含义可以回到[第6讲](https://time.geekbang.org/column/article/275696)学习。当然,你也可以先用单体数据库来替换分片的概念,这并不会妨碍对流程的理解。
<img src="https://static001.geekbang.org/resource/image/55/67/55141bef63a89718517cda63512af967.jpg" alt="9.7">
上图中的Ming代表小明Hong代表小红。在分片的账户表中各有两条记录第一行记录的指针write指向第二行记录实际的账户余额存储在第二行记录的Bal. data字段中。
Bal.data分为两个部分冒号前面的是时间戳代表记录的先后次序后面的是真正的账户余额。我们可以看到现在小明的账户上有4,900元小红的账户上有300元。
我们来看下Percolator的流程。
<img src="https://static001.geekbang.org/resource/image/e6/25/e610bcb9d4fa5b53cf9e7f293b4da425.jpg" alt="9.8">
**第一,准备阶段**事务管理器向分片发送Prepare请求包含了具体的数据操作要求。
分片接到请求后要做两件事写日志和添加私有版本。关于私有版本你可以简单理解为在lock字段上写入了标识信息的记录就是私有版本只有当前事务能够操作通常其他事务不能读写这条记录。
你可能注意到了两个分片上的lock内容并不一样。
主锁的选择是随机的参与事务的记录都可能拥有主锁但一个事务只能有一条记录拥有主锁其他参与事务的记录在lock字段记录了指针信息“primary@Ming.bal”指向主锁记录。
准备阶段结束的时候,两个分片都增加了私有版本记录,余额正好是转账顺利执行后的数字。
<img src="https://static001.geekbang.org/resource/image/f2/60/f2a39536e65c8e0f4c282a0e05274160.jpg" alt="9.9">
**第二,提交阶段**事务管理器只需要和拥有主锁的分片通讯发送Commit指令且不用附带其他信息。
分片P1增加了一条新记录时间戳为8指向时间戳为7的记录后者在准备阶段写入的主锁也被抹去。这时候7、8两条记录不再是私有版本所有事务都可以看到小明的余额变为2,700元事务结束。
你或许要问,为什么在提交阶段不用更新小红的记录?
Percolator最有趣的设计就是这里因为分片P2的最后一条记录保存了指向主锁的指针。其他事务读取到Hong7这条记录时会根据指针去查找Ming.bal发现记录已经提交所以小红的记录虽然是私有版本格式但仍然可视为已经生效了。
当然,这种通过指针查找的方式,会给读操作增加额外的工作。如果每个事务都照做,性能损耗就太大了。所以,还会有其他异步线程来更新小红的余额记录,最终变成下面的样子。
<img src="https://static001.geekbang.org/resource/image/c8/d2/c8dc734cd33a8149eeb1ffb2f435e6d2.jpg" alt="9.10">
现在让我们对比2PC的问题来看看Percolator模型有哪些改进。
1. **数据不一致**
2PC的一致性问题主要缘自第二阶段不能确保事务管理器与多个参与者的通讯始终正常。
但在Percolator的第二阶段事务管理器只需要与一个分片通讯这个Commit操作本身就是原子的。所以事务的状态自然也是原子的一致性问题被完美解决了。
1. **单点故障**
Percolator通过日志和异步线程的方式弱化了这个问题。
一是Percolator引入的异步线程可以在事务管理器宕机后回滚各个分片上的事务提供了善后手段不会让分片上被占用的资源无法释放。
二是,事务管理器可以用记录日志的方式使自身无状态化,日志通过共识算法同时保存在系统的多个节点上。这样,事务管理器宕机后,可以在其他节点启动新的事务管理器,基于日志恢复事务操作。
Percolator模型在分布式数据库的工程实践中被广泛借鉴。比如分布式数据库TiDB完全按照该模型实现了事务处理CockroachDB也从Percolator模型获得灵感设计了自己的2PC协议。
CockroachDB的变化在于没有随机选择主锁而是引入了一张全局事务表所有分片记录的指针指向了这个事务表中对应的事务记录。单就原子性处理来说这种设计似乎差异不大但在相关设计上会更有优势具体是什么优势呢下一讲我来揭晓答案。
### PGXC阵营GoldenDB的一阶段提交
那么分布式数据库的另一大阵营PGXC又如何解决2PC的问题呢
GoldenDB展现了另外一种改良思路称之为“一阶段提交”。
GoldenDB遵循PGXC架构包含了四种角色协调节点、数据节点、全局事务器和管理节点其中协调节点和数据节点均有多个。GoldenDB的数据节点由MySQL担任后者是独立的单体数据库。
<img src="https://static001.geekbang.org/resource/image/0f/70/0fa31de65b7c81yydda0d319ebe06070.jpg" alt="9.11">
虽然名字叫“一阶段提交”但GoldenDB的流程依然可以分为两个阶段。
<img src="https://static001.geekbang.org/resource/image/14/e2/142b33b069b60562f89acdb83c3346e2.jpg" alt="9.12">
第一阶段GoldenDB的协调节点接到事务后在全局事务管理器GTM的全局事务列表中将事务标记成活跃的状态。这个标记过程是GoldenDB的主要改进点实质是通过全局事务列表来申请资源规避可能存在的事务竞争。
这样的好处是避免了与所有参与者的通讯,也减少了很多无效的资源锁定动作。
<img src="https://static001.geekbang.org/resource/image/f6/f3/f65854eceef3335edc3b8930879115f3.jpg" alt="">
第二阶段协调节点把一个全局事务分拆成若干子事务分配给对应的MySQL去执行。如果所有操作成功协调者节点会将全局事务列表中的事务标记为结束整个事务处理完成。如果失败子事务在单机上自动回滚而后反馈给协调者节点后者向所有数据节点下发回滚指令。
**由于GoldenDB属于商业软件公开披露信息有限我们也就不再深入细节了你只要能够理解上面我讲的两个阶段就够了。**
GoldenDB的“一阶段提交”本质上是改变了资源的申请方式更准确的说法是并发控制手段从锁调度变为时间戳排序Timestamp Ordering。这样在正常情况下协调节点与数据节点只通讯一次降低了网络不确定性的影响数据库的整体性能有明显提升。因为第一阶段不涉及数据节点的操作也就弱化了数据一致性和单点故障的问题。
## 小结
好了,以上就是今天的主要内容了,我希望你能记住以下几点:
1. 事务的原子性就是让包含若干操作的事务表现得像一个最小粒度的操作,而这个操作一旦被执行只有两种结果,成功或者失败。
1. 相比于单机事务分布式事务原子性的复杂之处在于增加了多物理设备和网络的不确定性需要通过一定的算法和协议来实现。这类协议也有不少我重点介绍了TCC和2PC这两个常用协议。
1. TCC提供了一个应用层的分布式事务框架它对参与者没有特定要求但有较强的业务侵入2PC是专为数据库这样的资源层设计的不侵入业务也是今天分布式数据库主流产品的选择。
1. 考虑到2PC的重要性和人们对其实用价值的误解我又展开说明2PC的两种改良模型分别是Percolator和GoldenDB的“一阶段提交”。Percolator将2PC第二阶段工作简化到极致减少了与参与者的通讯完美解决了一致性问题同时通过日志和异步线程弱化了单点故障问题。GoldenDB则改良了2PC第一阶段的资源协调过程将协调者与多个参与者的交互转换为协调者与全局事务管理器的交互同样达到了减少通讯的效果弱化了一致性和单点故障的问题。
这节课马上就要结束了你可能要问为什么咱们没学三阶段提交协议Three-Phase Commit3PC
原因也很简单因为3PC虽然试图解决2PC的问题但它的通讯开销更大在网络分区时也无法很好地工作很少在工程实践中使用所以我就没有介绍你只要知道有这么个协议就好。
另外我还要提示一个容易与2PC协议混淆的概念也就是两阶段封锁协议Two-Phase Locking2PL
我认为这种混淆并不只是因为名字相似。从整个分布式事务看原子性协议之外还有一层隔离性协议由后者保证事务能够成功申请到资源。在相当长的一段时间里2PC与2PL的搭配都是一种主流实现方式可能让人误以为它们是可以替换的术语。实际上两者是截然不同的2PC是原子性协议而2PL是一种事务的隔离性协议也是一种并发控制算法。
在这一节中其实我们多次提到了并发控制算法但都没有展开介绍原因是这部分内容确实比较复杂没办法用三言两语说清我会在后面第13讲和第14讲中详细解释。
两种改良模型都一定程度上化解了2PC的单点故障和数据一致性问题但同步阻塞导致的性能问题还没有根本改善而这也是2PC最被诟病的地方可能也是很多人放弃分布数据库的理由。
可是2PC注定就是延时较长、性能差吗或者说分布式数据库中的分布式事务延时一定很长吗
我想告诉你的是其实不少优秀的分布式数据库产品已经大幅缩短了2PC的延时无论是理论模型还是工程实践都已经过验证。
那么,它们又有哪些精巧构思呢?我将在下一讲为你介绍这些黑科技。
<img src="https://static001.geekbang.org/resource/image/0a/91/0a676d16295d91870a30caa1fccd4c91.jpg" alt="">
## 思考题
最后我给你留下一个思考题。今天内容主要围绕着2PC展开而它的第一阶段“准备阶段”也被称为“投票阶段”“投票”这个词是不是让你想到Paxos协议呢
那么你觉得2PC和Paxos协议有没有关系如果有又是什么关系呢
如果你想到了答案,又或者是触发了你对相关问题的思考,都可以在评论区和我聊聊,我会在下一讲和你一起探讨。最后,谢谢你的收听,希望这节课能带给你一些收获,欢迎你把它分享给周围的朋友,一起进步。
## 学习资料
Daniel Peng and Frank Dabek: [**Large-scale Incremental Processing Using Distributed Transactions and Notifications**](https://www.cs.princeton.edu/courses/archive/fall10/cos597B/papers/percolator-osdi10.pdf)
Jim Gray: [**Notes on Data Base Operating Systems**](https://cs.nyu.edu/courses/fall18/CSCI-GA.3033-002/papers/Gray1978.pdf)

View File

@@ -0,0 +1,183 @@
<audio id="audio" title="10 | 原子性:如何打破事务高延迟的魔咒?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/a2/b77b89fb143f690efd142cb20eda0da2.mp3"></audio>
你好我是王磊你也可以加我Ivan。
通过上一讲的学习你已经知道使用两阶段提交协议2PC可以保证分布式事务的原子性但是2PC的性能始终是一个绕不过去的坎儿。
那么,它到底有多慢呢?
我们来看一组具体数据。2013年的MySQL技术大会上Percona Live MySQL C&amp;E 2013Randy Wigginton等人在一场名为“Distributed Transactions in MySQL”的演讲中公布了一组XA事务与单机事务的对比数据。XA协议是2PC在数据库领域的具体实现而MySQLInnoDB存储引擎正好就支持XA协议。我把这组数据转换为下面的折线图这样看起来会更加直观些。
<img src="https://static001.geekbang.org/resource/image/72/ya/7227f5f6b32f6d9bb4cafefa96ac8yya.jpg" alt="">
其中横坐标是并发线程数量纵坐标是事务延迟以毫秒为单位蓝色的折线表示单机事务红色的折线式表示跨两个节点的XA事务。我们可以清晰地看到无论并发数量如何XA事务的延迟时间总是在单机事务的10倍以上。
这绝对是一个巨大的性能差距,所以这个演讲最终的建议是“不要使用分布式事务”。
很明显今天任何计划使用分布式数据库的企业都不可能接受10倍于单体数据库的事务延迟。如果仍旧存在这样大的差距那分布式数据库也必然是无法生存的所以它们一定是做了某些优化。
具体是什么优化呢?这就是我们今天要讨论的主题,分布式事务要怎么打破高延迟的魔咒。
先别急揭开谜底之前我们先来算算2PC协议的事务延迟大概是多少。当然这里我们所说的2PC都是指基于Percolator优化的改进型如果你还不了解Percolator可以回到[第9讲](https://time.geekbang.org/column/article/278949)复习一下。
## 事务延迟估算
整个2PC的事务延迟由两个阶段组成可以用公式表达为
$$L_{txn} = L_{prep} + L_{commit}$$
其中,$L_{prep}$是准备阶段的延迟,$L_{commit}$是提交阶段的延迟。
我们先说准备阶段它是事务操作的主体包含若干读操作和若干写操作。我们把读操作的次数记为R读操作的平均延迟记为$L_{r}$写操作次数记为W写操作平均延迟记为$L_{w}$。那么整个准备阶段的延迟可以用公式表达为:
$$L_{prep} = R * L_{r} + W * L_{w}$$
在不同的产品架构下读操作的成本是不一样的。我们选一种最乐观的情况CockroachDB。因为它采用P2P架构每个节点既承担了客户端服务接入的工作也有请求处理和数据存储的职能。所以最理想的情况是读操作的客户端接入节点同时是当前事务所访问数据的Leader节点那么所有读取就都是本地操作。
磁盘操作相对网络延迟来说是极短的,所以我们可以忽略掉读取时间。那么,准备阶段的延迟主要由写入操作决定,可以用公式表达为:
$$L_{prep} = W * L_{w}$$
我们都知道,分布式数据库的写入,并不是简单的本地操作,而是使用共识算法同时在多个节点上写入数据。所以,一次写入操作延迟等于一轮共识算法开销,我们用$L_{c}$代表一轮共识算法的用时,可以得到下面的公式:
$$L_{prep} = W * L_{c}$$
我们再来看第二阶段提交阶段第9讲我们介绍了Percolator模型它的提交阶段只需要写入一次数据修改整个事务的状态。对于CockroachDB这个事务标识可以保存在本地。那么提交操作的延迟也是一轮共识算法也就是
$$L_{commit} = L_{c}$$
分别得到两个阶段的延迟后,带入最开始的公式,可以得到:
$$L_{txn} = (W + 1) * L_{c}$$
我们把这个公式带入具体例子里来看一下。
这次还是小明给小红转账金额是500元。
<img src="https://static001.geekbang.org/resource/image/e7/a3/e70f3cbc6ef1637f7b3feb81d8ba33a3.jpg" alt="">
在这个转账事务中包含两条写操作SQL分别是扣减小明账户余额和增加小红账户余额W等于2。再加上提交操作一共有3个$L_{c}$。我们可以看到这个公式里事务的延迟是与写操作SQL的数量线性相关的而真实场景中通常都会包含多个写操作那事务延迟肯定不能让人满意。
## 优化方法
### 缓存写提交Buffering Writes until Commit
怎么缩短写操作的延迟呢?
第一个办法是将所有写操作缓存起来直到commit语句时一起执行这种方式称为Buffering Writes until Commit我把它翻译为**“缓存写提交”**。而TiDB的事务处理中就采用这种方式我借用TiDB官网的一张交互图来说明执行过程。
<img src="https://static001.geekbang.org/resource/image/73/60/73yy16e27a530e0079yyc077623d7460.jpg" alt="">
所有从Client端提交的SQL首先会缓存在TiDB节点只有当客户端发起Commit时TiDB节点才会启动两阶段提交将SQL被转换为TiKV的操作。这样显然可以压缩第一阶段的延迟把多个写操作SQL压缩到大约一轮共识算法的时间。那么整个事务延迟就是
$$L_{txn} = 2 * L_{c}$$
但缓存写提交存在两个明显的缺点。
首先是在客户端发送Commit前SQL要被缓存起来如果某个业务场景同时存在长事务和海量并发的特点那么这个缓存就可能被撑爆或者成为瓶颈。
其次是客户端看到的SQL交互过程发生了变化在MySQL中如果出现事务竞争判断优先级的规则是First Write Win也就是对同一条记录先执行写操作的事务获胜。而TiDB因为缓存了所有写SQL所以就变成了First Commit Win也就是先提交的事务获胜。我们用一个具体的例子来演示这两种情况。
<img src="https://static001.geekbang.org/resource/image/22/d5/224a8afff7b76200b5db9ae4949218d5.jpg" alt="">
在MySQL中同时执行T1T2两个事务T1先执行了update所以获得优先权成功提交。而T2被阻塞等待T1提交后才完成提交。
<img src="https://static001.geekbang.org/resource/image/4b/41/4b3df90a47a28339e0f47717ea3fd041.jpg" alt="">
在TiDB中执行同样的T1、T2虽然T2晚于T1执行update但却先执行了commit所以T2获胜T1失败。
First Write Win与First Commit Win在交互上是显然不同的这虽然不是大问题但对于开发者来说还是有一定影响的。可以说TiDB的“缓存写提交”方式已经不是完全意义上的交互事务了。
### 管道Pipeline
有没有一种方法既能缩短延迟又能保持交互事务的特点呢还真有。这就是CockroachDB采用的方式称为Pipeline。具体过程就是在准备阶段是按照顺序将SQL转换为K/V操作并执行但是并不等待返回结果直接执行下一个K/V操作。
这样,准备阶段的延迟,等于最慢的一个写操作延迟,也就是一轮共识算法的开销,所以整体延迟同样是:
$$L_{prep} = L_{c}$$
那么,加上提交阶段的一轮共识算法开销:
$$L_{txn} = 2 * L_{c}$$
我们再回到小明转账的例子来看一下。
<img src="https://static001.geekbang.org/resource/image/c9/d1/c9e61yy462e8ceb27dbdb0ddb2157bd1.jpg" alt="">
同样的操作按照Pipeline方式增加小红账户余额时并不等待小明扣减账户的动作结束两条SQL的执行时间约等于1个$L_{c}$。加上提交阶段的1个$L_{c}$一共是2个$L_{c}$并且延迟也不再随着SQL数量增加而延长。
2个$L_{c}$是多久呢?我们带入真实场景,来计算一下 。
首先我们评估一下期望值。对于联机交易来说延迟通常不超过1秒如果用户体验良好则要控制在500毫秒以内。其中留给数据库的处理时间不会超过一半也就是250-500毫秒。这样推算$L_{c}$应该控制在125-250毫秒之间。
再来看看真实的网络环境。我们知道人类现有的科技水平是不能超越光速的这个光速是指光在真空中的传播速度大约是30万千米每秒。而光纤由于传播介质不同和折线传播的关系传输速度会降低30%大致是20万千米每秒。但是这仍然是一个比较理想的速度因为还要考虑网络上的各种设备、协议处理、丢包重传等等情况实际的网络延迟还要长很多。
为了让你有一个更直观的感受。我这里引用了论文“[Highly Available Transactions: Virtues and Limitations](https://amplab.cs.berkeley.edu/wp-content/uploads/2013/10/hat-vldb2014.pdf)“中的一些数据这篇论文发表在VLDB2014上在部分章节中初步探讨了系统全球化部署面临的延迟问题。论文作者在亚马逊EC2上使用Ping包的方式进行了实验并统计了一周时间内7个不同地区机房之间的RTTRound-Rip Time往返延迟数据。
简单来说RTT就是数据在两个节点之间往返一次的耗时。在讨论网络延迟的时候为了避免歧义我们通常使用RTT这个概念。
<img src="https://static001.geekbang.org/resource/image/37/10/37be4540d53b39372dd5ef7e623ef210.jpg" alt="">
实验中地理跨度较大两个机房是巴西圣保罗和新加坡两地之间的理论RTT是106.7毫秒使用光速测算而实际测试的RTT均值为362.8毫秒P9595%RTT均值为649毫秒。将649毫秒代入公式那$L_{txn}$就是接近1.3秒,这显然太长了。而考虑到共识算法的数据包更大,这个延迟还会更长。
### 并行提交Parallel Commits
但是像CockroachDB、YugabyteDB这样分布式数据库它们的目标就是全球化部署所以还要努力去压缩事务延迟。
可是还能怎么压缩呢准备阶段的操作已经压缩到极限了commit这个动作也不能少呀那就只有一个办法让这两个动作并行执行。
在优化前的处理流程中CockroachDB会记录事务的提交状态
```
TransactionRecord{
Status: COMMITTED,
...
}
```
并行执行的过程是这样的。
准备阶段的操作在CockroachDB中被称为意向写。这个并行执行就是在执行意向写的同时就写入事务标志当然这个时候不能确定事务是否提交成功的所以要引入一个新的状态“Staging”表示事务正在进行。那么这个记录事务状态的落盘操作和意向写大致是同步发生的所以只有一轮共识算法开销。事务表中写入的内容是类似这样的
```
TransactionRecord{
Status: STAGING,
Writes: []Key{&quot;A&quot;, &quot;C&quot;, ...},
...
}
```
Writes部分是意向写的Key。这是留给异步进程的线索通过这些Key是否写成功可以倒推出事务是否提交成功。
而客户端得到所有意向写的成功反馈后,可以直接返回调用方事务提交成功。**注意!这个地方就是关键了**,客户端只在当前进程内判断事务提交成功后,不维护事务状态,而直接返回调用方;事后由异步线程根据事务表中的线索,再次确认事务的状态,并落盘维护状态记录。这样事务操作中就减少了一轮共识算法开销。
<img src="https://static001.geekbang.org/resource/image/cf/78/cf8be2fc332e664edde17be516e9f578.jpg" alt="">
你有没有发现并行提交的优化思路其实和Percolator很相似那就是不要纠结于在一次事务中搞定所有事情可以只做最少的工作留下必要的线索就可以达到极致的速度。而后续的异步进程只要根据线索完成收尾工作就可以了。
## 小结
好了,这讲的内容到这里就该结束了。那么,让我们再回顾一下今日的内容吧。
1. 高延迟一直是分布式事务的痛点。在一些测试案例中MySQL多节点的XA事务延迟甚至达到单机事务的10倍。按照2PC协议的处理过程分布式事务延迟与事务内写操作SQL语句数量直接相关。延迟时间可以用公式表达为$L_{txn} = (W + 1) * L_{c}$ 。
1. 使用缓存写提交方式优化,可以缩短准备阶段的延迟,$L_{txn} = 2 * L_{c}$。但这种方式与事务并发控制技术直接相关仅在乐观锁时适用TiDB使用了这种方式。但是一旦将并发控制改为悲观协议事务延迟又会上升。
1. 通过管道方式优化,整体事务延迟可以降到两轮共识算法开销,并且在悲观协议下也适用。
1. 使用并行提交可以进一步将整体延迟压缩到一轮共识算法开销。CockroachDB使用了管道和并行提交这两种优化手段。
今天我们分析了分布式事务高延迟的原因和一些优化的手段理想的情况下事务延迟可以缩小到一轮共识算法开销。你看是不是对分布式数据库更有信心了。当然在测算事务延迟时我们还是预设了一些前提比如读操作成本趋近于零这仅在特定情况下对CockroachDB适用很多时候是不能忽略的其他产品则更是不能无视这个成本。那么在全球化部署下执行读操作时如何获得满意延迟呢或者还有什么其他难题我们在第24讲中会继续探讨。
<img src="https://static001.geekbang.org/resource/image/02/a6/023b197d9e33858572d4daeafb8612a6.jpg" alt="">
## 思考题
最后我们的思考题还是关于2PC的。第9讲和第10讲中我们介绍了2PC的各种优化手段今天最后介绍的“并行提交”方式将延迟压缩达到的一轮共识算法开销应该是现阶段比较极致的方法了。不过在工程实现中其实还有一些其他的方法也很有趣我想请你也介绍下自己了解的2PC优化方法。
欢迎你在评论区留言和我一起讨论,我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对如何优化分布式事务性能这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。
## 学习资料
Peter Bailis et al.: [**Highly Available Transactions: Virtues and Limitations**](https://amplab.cs.berkeley.edu/wp-content/uploads/2013/10/hat-vldb2014.pdf)
Randy Wigginton et al.: **Distributed Transactions in MySQL**

View File

@@ -0,0 +1,191 @@
<audio id="audio" title="11隔离性读写冲突时快照是最好的办法吗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/57/68/5795d39b4ef37e3cc01f18cd9661e368.mp3"></audio>
你好我是王磊你也可以叫我Ivan。我们今天的话题要从多版本并发控制开始。
多版本并发控制Multi-Version Concurrency ControlMVCC就是**通过记录数据项历史版本的方式,来提升系统应对多事务访问的并发处理能力**。今天几乎所有主流的单体数据库都实现了MVCC它已经成为一项非常重要也非常普及的技术。
MVCC为什么这么重要呢我们通过下面例子来回顾一下MVCC出现前的读写冲突场景。
<img src="https://static001.geekbang.org/resource/image/f2/fc/f2dfc3915ed8f39f4eec8d73bf1b80fc.jpg" alt="">
图中事务T1、T2先后启动分别对数据库执行写操作和读操作。写操作是一个过程在过程中任意一点数据的变更都是不完整的所以T2必须在数据写入完成后才能读取也就形成了读写阻塞。反之如果T2先启动T1也要等待T2将数据完全读取后才能执行写入。
早期数据库的设计和上面的例子一样,读写操作之间是互斥的,具体是通过锁机制来实现的。
你可能会觉得这个阻塞也没那么严重,磁盘操作应该很快吧?
别着急下结论让我们来分析下。如果先执行的是T1写事务除了磁盘写入数据的时间由于要保证数据库的高可靠至少还有一个备库同步复制主库的变更内容。这样阻塞时间就要再加上一次网络通讯的开销。
如果先执行的是T2只读事务情况也好不到哪去虽然不用考虑复制问题但是读操作通常会涉及更大范围的数据这样一来加锁的记录会更多被阻塞的写操作也就更多。而且只读事务往往要执行更加复杂的计算阻塞的时间也就更长。
所以说用锁解决读写冲突问题带来的事务阻塞开销还是不小的。相比之下用MVCC来解决读写冲突就不存在阻塞问题要优雅得多了。
[第4讲](https://time.geekbang.org/column/article/274200)中我们介绍了PGXC和NewSQL两种架构风格而且还说到分布式数据库的很多关键设计是和整体架构风格有关的。MVCC的设计就是这样随架构风格不同而不同。在PGXC架构中因为数据节点就是单体数据库所以**PGXC的MVCC实现方式其实就是单体数据库的实现方式。**
## 单体数据库的MVCC
那么就让我们先看下单体数据库的MVCC是怎么设计的。开头我们说了实现MVCC要记录数据的历史版本这就涉及到存储的问题。
### MVCC的存储方式
MVCC有三类存储方式一类是将历史版本直接存在数据表中的称为Append-Only典型代表是PostgreSQL。另外两类都是在独立的表空间存储历史版本它们区别在于存储的方式是全量还是增量。增量存储就是只存储与版本间变更的部分这种方式称为Delta也就是数学中常作为增量符号的那个Delta典型代表是MySQL和Oracle。全量存储则是将每个版本的数据全部存储下来这种方式称为Time-Travle典型代表是HANA。我把这三种方式整理到了下面的表格中你看起来会更直观些。
<img src="https://static001.geekbang.org/resource/image/93/5f/93a030347f0a93c9a97739f0a82b515f.jpg" alt="">
下面,我们来看看每种方式的优缺点。
#### Append-Only方式
优点
1. 在事务包含大量更新操作时也能保持较高效率。因为更新操作被转换为Delete + Insert数据并未被迁移只是有当前版本被标记为历史版本磁盘操作的开销较小。
1. 可以追溯更多的历史版本,不必担心回滚段被用完。
1. 因为执行更新操作时,历史版本仍然留在数据表中,所以如果出现问题,事务能够快速完成回滚操作。
缺点
1. 新老数据放在一起,会增加磁盘寻址的开销,随着历史版本增多,会导致查询速度变慢。
#### Delta方式
优点
1. 因为历史版本独立存储,所以不会影响当前读的执行效率。
1. 因为存储的只是变化的增量部分,所以占用存储空间较小。
缺点
1. 历史版本存储在回滚段中,而回滚段由所有事务共享,并且还是循环使用的。如果一个事务执行持续的时间较长,历史版本可能会被其他数据覆盖,无法查询。
1. 这个模式下读取的历史版本,实际上是基于当前版本和多个增量版本计算追溯回来的,那么计算开销自然就比较大。
Oracle早期版本中经常会出现的ORA-01555 “快照过旧”Snapshot Too Old就是回滚段中的历史版本被覆盖造成的。通常设置更大的回滚段和缩短事务执行时间可以解决这个问题。随着Oracle后续版本采用自动管理回滚段的设计这个问题也得到了缓解。
#### Time-Travel方式
优点
1. 同样是将历史版本独立存储,所以不会影响当前读的执行效率。
1. 相对Delta方式历史版本是全量独立存储的直接访问即可计算开销小。
缺点
1. 相对Delta方式需要占用更大的存储空间。
当然,无论采用三种存储方式中的哪一种,都需要进行历史版本清理。
好了以上就是单体数据库MVCC的三种存储方式同时也是PGXC的实现方式。而NewSQL底层使用分布式键值系统来存储数据MVCC的存储方式与PostgreSQL类似采用Append方式追加新版本。我觉得你应该比较容易理解就不再啰嗦了。
为了便于你记忆,我把三种存储方式的优缺点提炼了一下放到下面表格中,其实说到底这些特点就是由“是否独立存储”和“存储全量还是存储增量变更”这两个因素决定的。
<img src="https://static001.geekbang.org/resource/image/ae/e3/aec7c3224af50fe8551697c10be430e3.jpg" alt="">
### MVCC的工作过程
历史版本存储下来后又是如何发挥作用的呢?这个,我们开头时也说过了,是要解决多事务的并发控制问题,也就是保证事务的隔离性。在[第3讲](https://time.geekbang.org/column/article/272999)我们介绍了隔离性的多个级别其中可接受的最低隔离级别就是“已提交读”Read CommittedRC
那么我们先来看RC隔离级别下MVCC的工作过程。
按照RC隔离级别的要求事务只能看到的两类数据
1. 当前事务的更新所产生的数据。
1. 当前事务启动前,已经提交事务更新的数据。
我们用一个例子来说明。
<img src="https://static001.geekbang.org/resource/image/bc/01/bc504c1f630657a17734380def013c01.jpg" alt="">
T1到T7是七个数据库事务它们先后运行分别操作数据库表的记录R1到R7。事务T6要读取R1到R6这六条记录在T6启动时T6-1会向系统申请一个活动事务列表活动事务就是已经启动但尚未提交的事务这个列表中会看到T3、T4、T5等三个事务。
T6查询到R3、R4、R5时看到它们最新版本的事务ID刚好在活动事务列表里就会读取它们的上一版本。而R1、R2最新版本的事务ID小于活动事务列表中的最小事务ID即T3所以T6可以看到R1、R2的最新版本。
这个例子中MVCC的收益非常明显T6不会被正在执行写入操作的三个事务阻塞而如果按照原来的锁方式T6要在T3、T4、T5三个事务都结束后才能执行。
### 快照的工作原理
MVCC在RC级别的效果还不错。那么如果隔离级别是更严格一些的 “可重复读”RR我们继续往下看。
<img src="https://static001.geekbang.org/resource/image/03/7d/03ecd79c93d3c1cd3a40af813yy7507d.jpg" alt="">
还是继续刚才的例子当T6执行到下一个时间点T6-2T1到T4等4个事务都已经提交此时T6再次向系统申请活动事务列表列表包含T5和T7。遵循同样的规则这次T6可以看到R1到R4等四条记录的最新版本同时看到R5的上一版本。
很明显T6刚才和现在这两次查询得到了不同的结果集这是不符合RR要求的。
实现RR的办法也很简单我们只需要记录下T6-1时刻的活动事务列表在T6-2时再次使用就行了。那么这个反复使用的活动事务列表就被称为“快照”Snapshot
<img src="https://static001.geekbang.org/resource/image/86/ac/86d3436e8ca462202b8d9ebde33fabac.jpg" alt="">
快照是基于MVCC实现的一个重要功能从效果上看 快照就是快速地给数据库拍照片数据库会停留在你拍照的那一刻。所以用“快照”来实现RR是很方便的。
从上面的例子可以发现RC与RR的区别在于RC下每个SQL语句会有一个自己的快照所以看到的数据库是不同的而RR下所有SQL语句使用同一个快照所以会看到同样的数据库。
为了提升效率快照不是单纯的事务ID列表它会统计最小活动事务ID还有最大已提交事务ID。这样多数事务ID通过比较边界值就能被快速排除掉如果事务ID恰好在边界范围内再进一步查找是否与活跃事务ID匹配。
快照在MySQL中称为ReadView在PostgreSQL中称为SnapshotData组织方式都是类似的。
## PGXC读写冲突处理
在PGXC架构中实现RC隔离级的处理过程与单体数据库差异并不大。我想和你重点介绍的是PGXC在实现RR时遇到的两个挑战也就是实现快照的两个挑战。
一是如何保证产生单调递增事务ID。每个数据节点自行处理显然不行这就需要由一个集中点来统一生成。
二是如何提供全局快照。每个事务要把自己的状态发送给一个集中点,由它维护一个全局事务列表,并向所有事务提供快照。
所以PGXC风格的分布式数据库都有这样一个集中点通常称为全局事务管理器GTM。又因为事务ID是单调递增的用来衡量事务发生的先后顺序和时间戳作用相近所以全局事务管理器也被称为“全局时钟”。
## NewSQL读写冲突处理
讲完PGXC的快照再来看看NewSQL如何处理读写冲突。这里我要向你介绍TiDB和CockroachDB两种实现方式因为它们是比较典型的两种情况。至于它们哪里典型呢我先不说你可以在阅读过程中仔细体会。
### TiDB
首先来说TiDB我们看图说话。
<img src="https://static001.geekbang.org/resource/image/e9/d9/e991e0cbf84a02aa5c3851fa083f24d9.jpg" alt="">
TiDB底层是分布式键值系统我们假设两个事务操作同一个数据项。其中事务T1执行写操作由Prewrite和Commit两个阶段构成对应了我们之前介绍的两阶段提交协议2PC如果你还不熟悉可以重新阅读[第9讲](https://time.geekbang.org/column/article/278949)复习一下。这里你也可以简单理解为T1的写操作分成了两个阶段T2在这两个阶段之间试图执行读操作但是T2会被阻塞直到T1完成后T2才能继续执行。
你肯定会非常惊讶这不是MVCC出现前的读写阻塞吗
TiDB为什么没有使用快照读取历史版本呢 TiDB官方文档并没有说明背后的思路我猜问题出在全局事务列表上因为TiDB根本没有设计全局事务列表。当然这应该不是设计上的疏忽我更愿意把它理解为一种权衡是在读写效率和全局事务列表的维护代价之间的选择。
事实上PGXC中的全局事务管理器就是一个单点很容易成为性能的瓶颈而分布式系统一个普遍的设计思想就是要避免对单点的依赖。当然TiDB的这个设计付出的代价也是巨大的。虽然TiDB在3.0版本后增加了悲观锁,设计稍有变化,但大体仍是这样。
### CockroachDB
那么如果有全局事务列表又会怎么操作呢说来也巧CockroachDB真的就设计了这么一张全局事务列表。它是否照搬了单体数据库的“快照”呢答案也是否定的。
我们来看看它的处理过程。
<img src="https://static001.geekbang.org/resource/image/e6/68/e6c49739eb9b6ayy030698013e685f68.jpg" alt="">
依旧是T1事务先执行写操作中途T2事务启动执行读操作此时T2会被优先执行。待T2完成后T1事务被重启。重启的意思是T1获得一个新的时间戳等同于事务ID并重新执行。
又是一个不可思议的过程,还是会产生读写阻塞,这又怎么解释呢?
CockroachDB没有使用快照不是因为没有全局事务列表而是因为它的隔离级别目标不是RR而是SSI也就是可串行化。
你可以回想一下第3讲中黑白球的例子。对于串行化操作来说没有与读写并行操作等价的处理方式因为先读后写和先写后读读操作必然得到两个不同结果。更加学术的解释是先读后写操作会产生一个**读写反向依赖**可能影响串行化事务调度。这个概念有些不好理解你也不用着急在14讲中我会有更详细的介绍。总之CockroachDB对于读写冲突、写写冲突采用了几乎同样的处理方式。
在上面的例子中为了方便描述我简化了读写冲突的处理过程。事实上被重启的事务并不一定是执行写操作的事务。CockroachDB的每个事务都有一个优先级出现事务冲突时会比较两个事务的优先级高优先级的事务继续执行低优先级的事务则被重启。而被重启事务的优先级也会提升避免总是在竞争中失败最终被“饿死”。
TiDB和CockroachDB的实现方式已经讲完了现在你该明白它们典型在哪里了吧那就是全局事务列表是否存在和实现哪种隔离级别这两个因素都会影响最终的设计。
<img src="https://static001.geekbang.org/resource/image/fa/91/fa07956ea5ddb076eefa32ae7affa191.jpg" alt="">
## 小结
好了,今天的话题就谈到这了,让我们一起回顾下这一讲的内容。
1. 用锁机制来处理读写冲突会影响并发处理能力而MVCC的出现很好地解决了这个问题几乎所有的单体数据库都实现了MVCC。MVCC技术包括数据的历史版本存储、清理机制存储方式具体包括Append-Only、Delta、Time-Travel三种方式。通过MVCC和快照基于MVCC单体数据库可以完美地解决RC和RR级别下的读写冲突问题不会造成事务阻塞。
1. PGXC风格的分布式数据库用单体数据库作为数据节点存储数据所以自然延续了其MVCC的实现方式。但PGXC的改变在于增加了全局事务管理器统一管理事务ID的生成和活动事务列表的维护。
1. NewSQL风格的分布式数据库没有普遍采用快照解决读写冲突问题其中TiDB是由于权衡全局事务列表的代价CockroachDB则是因为要实现更高的隔离级别。但无论哪种原因都造成了读写并行能力的下降。
要特别说明的是虽然NewSQL架构的分布式数据库没有普遍使用快照处理读写事务但它们仍然实现了MVCC在数据存储层保留了历史版本。所以NewSQL产品往往也会提供一些低数据一致性的只读事务接口提升读取操作的性能。
## 思考题
最后又到了我们的思考题时间。今天我介绍了两种风格的分布式数据库如何解决读写冲突问题。其实无论是否使用MVCC实现快照隔离时间都是一个重要的因素每个事务都要获得一个事务ID或者时间戳这直接决定了它能够读取什么版本的数据或者是否被阻塞。
但是你有没有想过时间误差的问题。我在[第2讲](https://time.geekbang.org/column/article/272104)中曾经提到Spanner的全局时钟存在7毫秒的误差在[第5讲](https://time.geekbang.org/column/article/274908)又深入探讨了物理时钟和逻辑时钟如何控制时间误差。那么,你觉得时间误差会影响读写冲突的处理吗?
如果你想到了答案,又或者是触发了你对相关问题的思考,都可以在评论区和我聊聊,我会在下一讲和你一起探讨。最后,希望这节课能带给你一些收获,也欢迎你把它分享给周围的朋友,一起进步。

View File

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

View File

@@ -0,0 +1,132 @@
<audio id="audio" title="13 | 隔离性:为什么使用乐观协议的分布式数据库越来越少?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7b/67/7b198f2fyyc0c6f470afc0ac7e382b67.mp3"></audio>
你好我是王磊你也可以叫我Ivan。
我们在11、12两讲已经深入分析了读写冲突时的控制技术这项技术的核心是在实现目标隔离级别的基础上最大程度地提升读写并发能力。但是读写冲突只是事务冲突的部分情况更多时候我们要面对的是写写冲突甚至后者还要更重要些。
你可能经常会听到这类说法“某某网站的架构非常牛能抗住海量并发”“某某网站很弱搞个促销让大家去抢红包结果活动刚开始2分钟系统就挂了”。其实要想让系统支持海量并发很重要的基础就是数据库的并发处理能力而这里面最重要的就是对写写冲突的并发控制。因为并发控制如此重要所以很多经典教材都会花费大量篇幅来探讨这个问题进而系统性地介绍并发控制技术。在这个技术体系中虽然有多种不同的划分方式但最为大家熟知就是悲观协议和乐观协议两大类。
TiDB和CockroachDB的流行一度让大家对于乐观协议这个概念印象深刻。但是经过几年的实践两款产品都将默认的并发控制机制改回了悲观协议。他们为什么做了这个改变呢我们这一讲专门来探讨什么是乐观控制协议以及为什么TiDB和CockroachDB不再把它作为默认选项。
## 并发控制技术的分类
首先,无论是学术界还是工业界,都倾向于将并发控制分为是悲观协议和乐观协议两大类。但是,这个界限在哪,其实各有各的解释。
我先给一个朴素版的定义。所谓悲观与乐观,它和我们自然语言的含义大致是一样的,悲观就是对未来的一种负面预测,具体来说,就是认为会出现比较多的事务竞争,不容易获得充足的资源完成事务操作。而乐观,则完全相反,认为不会有太多的事务竞争,所以资源是足够的。这个定义虽然不那么精准,但大体表示了两种机制的倾向。
再进一步,落到实现机制上,有一个广泛被提到的定义版本。乐观协议就是直接提交,遇到冲突就回滚;悲观协议就是在真正提交事务前,先尝试对需要修改的资源上锁,只有在确保事务一定能够执行成功后,才开始提交。
总之,这个版本的核心就是,悲观协议是使用锁的,而乐观协议是不使用锁的。这就非常容易把握了。
但是这个解释和真正分布式数据库产品的实现还是有些差距的比如TiDB就宣称自己使用了乐观锁。对你没听错是乐观锁。怎么有锁还能乐观呢是不是有点蒙了
为了让你在学习过程中不背负着这个巨大的问号我们就先放下对定义的探讨先来看看TiDB乐观锁的实现方式。
### 乐观锁TiDB
TiDB的乐观锁基本上就是Percolator模型这个模型我们在[第9讲](https://time.geekbang.org/column/article/278949)时曾经介绍过,它的运行过程可以分为三个阶段。
1. **选择Primary Row**
收集所有参与修改的行从中随机选择一行作为这个事务的Primary Row这一行是拥有锁的称为Primary Lock而且这个锁会负责标记整个事务的完成状态。所有其他修改行也有锁称为Secondary Lock都会保留指向Primary Row的指针。
1. **写入阶段**
按照两阶段提交的顺序执行第一阶段。每个修改行都会执行上锁并执行“prewrite”prewrite就是将数据写入私有版本其他事务不可见。注意这时候每个修改行都可能碰到锁冲突的情况如果冲突了就终止事务返回给TiDB那么整个事务也就终止了。如果所有修改行都顺利上锁完成prewrite第一阶段结束。
1. **提交阶段**
这是两阶段提交的第二阶段,提交 Primary Row也就是写入新版本的提交记录并清除 Primary Lock如果顺利完成那么这个事务整体也就完成了反之就是失败。而Secondary Rows上的锁则会交给异步线程根据Primary Lock的状态去清理。
<img src="https://static001.geekbang.org/resource/image/2f/e8/2fc7c4959ae0b4a223c23303a57c92e8.jpg" alt="">
你看这个过程中不仅有锁,而且锁的数量还不少。那么,为什么又说它是乐观协议呢?
想回答这个问题,我们就需要一些理论知识了。
### 并发控制的三个阶段
在经典理论教材“[Principles of Distributed Database Systems](https://link.springer.com/content/pdf/bfm%3A978-1-4419-8834-8%2F1.pdf)”中作者将乐观协议和悲观协议的操作都统一成四个阶段分别是有效性验证V、读R、计算C和写W。两者的区别就是这四个阶段的顺序不同悲观协议的操作顺序是VRCW而乐观协议的操作顺序则是RCVW。因为在比较两种协议时计算C这个阶段没有实质影响可以忽略掉。那么简化后悲观协议的顺序是VRW而乐观协议的顺序就是RVW。
RVW的三阶段划分也见于研究乐观协议的经典论文“[On Optimistic Methods for Concurrency Control](https://www.cs.du.edu/~leut/4423/papers/kung.pdf)”。
<img src="https://static001.geekbang.org/resource/image/a2/eb/a2f4a6f1e7d21c0a206dc74308a2d6eb.jpg" alt="">
关于三个阶段的定义在不同文献中稍有区别其中“Principles of Distributed Database Systems”对这三个阶段的定义通用性更强对于RVW和VRW都是有效的我们先看下具体内容。
**读阶段Read Pharse**,每个事务对数据项的局部拷贝进行更新。
要注意,此时的更新结果对于其他事务是不可见的。这个阶段的命名特别容易让人误解,明明做了写操作,却叫做“读阶段”。我想它大概是讲,那些后面要写入的内容,先要暂时加载到一个仅自己可见的临时空间内。这有点像我们抄录的过程,先读取原文并在脑子里记住,然后誊写出来。
**有效性确认阶段Validation Pharse**,验证准备提交的事务。
这个验证就是指检查这些更新是否可以保证数据库的一致性,如果检查通过进入下一个阶段,否则取消事务。再深入一点,这段话有两层意思。首先这里提到的检查与隔离性目标有直接联系;其次就是检查可以有不同的手段,也就是不同的并发控制技术,比如可以是基于锁的检查,也可以是基于时间戳排序。
**写阶段Write Pharse**,将读阶段的更新结果写入到数据库中,接受事务的提交结果。
这个阶段的工作就比较容易理解了,就是完成最终的事务提交操作。
还有一种关于乐观与悲观的表述,也与三阶段的顺序相呼应。乐观,重在事后检测,在事务提交时检查是否满足隔离级别,如果满足则提交,否则回滚并自动重新执行。悲观,重在事前预防,在事务执行时检查是否满足隔离级别,如果满足则继续执行,否则等待或回滚。
我们再回到TiDB的乐观锁。虽然对于每一个修改行来说TiDB都做了有效性验证而且顺序是VRW可以说是悲观的但这只是局部的有效性验证从整体看TiDB没有做全局有效性验证不符合VRW顺序所以还是相对乐观的。
下面这一段,我们稍微延伸一下有关乐观并发控制的知识。
### 狭义乐观并发控制OCC
“[Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery](http://www.gbv.de/dms/weimar/toc/647210940_toc.pdf)”给出了一个专用于RVW的三阶段定义也就是说它是专门描述乐观协议的。其中主要差别在“有效性确认阶段”是针对可串行化的检查检查采用基于时间戳的特定算法。
这个定义是一个更加具体的乐观协议严格符合RVW顺序所以我把它称为狭义上的乐观并发控制Optimistic Concurrency Control也称为基于有效性确认的并发控制Validation-Based Concurrency Control。很多学术论文中的OCC就是指它。而在工业界真正生产级的分布式数据库还很少使用狭义OCC进行并发控制唯一的例外就是FoundationDB。与之相对应的则是TiDB这种广义上的乐观并发控制说它乐观是因为它没有严格遵循VRW顺序。
## 乐观协议的挑战
TiDB的乐观锁已经讲清楚了我们再回到这一讲的主线为啥乐观要改成悲观呢主要是两方面的挑战一是事务冲突少是使用乐观协议的前提但这个前提是否普遍成立二是现有应用系统使用的单体数据库多是悲观协议兼容性上的挑战。
### 事务频繁冲突
首先,事务冲突少这个前提,随着分布式数据库的适用场景越来越广泛,显得不那么有通用性了。比如,金融行业就经常会有一些事务冲突多又要保证严格事务性的业务场景,一个简单的例子就是银行的代发工资。代发工资这个过程,其实就是从企业账户给一大批个人账户转账的过程,是一个批量操作。在这个大的转账事务中可能涉及到成千上万的更新,那么事务持续的时间就会比较长。如果使用乐观协议,在这段时间内,只要有一个人的账户余额发生变化,事务就要回滚,那么这个事务很可能一直都在重试、回滚,永远也执行不完。这个时候,我们就一点也不要乐观了,像传统单体数据库那样,使用最悲观的锁机制,就很容易实现也很高效。
当然为了避免这种情况的出现TiDB的乐观锁约定了事务的长度默认单个事务包含的 SQL 语句不超过 5000 条。但这种限制其实是一个消极的处理方式,毕竟业务需求是真实存在的,如果数据库不支持,就必须通过应用层编码去解决了。
### 遗留应用的兼容性需求
回到悲观协议还有一个重要的原因那就是保证对遗留应用系统的兼容性。这个很容易理解因为单体数据库都是悲观协议甚至多数都是基于锁的悲观协议所以在SQL运行效果上与乐观协议有直接的区别。一个非常典型的例子就是select for update。这是一个显式的加锁操作或者说是显式的方式进行有效性确认广义的乐观协议都不提供严格的RVW所以也就无法支持这个操作。
select for update是不是一个必须的操作呢其实也不是的这个语句出现是因为数据库不能支持可串行化隔离给应用提供了一个控制手段主导权交给了应用。但是这就是单体数据库长久以来的规则已经是生态的一部分为了降低应用的改造量新产品还是必须接受。
## 乐观协议的改变
因为上面这些挑战TiDB的并发控制机制也做出了改变增加了“悲观锁”并作为默认选项。TiDB悲观锁的理论基础很简单就是在原有的局部有效性确认前增加一轮全局有效性确认。这样就是严格的VRW自然就是标准的悲观协议了。具体采用的方式就是增加了悲观锁这个锁是实际存在的表现为一个占位符随着SQL的执行即时向存储系统TiKV发出这样事务就可以在第一时间发现是否有其他事务与自己冲突。
<img src="https://static001.geekbang.org/resource/image/81/3c/8105877712a140096aac9cc1d122a43c.jpg" alt="">
另外悲观锁还触发了一个变化。TiDB原有的事务模型并不是一个交互事务它会把所有的写SQL都攒在一起在commit阶段一起提交所以有很大的并行度锁的时间较短死锁的概率也就较低。因为增加了悲观锁的加锁动作变回了一个可交互事务TiDB还要增加一个死锁检测机制。
## 小结
到这里,今天的内容就告一段落了,让我们一起梳理下今天内容。
1. 并发控制分为乐观和悲观两种,它们的语义和自然语言都是对未来正面或负面的预期。乐观协议预期事务冲突很少,所以不会提前做什么,悲观协议认为事务冲突比较多,所以要有所准备。
1. 按照经典理论并发控制都是由三个阶段组成分别是有效性确认V、读R和写W。悲观协议的执行顺序是VRW乐观协议的执行顺序是RVW。所以又可以得到两个乐观协议的定义狭义上必须满足RVW才是乐观并发控制而且三阶段有更具体的要求这个就是学术论文上的OCC。另外广义的定义只要不是严格VRW的并发控制都是相对乐观的都是乐观并发控制TiDB就是相对乐观。生产级的分布式数据库很少有使用狭义的OCC协议FoundationDB是一个例外。
1. 乐观协议的挑战来自两个方面。第一点乐观协议在事务冲突较少时因为避免了锁的管理开销能提供更好的性能但在事务冲突较多时会出现大量的回滚效率低下。总的来说乐观协议的通用性并不好。第二点传统单体数据库几乎都是基于锁的悲观协议乐观协议在语义层面没有对等的SQL例如select for update。因此TiDB和CockroachDB都由乐观协议转变为悲观协议并且是基于锁的悲观协议。
1. TiDB的悲观协议符合严格的VRW顺序在原有的两阶段提交前增加了一轮悲观锁占位操作实现全局有效性确认。但是随着悲观锁的引入TiDB转变为一个可交互事务模式出现死锁的概率大幅提升对应增加死锁检测功能。
今天的课程中我们澄清了对乐观协议的常见误解也解释了为什么TiDB要把默认选项从乐观锁改为悲观锁。当然你一定注意到了除了基于锁的悲观协议外还有一些其他技术比如基于时间戳排序TO和串行化图检测SGT我们将在下一讲中继续探讨这个话题。
<img src="https://static001.geekbang.org/resource/image/79/39/7950a13569109c0cb811868d5a5b5739.jpg" alt="">
## 思考题
课程的最后,我们来看看今天的思考题。
在这一讲开头我们说TiDB和CockroachDB都经历了从乐观协议到悲观协议的转变但在文中只展开了TiDB变化前后的设计细节。其实对于CockroachDB你应该也不陌生了我们在最近几讲中都有提及到它的一些特性。所以我今天的问题是请你推测下CockraochDB的悲观协议大概会采用什么方式而这种方式又有什么优势当然CockroachDB的实现方式很容易查到所以我更关心你的思考的过程。
欢迎你在评论区留言和我一起讨论,我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对乐观协议或者并发控制技术这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。
## 学习资料
Gerhard Weikum and Gottfried Vossen: [**Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery**](http://www.gbv.de/dms/weimar/toc/647210940_toc.pdf)
H. T. Kung and John T. Robinson: [**On Optimistic Methods for Concurrency Control**](https://www.cs.du.edu/~leut/4423/papers/kung.pdf)
M. Tamer Özsu and Patrick Valduriez: [**Principles of Distributed Database Systems**](https://link.springer.com/content/pdf/bfm%3A978-1-4419-8834-8%2F1.pdf)

View File

@@ -0,0 +1,164 @@
<audio id="audio" title="14 | 隔离性:实现悲观协议,除了锁还有别的办法吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f0/08/f0b57e197e62934988cbb7c62f0c7a08.mp3"></audio>
你好我是王磊你也可以叫我Ivan。
我们今天的主题是悲观协议,我会结合[第13讲](https://time.geekbang.org/column/article/282401)的内容将并发控制技术和你说清楚。在第13讲我们以并发控制的三阶段作为工具区分了广义上的乐观协议和悲观协议。因为狭义乐观很少使用所以我们将重点放在了相对乐观上。
其实,相对乐观和局部悲观是一体两面的关系,识别它的要点就在于是否有全局有效性验证,这也和分布式数据库的架构特点息息相关。但是关于悲观协议,还有很多内容没有提及,下面我们就来填补这一大块空白。
## 悲观协议的分类
要搞清楚悲观协议的分类,其实是要先跳出来,从并发控制技术整体的分类体系来看。
事实上并发控制的分类体系连学术界的标准也不统一。比如在第13讲提到的两本经典教材中“[Principles of Distributed Database Systems](https://link.springer.com/content/pdf/bfm%3A978-1-4419-8834-8%2F1.pdf)”的分类是按照比较宽泛的乐观协议和悲观协议进行分类,子类之间又有很多重叠的概念,理解起来有点复杂。
而“[Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery](http://www.gbv.de/dms/weimar/toc/647210940_toc.pdf)”采用的划分方式是狭义乐观协议和其他悲观协议。这里狭义乐观协议就是指我们在第13讲提到过的基于有效性验证的并发控制也是学术上定义的OCC。
我个人认为,狭义乐观协议和其他悲观协议这种分类方式更清晰些,所以就选择了“ Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery”中的划分体系。下面我摘录了书中的一幅图用来梳理不同的并发控制协议。
<img src="https://static001.geekbang.org/resource/image/ed/a1/ede7edbb88108b76d015fa69d14425a1.png" alt="">
这个体系首先分为悲观和乐观两个大类。因为这里的乐观协议是指狭义乐观并发控制,所以包含内容就比较少,只有前向乐观并发控制和后向乐观并发控制;而悲观协议又分为基于锁和非锁两大类,其中基于锁的协议是数量最多的。
## 两阶段封锁Two-Phase Locking2PL
基于锁的协议显然不只是2PL还包括有序共享Ordered Sharing 2PL, O2PL、利他锁Altruistic Locking, AL、只写封锁树Write-only Tree Locking, WTL和读写封锁树Read/Write Tree Locking RWTL。但这几种协议在真正的数据库系统中很少使用所以就不过多介绍了我们还是把重点放在数据库系统主要使用的2PL上。
2PL就是事务具备两阶段特点的并发控制协议这里的两个阶段指加锁阶段和释放锁阶段并且加锁阶段严格区别于紧接着的释放锁阶段。我们可以通过一张图来加深对2PL理解。
<img src="https://static001.geekbang.org/resource/image/54/75/540f30e8c3877b4951711e3c7305df75.png" alt="">
在t1时刻之前是加锁阶段在t1之后则是释放锁阶段我们可以从时间上明确地把事务执行过程划分为两个阶段。2PL的关键点就是释放锁之后不能再加锁。而根据加锁和释放锁时机的不同2PL又有一些变体。
**保守两阶段封锁协议**Conservative 2PLC2PL事务在开始时设置它需要的所有锁。
<img src="https://static001.geekbang.org/resource/image/66/81/6680049eacdd2bab61a65eyy0e36a881.png" alt="">
**严格两阶段封锁协议**Strict 2PLS2PL事务一直持有已经获得的所有写锁直到事务终止。
<img src="https://static001.geekbang.org/resource/image/b7/ec/b7291c52385b627282f5c4f010acacec.png" alt="">
**强两阶段封锁协议**Strong Strict 2PLSS2PL事务一直持有已经获得的所有锁包括写锁和读锁直到事务终止。SS2PL与S2PL差别只在于一直持有的锁的类型所以它们的图形是相同的。
理解了这几种2PL的变体后我们再回想一下[第13讲](https://time.geekbang.org/column/article/282401)中的Percolator模型。当主锁Primary Lock没有释放前所有的记录上的从锁Secondary Lock实质上都没有释放在主锁释放后所有从锁自然释放。所以Percolator也属于S2PL。TiDB的乐观锁机制是基于Percolator的那么TiDB就也是S2PL。
事实上S2PL可能是使用最广泛的悲观协议几乎所有单体数据都依赖S2PL实现可串行化。而在分布式数据库中甚至需要使用SS2PL来保证可串行化执行典型的例子是TDSQL。但S2PL模式下事务持有锁的时间过长导致系统并发性能较差所以实际使用中往往不会配置到可串行化级别。这就意味着我们还是没有生产级技术方案只能期望出现新的方式既达到可串行化隔离级别又能有更好的性能。最终我们等到了一种可能是性能更优的工程化实现这就是CockroachDB的串行化快照隔离SSI。而SSI的核心就是串行化图检测SGT
## 串行化图检测SGT
SSI是一种隔离级别的命名最早来自PostgreSQLCockroachDB沿用了这个名称。它是在SI基础上实现的可串行化隔离。同样作为SSI核心的SGT也不是CockroachDB首创学术界早就提出了这个理论但真正的工程化实现要晚得多。
### 理论来源PostgreSQL
PostgreSQL在论文“[Serializable Snapshot Isolation in PostgreSQL](http://vldb.org/pvldb/vol5/p1850_danrkports_vldb2012.pdf)”中最早提出了SSI的工程实现方案这篇论文也被VLDB2012收录。
为了更清楚地描述SSI方案我们先要了解一点理论知识。
串行化理论的核心是串行化图Serializable GraphSG。这个图用来分析数据库事务操作的冲突情况。每个事务是一个节点事务之间的关系则表示为一条有向边。那么什么样的关系可以表示为边呢
串行化图的构建规则是这样的,事务作为节点,当一个操作与另一个操作冲突时,在两个事务节点之间就可以画上一条有向边。
具体来说,事务之间的边又分为三类情况:
1. 写读依赖WR-Dependencies第二个操作读取了第一个操作写入的值。
1. 写写依赖WW-Dependencies第二个操作覆盖了第一个操作写入的值。
1. 读写反依赖RW-Antidependencies第二个操作覆盖了第一个操作读取的值可能导致读取值过期。
我们通过一个例子,看看如何用这几条规则来构建一个简单的串行化图。
<img src="https://static001.geekbang.org/resource/image/0d/73/0ddedf5e27966fac0388f36ddde7f473.png" alt="">
图中一共有三个事务先后执行事务T1先执行W(A)T2再执行R(A)所以T1与T2之间存在WR依赖因此形成一条T1指向T2的边同理T2的W(B)与T3的R(B)也存在WR依赖T1的W(A)与T3的R(A)之间也是WR依赖这样就又形成两条有向边分别是T2指向T3和T1指向T3。
<img src="https://static001.geekbang.org/resource/image/7e/f5/7e66dc945b0ed425781f1e922ed338f5.png" alt="">
最终我们看到产生了一个有向无环图Directed Acyclic GraphDAG。能够构建出DAG就说明相关事务是可串行化执行的不需要中断任何事务。
我们可以使用SGT验证一下典型的死锁情况。我们知道事务T1和T2分别以不同的顺序写两个数据项那么就会形成死锁。
<img src="https://static001.geekbang.org/resource/image/a2/7f/a2042eef98d2a84556fcbfb1814b517f.png" alt="">
用串行化图来体现就是这个样子,显然构成了环。
<img src="https://static001.geekbang.org/resource/image/88/ef/88d4955b226a9ee089cd8d361d86d0ef.png" alt="">
在SGT中WR依赖和WW依赖都与我们的直觉相符而RW反向依赖就比较难理解了。在PostgreSQL的论文中专门描述了一个RW反向依赖的场景这里我把它引用过来我们一起学习一下。
这个场景一共需要维护两张表一张收入表reciepts会记入当日的收入情况每行都会记录一个批次号另一张独立的控制表current_batch里面只有一条记录就是当前的批次号。你也可以把这里的批次号理解为一个工作日。
同时还有三个事务T1、T2、T3。
- T2是记录新的收入NEW-RECEIPT从控制表中读取当前的批次号然后在收入表中插入一条新的记录。
- T3负责关闭当前批次CLOSE-BATCH而具体实现是通过将控制表中的批次号递增的方式这就意味着后续再发生的收入会划归到下一个批次。
- T1是报告REPORT读取当前控制表的批次号处理逻辑是用当前已经加一的批次号再减一。T1用这个批次号作为条件读取收据表中的所有记录。查询到这个批次也就是这一日所有的交易。
其实,这个例子很像银行存款系统的日终翻牌。
因为T1要报告当天的收入情况所以它必须要在T3之后执行。事务T2记录了当天的每笔入账必须在T3之前执行这样才能出现在当天的报表中。三者顺序执行可以正常工作否则就会出现异常比如下面这样的
<img src="https://static001.geekbang.org/resource/image/b2/01/b2223b52a92e76f5dfe338e366238501.png" alt="">
T2先拿到一个批次号x随后T3执行批次号关闭后x这个批次号其实已经过期但是T2还继续使用x记录当前的这笔收入。T1正常在T3后执行此时T2尚未提交所以T1的报告中漏掉了T2的那笔收入。因为T2使用时过期的批次号x第二天的报告中也不会统计到这笔收入最终这笔收入就神奇地消失了。
在理解了这个例子的异常现象后我们用串行化图方法来验证一下。我们是把事务中的SQL抽象为对数据项的操作可以得到下面这张图。
<img src="https://static001.geekbang.org/resource/image/88/7a/884e0f7b80fc32b9b8491ee76f8f127a.png" alt="">
图中batch是指批次号reps是指收入情况。
接下来我们按照先后顺序提取有向边先由T2.R(batch) -&gt; T3.W(batch)得到T2到T3的RW依赖再由T3.W(batch)-&gt;T1.R(batch),得到 T3到T1的WR依赖最后由T1.R(reps)-&gt;T2.W(reps)得到T1到T2的RW依赖。这样就构成了下面的串行化图。
<img src="https://static001.geekbang.org/resource/image/bf/e8/bf6019d3e3953a4075b001f4853963e8.png" alt="">
显然这三个事务之间是存在环的,那么这三个事务就是不能串行化的。
这个异常现象中很有意思的一点是虽然T1是一个只读事务但如果没有T1的话T2与T3不会形成环依然是可串行化执行的。这里就为我们澄清了一点我们直觉上认为的只读事务不会影响事务并发机制其实是不对的。
### 工程实现CockroachDB
RW反向依赖是一个非常特别的存在而特别之处就在于传统的锁机制无法记录这种情况。因此在论文“[Serializable Snapshot Isolation in PostgreSQL](http://vldb.org/pvldb/vol5/p1850_danrkports_vldb2012.pdf)”中提出增加一种锁SIREAD用来记录快照隔离SI上所有执行过的读操作Read从而识别RW反向依赖。本质上SIREAD并不是锁只是一种标识。但这个方案面临的困境是读操作涉及到的数据范围实在太大跟踪标识带来的成本可能比S2PL还要高也就无法达到最初的目标。
针对这个问题CockroachDB做了一个关键设计**读时间戳缓存**Read Timestamp Cache简称RTC。
基于RTC的新方案是这样的当执行任何的读取操作时操作的时间戳都会被记录在所访问节点的本地RTC中。当任何写操作访问这个节点时都会以将要访问的Key为输入向RTC查询最大的读时间戳MRT如果MRT大于这个写入操作的时间戳那继续写入就会形成RW依赖。这时就必须终止并重启写入事务让写入事务拿到一个更大的时间戳重新尝试。
具体来说RTC是以Key的范围来组织读时间戳的。这样当读取操作携带了谓词条件比如where子句对应的操作就是一个范围读取会覆盖若干个Key那么整个Key的范围也可以被记录在RTC中。这样处理的好处是可以兼容一种特殊情况。
例如事务T1第一次范围读取Range Scan数据表where条件是“&gt;=1 and &lt;=5”读取到1、2、5三个值T1完成后事务T2在该表插入了4因为RTC记录的是范围区间[1,5]所以4也可以被检测出存在RW依赖。这个地方有点像MySQL间隙锁的原理。
RTC是一个大小有限的采用LRULeast Recently Used最近最少使用淘汰算法的缓存。当达到存储上限时最老的时间戳会被抛弃。为了应对缓存超限的情况会将RTC中出现过的所有Key上最早的那个读时间戳记录下来作为低水位线Low Water Mark。如果一个写操作将要写的Key不在RTC中则会返回这个低水位线。
## 相对乐观
到这里你应该大概理解了SGT的运行机制它和传统的S2PL一样属于悲观协议。但SGT没有锁的管理成本所以性能比S2PL更好。
CockroachDB基于SGT理论进行工程化使可串行化真正成为生产级可用的隔离级别。从整体并发控制机制看CockroachDB和上一讲的TiDB一样虽然在局部看是悲观协议但因为不符合严格的VRW顺序所以在全局来看仍是一个相对乐观的协议。
这种乐观协议同样存在[第13讲](https://time.geekbang.org/column/article/282401)提到的问题所以CockroachDB也在原有基础上进行了改良通过增加全局的锁表Lock Table使用加锁的方式先进行一轮全局有效性验证确定无冲突的情况下再使用单个节点的SGT。
## 小结
有关悲观协议的内容就聊到这里了,我们一起梳理下今天课程的重点。
1. 并发控制机制的划分方法很多,没有统一标准,我们使用了[Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery](http://www.gbv.de/dms/weimar/toc/647210940_toc.pdf)提出的划分标准分为悲观协议与乐观协议两种。这里的乐观协议是上一讲提到的狭义乐观协议悲观协议又分为锁和非锁两大类我们简单介绍了2PL这一个分支。
1. 我们回顾了Percolator模型按照S2PL的定义Percoloatro本质就是S2PL因此TiDB的乐观锁也属于S2PL。
1. S2PL是数据库并发控制的主流技术但是锁管理复杂在实现串行化隔离级别时开销太大。而后我们讨论了非锁协议中的串行化图检测SGT。PostgreSQL最早提出了SGT的工程实现方式SSI。CockroachDB在此基础上又进行了优化降低了SIREAD的开销是生产级的可串行化隔离。
1. CockroachDB最初和TiDB一样都是局部采用悲观协议而不做全局有效性验证是广义的乐观协议。后来CockroachDB同样也将乐观协议改为悲观协议采用的方式是增加全局的锁表进行全局有效性验证而后再转入单个的SGT处理。
今天的课程中我们提到了串行化理论只有当相关事务形成DAG图时这些事务才是可串行化的。这个理论不仅适用于SGT2PL的最终调度结果也同样是DAG图。在更大范围内批量任务调度时DAG也同样被作为衡量标准例如Spark。
<img src="https://static001.geekbang.org/resource/image/a2/98/a2de442d44b6fd69c16e83a509c0a698.png" alt="">
## 思考题
课程的最后,我们来看看今天的思考题。
在[第11讲](https://time.geekbang.org/column/article/280925)中我们提到了MVCC。有的数据库教材中将MVCC作为一种重要的并发控制技术与乐观协议、悲观协议并列但我们今天并没有单独提到它。所以我的问题是你觉得该如何理解MVCC与乐观协议、悲观协议的关系呢
欢迎你在评论区留言和我一起讨论,我会在答疑篇回复这个问题。如果你身边的朋友也对悲观协议或者并发控制技术这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。
## 学习资料
最早的SSI工程实现方案[Serializable Snapshot Isolation in PostgreSQL](http://vldb.org/pvldb/vol5/p1850_danrkports_vldb2012.pdf)
按照狭义乐观协议和其他悲观协议划分并发控制协议:[Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery](http://www.gbv.de/dms/weimar/toc/647210940_toc.pdf)

View File

@@ -0,0 +1,146 @@
<audio id="audio" title="15 | 分布式事务串讲:重难点回顾+思考题答疑+知识全景图" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/85/3c/8596e3b1ec4a2da92ce1a715fda8fc3c.mp3"></audio>
你好我是王磊你也可以叫我Ivan。
今天这一讲是我们这门课的第二个答疑篇我会带你回顾第9讲到第14讲的主要内容集中解答留给你的思考题同时回复一些留言区的热点问题。这几讲涉及的也是数据库的核心话题“事务”咱们一定得学扎实了。
## 第9讲原子性提交协议
在[第9讲](https://time.geekbang.org/column/article/278949)我们首先讨论了两种不同的原子性协议分别是面向应用层的协议TCC和面向资源层的协议2PC。
使用TCC协议时由应用系统负责协议的实现数据库没有额外工作。所以TCC更加灵活但对业务的侵入性更高。反之使用2PC协议时主要靠数据库来实现应用层工作很少所以业务侵入少但是存在同步阻塞、单点故障和数据不一致这三个问题。
针对2PC协议的这些问题我又介绍了两种改进型协议。一种是Percolator模型在TiDB和CockroachDB中有具体落地。另一种是GoldenDB中实现的“一阶段提交”协议。它们都较好地解决了单点故障和数据不一致的问题。而有关同步阻塞带来的高延迟问题我们没有展开而是留到了[第10讲](https://time.geekbang.org/column/article/279660)。
这一讲的是思考题是2PC第一阶段“准备阶段”也被称为“投票阶段”和Paxos协议处理阶段的命名相近你觉得2PC和Paxos协议有没有关系如果有又是什么关系呢
“Chenchukun”“tt”“李鑫磊”“piboye”和“Bryant.C”等几位同学的留言都很好地回答了这个问题。总的来说就是Paxos是对单值或者一种状态达成共识的过程而2PC是对多个不同数据项的变更或者多个状态达成一致的过程。它们是有区别的Paxos不能替换2PC但它们也是有某种联系的。那到底是什么联系呢
别着急我们先看看“Lost Horizon”和“平风造雨”两位同学的提问。
“Lost Horizon”在第9讲提出的问题是如果向单元 A 发出 Confirm 操作成功且收到成功应答,向单元 B 发出 Confirm 操作成功,但没有收到成功应答,是否应该先确认 B 的状态然后再决定是否需要回滚或者补偿A 的变更呢?
“平风造雨”在[第10讲](https://time.geekbang.org/column/article/279660)提出的问题是:如果客户端没有在一定的时间内得到所有意向写的反馈(不知道反馈是成功还是失败),要如何处理?
这两位同学的问题虽然不一样但有一个共同点,就是**事务协调者收不到事务参与者的反馈怎么办**。
为什么会收不到呢多数情况是和网络故障有关。那么Paxos协议这个针对网络分区而设计的共识算法能不能帮助解决这个问题呢
Paxos确实有帮助但真正能解决问题的是在其之上衍生的Paxos Commit协议这是一个融合了Paxos算法的原子提交协议也是我们前面所说的2PC和Paxos的联系所在。
### Paxos Commit协议
Paxos Commit协议是2006年在论文“[Consensus on Transaction Commit](https://dsf.berkeley.edu/cs286/papers/paxoscommit-tods2006.pdf)”中首次提出的。值得一提的是这篇论文的两位作者正是我们课程中多次提到的Jim Gray和Leslie Lamport。他们分别是数据库和分布式领域的图灵奖获得者也分别是2PC和Paxos的提出者。
我们结合论文中的配图简单学习一下Paxos Commit的思路。
<img src="https://static001.geekbang.org/resource/image/3e/b5/3eaef70a0ab06ed9e792a9ef73de0bb5.png" alt="">
Paxos Commit协议中有四个角色有两个与2PC对应分别是TMTransaction Manager事务管理者也就是事务协调者RMResource Manager资源管理者也就是事务参与者另外两个角色与Paxos对应一个是Leader一个是Acceptor。其中TM和Leader在逻辑上是不可能分的所以在图中隐去了。因为Leader是选举出来的所以第一个Leader标识为Initial Leader。
下面,我们来描述下这个处理过程。
1. 首先由RM1就是某个RM向Leader发送Begin Commit指令。这个操作和第9讲介绍的2PC稍有不同但和客户端向Leader发送指令在效果上是大致相同的。同时RM1要向所有Acceptor发送Prepared指令。因为把事务触发也算进去了所以整个协议有三个阶段构成Prepare是其中的第二阶段而RM对Prepare指令的响应过程又拆分成了a和b两个子阶段。所以这里的Prepared指令用2a Prepared表示要注意这是一个完成时表示已经准备完毕。
1. Leader向除RM1外的所有RM发送Prepare指令。RM执行指令后向所有Acceptor发送消息2a Prepared。这里的关键点是一个2a Prepared消息里只包含一个RM的执行情况。而每个Acceptor都会收到所有RM发送的消息从而得到全局RM的执行情况。
1. 每个Acceptor向Leader汇报自己掌握的全局状态载体是消息2b Prepared。2b Prepared是对2a Prepared的合并每个消息都记录了所有RM的执行情况。最后Leader基于多数派得出了最终的全局状态。这一点和2PC完全不同事务的状态完全由投票决定Leader也就是事务协调者是没有独立判断逻辑的。
1. Leader基于已知的全局状态向所有RM发送Commit指令。
这个过程中如果Acceptor总数是2F+1那么每个RM就有2F+1条路径与Leader通讯。只要保证其中F+1条路径是畅通的整个协议就可以正常运行。因此Paxos Commit协议的优势之一就是在很大程度上避免了网络故障对系统的影响。但是相比于2PC来说它的消息数量大幅增加而且多了一次消息延迟。目前实际产品中还很少有使用Paxos Commit协议的。
## 第10讲2PC的延迟优化
[第10讲](https://time.geekbang.org/column/article/279660)的核心内容是2PC的低延迟技术我们先是分析了延迟的主要构成发现延迟时间与事务中的写入操作数量线性相关然后又将延迟时间的计量单位统一为共识算法延迟$L_{c}$,最后得到了下面的延迟计算公式:
$$L_{txn} = (W + 1) * L_{c}$$
随后我为你讲解了三种优化技术都是基于Percolator模型的分别是缓存提交写、管道和并行提交。TiDB采用“缓存提交写”达到了2倍共识算法延迟但这个方案的缺点是缓存SQL的节点会出现瓶颈而且不再是交互事务。CockroachDB采用了管道和并行提交技术整体延迟缩短到了1倍共识算法延迟可能是目前最极致的优化方法了。
这一讲的是思考题是虽然CockroachDB的优化已经比较极致了但还有些优化方法也很有趣请你介绍下自己了解的2PC优化方法。
关于这个问题我们刚刚讲的Paxos Commit其实已经是一种2PC的优化方法了。另外在Spanner论文“[Spanner: Googles Globally-Distributed Database](https://www.cs.princeton.edu/courses/archive/fall13/cos518/papers/spanner.pdf)”中也介绍了它的2PC优化方式。Spanner的2PC优化特点在于由客户端负责第一段协调发送prepare指令减少了节点间的通讯。具体的内容你可以参考下这篇论文。
在留言区,我看到很多同学对并行提交有不同的理解。我要再提示一下,并行提交中的**异步写事务日志只是根据每个数据项的写入情况,追溯出事务的状态,然后落盘保存,整个过程并没有任何重试或者回滚的操作**。这是因为,在之前的同步操作过程中,负责管道写入的同步线程,已经明确知道了每个数据项的写入情况,也就是确定了事务的状态,不同步落盘只是为了避免由此带来的共识算法延迟。
## 第11讲读写冲突、MVCC与快照
在[第11讲](https://time.geekbang.org/column/article/280925)中我们介绍如何避免读写冲突的解决方案其中很重要的概念就是MVCC和快照。MVCC是单体数据库普遍使用的一种技术通过记录数据项历史版本的方式提升系统应对多事务访问的并发处理能力。
在MVCC出现前读写操作是相互阻塞的并行能力受到很大影响。而使用MVCC可以实现读写无阻塞并能够达到RC读已提交隔离级别。基于MVCC还可以构建快照使用快照则能够更容易地实现 RR可重复读和SI快照隔离两个隔离级别。
首先我们学习了PGXC风格分布式数据库的读写冲突处理方案。PGXC因为使用单体数据库作为数据节点所以沿用了MVCC来实现RC。但如果要实现RR级别则需要全局事务管理器GTM承担产生事务ID和记录事务状态的职责。
然后我们介绍了TiDB和CockroachDB这两种NewSQL风格分布式数据库的读写冲突处理方案。TiDB没有设置全局事务列表所以读写是相互阻塞的。CockroachDB虽然有全局事务列表但由于它的目标隔离级别是可串行化所以也没有采用快照方式读写也是相互阻塞的。
这一讲的思考题是:在介绍的几种读写冲突的处理方案中,时间都是非常重要的因素,但时间是有误差的,那么你觉得时间误差会影响读写冲突的处理吗?
其实这个问题就是引导你思考以便更好地理解第12讲的内容。MVCC机制是用时间戳作为重要依据来判别哪个数据版本是可读取的。但是如果这个时间戳本身有误差就需要特定的机制来管理这个误差从而读取到正确的数据版本。更详细的内容你可以去学习下[第12讲](https://time.geekbang.org/column/article/281671)。
“真名不叫黄金”同学的答案非常准确,抓住了时钟置信区间这个关键点,分析思路也很清晰,点赞。
## 第12讲读写操作与时间误差
在[第12讲](https://time.geekbang.org/column/article/281671)中,我们给出了时间误差的具体控制手段,也就是写等待和读等待。
Spanner采用了写等待方案也就是Commit Wait理论上每个写事务都要等待一个时间置信区间。对Spanner来说这个区间最大是7毫秒均值是4毫秒。但是由于Spanner的2PC设计需要再增加一个时间置信区间来确保提交时间戳晚于预备时间戳。所以实际上Spanner的写等待时间就是两倍时间置信区间均值达到了8毫秒。传说中Spanner的TPS是125就是用这个均值计算的1秒/8毫秒但如果事务之间操作的数据不重叠其实是不受这个限制的。
CockroachDB采用了读等待方式就是在所有的读操作执行前处理时间置信区间。读等待的优点是偶发只有读操作落入写操作的置信区间才需要重启进行等待。但是重启后的读操作可能继续落入其他写操作的置信区间引发多次重启。所以读等待的缺点是等待时间可能比较长。
这一讲的思考题是:读等待和写等待都是通过等待的方式,度过不确定的时间误差,从而给出确定性的读写顺序,但性能会明显下降。那么在什么情况下,不用“等待”也能达到线性一致性或因果一致性呢?”
我为这个问题准备了两个答案。
第一个答案是要复习[第5讲](https://time.geekbang.org/column/article/274908)的相关知识。如果分布式数据库使用了TSO保证全局时钟的单向递增那么就不再需要等待了因为在事件发生时已经按照全序排列并进行了记录。
第二个答案是就时间的话题做下延展。“等待”是为了让事件先后关系明确,消除模糊的边界,但这个思路还是站在上帝视角。
我们试想一种场景事件1是小明正在北京的饭馆里与人谈论小刚的大学趣事事件2是小刚在温哥华的公寓里打了一个喷嚏。如果事件1发生后4毫秒事件2才发生那么从绝对时间也就是全序上看两者是有先后关系的。但是从因果关系上也就是偏序上看事件1与事件2是没有联系的。因为科学承认的最快速度是光速从北京到温哥华即使是光速也无法在4毫秒内到达。那么从偏序关系上看事件1和事件2是并发的因为事件1没有机会影响到事件2。
当然这个理论不是我看多了科幻电影想出来的它来自Lamport的论文“ [Time, Clocks, and the Ordering of Events in a Distributed System](https://www.cs.princeton.edu/courses/archive/fall08/cos597B/papers/time-clocks.pdf)”。
所以假设两个事件发生地的距离除以光速得到一个时间X两个事件的时间戳间隔是Y时钟误差是Z。如果X&gt;Y+Z那么可以确定两个事件是并行发生的事件2就不用读等待了。这是因为既然事件是并行的事件2看不到事件1的结果也就是正常的了。
## 第13讲广义乐观和狭义乐观
在[第13讲](https://time.geekbang.org/column/article/282401)中,我们开始探讨“写写冲突”的控制技术,这也是并发控制最核心的内容。大型系统之所以能够承载海量并发,就在于底层数据库有强大的并发处理能力。
并发控制分为乐观协议和悲观协议两大类单体数据库大多使用悲观协议。TiDB和CockroachDB都在早期版本中提供了乐观协议但在后来的产品演进又改回了悲观协议其主要原因是事务竞争激烈和对遗留应用系统的兼容。
我们还从经典理论教材中提取了并发控制的四阶段忽略掉计算C阶段后悲观协议与乐观协议的区别在于有效性验证V、读R、写W这三阶段的排序不同。在分布式架构下有效性验证又分为局部有效性验证和全局有效性验证。因此乐观又分为狭义乐观和广义乐观而狭义乐观就是学术领域常说的OCC。TiDB的乐观锁因为没有全局有效性验证不严格符合VRW悲观协议排序所以是广义乐观。而TiDB后来增加的悲观锁增加了全局有效性验证是严格的VRW所以是悲观协议。
这一讲的思考题是在了解乐观协议及TiDB乐观转悲观的设计后请你来推测下CockroachDB向悲观协转换大概会采用什么方式
这个问题是为了引出第14讲的主题。CockroachDB早期的乐观协议也是广义乐观在局部看是悲观协议使用了串行化图检测SGT的方式。SGT是区别于锁的另一种控制技术具有更好的性能。CockroachDB的改良方式是增加了全局的锁表Lock Table局部保留了原有的SGT。
## 第14讲悲观协议
在第14讲中我们首先讨论了完整的并发控制技术体系选择了“[Transactional Information Systems](http://www.gbv.de/dms/weimar/toc/647210940_toc.pdf)”定义狭义乐观和其他悲观协议这种的组织形式而后对2PL的定义和各种变体进行了说明。我们根据S2PL的定义可以推导出Percolator模型属于S2PL的结论。S2PL虽然使用广泛但不能在生产级支持可串行化隔离。
PostgreSQL的SSI设计给出了另一种实现它的理论基础是SGT。CockroachDB在此基础设计了读时间戳缓存RTC降低了原有SIREAD的开销达到了生产级性能要求。最后我还和你一起学习了CockroachDB采用全局锁表实现悲观协议的原理。
这一讲的思考题是我们之前已经介绍过MVCC它是一项重要的并发控制技术你觉得该如何理解它和乐观协议、悲观协议的关系
就像我们在[第11讲](https://time.geekbang.org/column/article/280925)中所说的MVCC已经是数据库的底层技术与乐观协议、悲观协议下的各项技术是两个不同的维度最后形成了MVTO、MV2PL、MVSGT等技术。这些技术考虑了多版本情况下的处理但遵循的基本原理还是一样的。
## 小结
正如我在这一讲开头提到的第9到第14这6讲的内容都是围绕着分布式数据库的事务展开的重点就是原子性和隔离性的协议、算法和工程实现。
对于原子性我们主要关注非功能性指标背后的架构优化和理论创新尤其是NewSQL风格分布式数据库在2PC的三个传统难题也就是同步阻塞、单点故障、数据一致性上都取得的突破。
隔离性则不同早在单体数据库时代架构设计就在正确性也就是隔离级别上作了妥协换取性能。而分布式数据库在重新挑战了隔离性这个难题CockroachDB在这方面的探索更是意义重大它实践了一种兼顾正确性和性能的技术方案。
如果你对今天的内容有任何疑问,欢迎在评论区留言和我一起讨论。要是你身边的朋友也对分布式数据库的事务处理这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。
## 分布式数据全景图2/4
<img src="https://static001.geekbang.org/resource/image/6a/5b/6a60b1f94160712b587dcdd4a157db5b.jpg" alt="">
## 学习资料
James C. Corbett et al.: [**Spanner: Googles Globally-Distributed Database**](https://www.cs.princeton.edu/courses/archive/fall13/cos518/papers/spanner.pdf)
Jim Gray and Leslie Lamport: [**Consensus on Transaction Commit**](https://dsf.berkeley.edu/cs286/papers/paxoscommit-tods2006.pdf)
Leslie Lamport: [**Time, Clocks, and the Ordering of Events in a Distributed System**](https://www.cs.princeton.edu/courses/archive/fall08/cos597B/papers/time-clocks.pdf)
Gerhard Weikum and Gottfried Vossen: [**Transactional Information Systems**](http://www.gbv.de/dms/weimar/toc/647210940_toc.pdf)

View File

@@ -0,0 +1,129 @@
<audio id="audio" title="16 | 为什么不建议你使用存储过程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/70/fd/708c289179eeac4d4bd74dba443f77fd.mp3"></audio>
你好我是王磊你也可以叫我Ivan。
今天我们一起来到了这门课的第16讲。如果你学习并理解了前面的所有课程那么我要恭喜你这不仅是因为你学完了一半的课程还意味着你已经征服了“数据库事务”这座高峰。当然如果你有困惑的地方也不必沮丧因为接下来会是一小段平缓地带我们会探讨一些相对独立的问题。比如我们今天的话题为什么不建议你使用存储过程
有些资深的数据库开发同学可能不同意这个观点,我猜他们大概会说:“存储过程很好呀,那些用不好的人就是自己水平烂,不接受反驳!”其实,我就有过这样的念头,但是现在的我面对分布式数据库,会更倾向于少用或者不用存储过程。下面,我就来和你分享下这个心路历程吧。
## 我从C/S时代走来
当我刚成为一个名程序员时正好是C/S架构时代的末期。那时最流行的开发套件是PowerBuilder和Sybase数据库。PowerBuilder是一款可视化开发工具有点像VB开发好的程序运行在用户的PC终端上通过驱动程序连接远端的数据库。而Sybase当时正与Oracle争夺数据库的头把交椅它和SQL Server有很深的渊源两者在架构和语言上都很像。
<img src="https://static001.geekbang.org/resource/image/4c/69/4c5bc32eeaffdd633e0429a2a52db269.jpg" alt="" title="C/S架构">
在这个C/S架构中数据库不仅承担了数据存储、计算功能还要运行很重的业务逻辑相当于数据库同时承担了应用服务器Application Server的大多数功能。而这些业务逻辑的技术载体就是存储过程。所以不管是Sybase还是Oracle它们存储过程的功能都非常强大。
## 触发器被抛弃
进入B/S时代大家对数据库的理解发生了变化应用服务器承载了服务器端的主要业务逻辑那还要不要使用存储过程呢你看这和我们今天的问题是一样的。当时的主流观点认为存储过程还有存在价值的但是它的同胞兄弟触发器则被彻底抛弃了。
<img src="https://static001.geekbang.org/resource/image/aa/ab/aae1057b9ddb1192yy08aa891ec5c2ab.jpg" alt="" title="B/S架构">
为什么呢其实触发器和存储过程一样也是一种自定义函数。但它并不是显式调用而是在操作数据表的时候被动触发也就是执行insert、update和delete时而且你还可以选择触发时机是在操作前还是操作后也就before和after的语义。
听上去这个功能很强大吧,有点面向事件编程的意思。但是,如果你维护过触发器的逻辑就会发现,这是一个大坑。随着业务的发展和变更,触发器的逻辑会越来越复杂,就有人会在触发器的逻辑里操纵另一张表,而那张表上又有其他触发器牵连到其他表,这样慢慢就变成一个交错网络。
这简直就是一个地雷阵,你只要踏错一小步,经过一串连锁反应就会演变成一场大灾难。所以,触发器毫无悬念地退出了历史舞台。
## 存储过程的优点
存储过程的调用清晰,不存在触发器的问题。它的优点很明显,逻辑运行在数据库,没有网络传输数据的开销,所以在进行数据密集型操作时,性能优势很突出。
关于存储过程的使用我有一段亲身经历虽然过去了很多年但依然记忆深刻。当时要开发一个功能追溯业务实体间的影响关系比如A影响BB又影响到C。这个功能就是要以A为输入把B和C都找出来当然这个影响关系不只是三层了一直要追溯到所有被影响的实体。
今天我们都知道这是一个典型的关联关系查询适合用图数据库来处理。但那个时候还没有可用的图数据库我们需要在Oracle上解决这个问题。有一个比我更年轻的同事写了一段Java代码来实现这个功能我猜他没有经历过C/S时代。程序运行起来应用服务器不断地访问这张表处理每一条记录的关联关系。性能可想而知在一个数据量较少的测试环境上程序足足跑了三十分钟。这大大超出了用户的容忍范围必须要优化。
关于解决方案,我想你也猜到了,我换成了存储过程来实现同样的逻辑,因为不需要网络传输,性能大幅度提升。最后,存储过程花了大概二十几秒就得到了同样的结果。“干得漂亮!“我当时这么告诉自己。
## 存储过程的问题
但是后来,我发现了这个方案的问题,那就是移植性差。我们开发的产品要部署到客户环境里,会受到相关基础软件的制约。
有一次刚好碰到这个客户没有使用Oracle所以其他同事将我写的逻辑翻写到了客户使用的数据库上。我们给这个数据库取个化名就叫它TDB吧。可是移植到TDB之后的存储过程并没有跑出结果直接失败退出。我觉得很奇怪就跟踪了这段代码最后发现问题不在逻辑本身而在数据库上。答案是这样的这段逻辑中我使用了递归算法因为Oracle支持很深的递归层次所以运行完全没有问题而TDB只支持非常有限的递归层次而当时数据关联关系又比较多所以程序没跑多久就报错退出了。
这段经历让我对存储过程的信心有一点动摇。存储过程对于环境有很重的依赖而这个环境并不是操作系统和Java虚拟机这样遵循统一标准、有大量技术资料的开放环境而是数据库这个不那么标准的黑盒子。
然而存储过程的问题还不止于此。当我在C/S架构下开发时就遇到了存储过程难以调试的问题只不过当时大家都认为这是必须付出的代价。但是随着B/S架构的到来Java代码的开发测试技术不断发展相比之下存储过程难调试的问题就显得更突出了。而到了今天敏捷开发日渐普及DevOps工具链迅速发展而存储过程呢还是“遗世独立”的样子。
说了这么多,我希望你明白的是,今天的存储过程和当年的触发器,本质上面临的是同样的问题:**一种技术必须要匹配同时代的工程化水平,与整个技术生态相融合,否则它就要退出绝大多数应用场景**。
你看《阿里巴巴Java开发手册》中也赫然写着“禁止使用存储过程存储过程难以调试和扩展更没有移植性。”我想他们大概是有和我类似的心路历程吧。
## 分布式数据库的支持情况
刚才说的都是我从工程化角度发表的一些观点,现在让我们回到分布式数据库,再来看看这个新技术对存储过程的支持情况是怎样的。
目前多数NewSQL分布式数据库仍然是不支持存储过程的。OceanBase是一个例外它在2.2版本中增加了对Oracle存储过程的支持。我认为这是它全面兼容Oracle策略的产物。但是OceanBase的官方说明也说得很清楚目前存储过程的功能还不能满足生产级的要求。
其实对遗留系统的兼容可能就是今天存储过程最大的意义。而对于那些从MySQL向分布式数据库迁移的系统这个诉求可能就没那么强烈因为这些系统没有那么倚重存储过程。其中的原因就是MySQL在较晚的版本才提供存储过程而且功能上也没有Oracle那么强大用户对它的依赖自然也就小了。
当然存储过程没有得到NewSQL的广泛支持还因为架构上存在的难题。我们不妨看看业界的一些尝试。
Google在2018年VLDB上发布了F1的新论文” [F1 Query: Declarative Querying at Scale](http://vldb.org/pvldb/vol11/p1835-samwel.pdf)”。论文中提出通过独立的UDF Server支持自定义函数也就是存储过程。这个架构中因为F1是完全独立于数据存储的所以UDF Server自然也就被抽了出来。从论文提供的测试数据看这个设计保持了比较高的性能但我觉得这和Google强大的网络设施有很大关系在普通企业网络条件下能否适用这还很难说。
<img src="https://static001.geekbang.org/resource/image/08/1c/080f90c6eb4b97732a0786e83c89ef1c.png" alt="">
关于UDF Server的设计还有两点也是非常重要的。
- 首先UDF实现了对通用语言的支持除了SQL还支持C++、Java、Go等多种语言实现方式。这样不依赖于数据库的SQL方言逻辑表述的通用性更好。
- 其次UDF并没有耦合在存储层。这意味着它的上下文环境可以更加开放。
这两点变化意味着存储过程的调试问题可能会得到明显的改善使其与DevOps体系的对接成为可能。
不仅是F1其实更早的VoltDB也已经对存储过程进行了改革。VoltDB是一款基于内存的分布式数据库由数据库领域的传奇人物迈克尔 · 斯通布雷克Micheal Stonebraker主导开发。VoltDB将存储过程作为主要操作方式并支持使用Java语言编写。开发者可以继承系统提供的父类VoltProcedure来开发自己的存储过程。下面是一个简单的示例。
```
import org.voltdb.*;
public class LeastPopulated extends VoltProcedure {
//待执行的SQL语句
public final SQLStmt getLeast = new SQLStmt(
&quot; SELECT TOP 1 county, abbreviation, population &quot;
+ &quot; FROM people, states WHERE people.state_num=?&quot;
+ &quot; AND people.state_num=states.state_num&quot;
+ &quot; ORDER BY population ASC;&quot; );
//执行入口
public VoltTable[] run(int state_num)
throws VoltAbortException {
//赋输入参数
voltQueueSQL( getLeast, state_num );
//SQL执行函数
return voltExecuteSQL();
}
}
```
这段代码的逻辑非常简单首先定义SQL其中“state_num=”是预留参数位置而后在入口函数run()中赋参并执行。
VoltDB在设计理念上非常与众不同很重视CPU的使用效率。他们对传统数据库进行了分析认为普通数据库只有12%的CPU时间在做真正有意义的数据操作所以它的很多设计都是围绕着充分利用CPU资源这个理念展开的。
具体来说存储过程实质上是预定义的事务没有人工交互过程也就避免了相应的CPU等待。同时因为存储过程的内容是预先可知的所以能够尽早的将数据加载到内存中这又进一步减少了网络和磁盘I/O带来的CPU等待。
正是由于存储过程和内存的使用VoltDB即使在单线程模型下也获得了很好的性能。反过来单线程本身也让事务控制更加简单避免了传统的锁管理的开销和CPU等待提升了VoltDB的性能。
可以说与其他数据库相比存储过程对于VoltDB意义已经是截然不同了。
## 小结
好了,有关存储过程的话题就到这里了,让我们一起梳理下今天的重点内容。
1. 我用自己的一段亲身经历,说明了存储过程的移植差。究其原因,在于存储过程高度依赖于数据库环境,而数据库环境不像操作系统或虚拟机那样遵循统一的标准。因为同样的原因,存储过程调试也很复杂,也没有跟上敏捷开发的步伐,与今天工程化的要求不匹配。正是因为这两个工程化方面的原因,我建议你不用或者少用存储过程。
1. 从分布式数据库看多数NewSQL还不支持存储过程OceanBase作为唯一的例外已经支持Oracle存储过程但仍然没有达到生产级。
1. F1的论文提出了独立UDF Server的思路是分布式架构下存储过程的一种实现方案但能不能适合普通的企业网络环境尚待观察。但这个方案中存储过程的实现语言不局限于SQL方言而是放宽到多种主流语言向标准兼容具备更好的开放性。这提升了存储过程技术与DevOps融合的可能性。
1. VoltDB作为一款内存型分布式数据库以存储过程作为主要的操作定义方式支持使用Java语言开发。甚至可以说VoltDB的基础就是存储过程这种预定义事务方式。存储过程、内存存储、单线程三者互相影响使得VoltDB具备出色的性能表现。
对于任何一个程序员来说,放弃一种已经熟练掌握而且执行高效的技术,必然是一个艰难的决定。但是今天,对于大型软件系统而言,工程化要求远比某项技术本身更加重要。不能与整个技术生态协作的技术,最终将无法避免被边缘化的命运。当你学习一门新技术前,无论是分布式数据库还是微服务,我都建议你要关注它与周边生态是否能够适配,因为符合潮流的技术有机会变得更好,而太过小众的技术则蕴藏了更大的不确定性。
<img src="https://static001.geekbang.org/resource/image/47/59/47cbcfe7d5b68cdf5670deb3d613ca59.jpg" alt="">
## 思考题
课程的最后我们来看看今天的思考题。我们说VoltDB的设计思路很特别除了单线程、大量使用内存、存储过程支持Java语言外它在数据的复制上的设计也是别出心裁既不是NewSQL的Paxos协议也不是PGXC的主从复制你能想到是如何设计的吗提示一下复制机制和存储过程是有一定关系的。
欢迎你在评论区留言和我一起讨论,我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对存储过程这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。
## 学习资料
Bart Samwel: [**F1 Query: Declarative Querying at Scale**](http://vldb.org/pvldb/vol11/p1835-samwel.pdf)

View File

@@ -0,0 +1,165 @@
<audio id="audio" title="17 | 为什么不建议你使用自增主键?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e4/fa/e429e0bf3bea89746c96d85b2f95d9fa.mp3"></audio>
你好我是王磊你也可以叫我Ivan。
有经验的数据库开发人员一定知道,数据库除了事务处理、查询引擎这些核心功能外,还会提供一些小特性。它们看上去不起眼,却对简化开发工作很有帮助。
不过,这些特性的设计往往是以单体数据库架构和适度的并发压力为前提的。随着业务规模扩大,在真正的海量并发下,这些特性就可能被削弱或者失效。在分布式架构下,是否要延续这些特性也存在不确定性,我们今天要聊的自增主键就是这样的小特性。
虽然,我对自增主键的态度和[第16讲](https://time.geekbang.org/column/article/285270)提到的存储过程一样,都不推荐你使用,但是原因各有不同。存储过程主要是工程方面的原因,而自增主键则是架构上的因素。好了,让我们进入正题吧。
## 自增主键的特性
自增主键在不同的数据库中的存在形式稍有差异。在MySQL中你可以在建表时直接通过关键字auto_increment来定义自增主键例如这样
```
create table test (
id int(16) NOT NULL AUTO_INCREMENT,
name char(10) DEFAULT NULL,
PRIMARY KEY(id)
) ENGINE = InnoDB;
```
而在Oracle中则是先声明一个连续的序列也就是sequence而后在insert语句中可以直接引用sequence例如下面这样
```
create sequence test_seq increment by 1 start with 1;
insert into test(id, name) values(test_seq.nextval, ' An example ');
```
自增主键给开发人员提供了很大的便利。因为,主键必须要保证唯一,而且多数设计规范都会要求,主键不要带有业务属性,所以如果数据库没有内置这个特性,应用开发人员就必须自己设计一套主键的生成逻辑。数据库原生提供的自增主键免去了这些工作量,而且似乎还能满足开发人员的更多的期待。
这些期待是什么呢?我总结了一下,大概有这么三层:
- 首先是唯一性,这是必须保证的,否则还能叫主键吗?
- 其次是单调递增,也就是后插入记录的自增主键值一定比先插入记录要大。
- 最后就是连续递增自增主键每次加1。有些应用系统甚至会基于自增主键的“连续递增”特性来设计业务逻辑。
## 单体数据库的自增主键
但是,我接下来的分析可能会让你失望,因为除了最基本的唯一性,另外的两层期待都是无法充分满足的。
### 无法连续递增
首先说连续递增。在多数情况下自增主键确实表现为连续递增。但是当事务发生冲突时主键就会跳跃留下空洞。下面我用一个例子简单介绍下MySQL的处理过程。
<img src="https://static001.geekbang.org/resource/image/1a/21/1ae285029b67aa128fd34c5cc3caf721.jpg" alt="">
两个事务T1和T2都要在同一张表中插入记录T1先执行得到的主键是25而T2后执行得到是26。
<img src="https://static001.geekbang.org/resource/image/7e/54/7e48552810fcc500aec4f4c253a18e54.jpg" alt="">
但是T1事务还要操作其他数据库表结果不走运出现了异常T1必须回滚。T2事务则正常执行成功完成了事务提交。
<img src="https://static001.geekbang.org/resource/image/f8/6e/f8890f3f251cc74808f6fyy53a29526e.jpg" alt="">
这样在数据表中就缺少主键为25的记录而当下一个事务T3再次申请主键时得到的就是27那么25就成了永远的空洞。
为什么不支持连续递增呢?这是因为自增字段所依赖的计数器并不是和事务绑定的。如果要做到连续递增,就要保证计数器提供的每个主键都被使用。
怎么确保每个主键都被使用呢?那就要等待使用主键的事务都提交成功。这意味着,必须前一个事务提交后,计数器才能为后一个事务提供新的主键,这个计数器就变成了一个表级锁。
显然如果存在这么大粒度的锁性能肯定会很差所以MySQL优先选择了性能放弃了连续递增。至于那些因为事务冲突被跳过的数字呢系统也不会再回收重用了这是因为要保证自增主键的单调递增。
看到这里你可能会想, 虽然实现不了连续递增,但至少能保证单调递增,也不错。那么,我要再给你泼一盆冷水了,这个单调递增有时也是不能保证的。
### 无法单调递增
对于单体数据库自身来说,自增主键确实是单调递增的。但使用自增主键也是有前提的,那就是主键生成的速度要能够满足应用系统的并发需求。而在高并发量场景下,每个事务都要去申请主键,数据库如果无法及时处理,自增主键就会成为瓶颈。那么,这时只用自增主键已经不能解决问题了,往往还要在应用系统上做些优化。
比如对于Oracle数据库常见的优化方式就是由Sequence负责生成主键的高位由应用服务器负责生成低位数字拼接起来形成完整的主键。
<img src="https://static001.geekbang.org/resource/image/f2/0c/f2b6cc531b0dbec7d19f191b6225b20c.jpg" alt="">
图中展示这样的例子数据库的Sequence 是一个5位的整型数字范围从10001到99999。每个应用系统实例先拿到一个号比如10001应用系统在使用这5位为作为高位自己再去拼接5位的低位这样得到一个10位长度的主键。这样每个节点访问一次Sequence就可以处理99999次请求处理过程是基于应用系统内存中的数据计算主键没有磁盘I/O开销而相对的Sequence递增时是要记录日志的所以方案改进后性能有大幅度提升。
这个方案虽然使用了Sequence但也只能保证全局唯一数据表中最终保存的主键不再是单调递增的了。
因为几乎所有数据库中的自增字段或者自增序列都是要记录日志的也就都会产生磁盘I/O也就都会面临这个性能瓶颈的问题。所以我们可以得出一个结论在一个海量并发场景下即使借助单体数据库的自增主键特性也不能实现单调递增的主键。
## 自增主键的问题
对于分布式数据库,自增主键带来的麻烦就更大了。具体来说是两个问题,一是在自增主键的产生环节,二是在自增主键的使用环节。
首先,产生自增主键难点就在单调递增。如果你已经学习过[第5讲](https://time.geekbang.org/column/article/274908)就会发现单调递增这个要求和全局时钟中的TSO是很相似的。你现在已经知道TSO实现起来比较复杂也容易成为系统的瓶颈如果再用作主键的发生器显然不大合适。
其次使用单调递增的主键也会给分布式数据库的写入带来问题。这个问题是在Range分片下发生的我们通常将这个问题称为 “尾部热点”。
### 尾部热点
我们先通过一组性能测试数据来看看尾部热点问题的现象,这些数据和图表来自[CockroachDB官网](https://www.cockroachlabs.com/blog/unpacking-competitive-benchmarks/)。
<img src="https://static001.geekbang.org/resource/image/53/d2/5324e7ee83485724b062d6e8e72bcdd2.png" alt="">
这本身是一个CockraochDB与YugabyteDB的对比测试。测试环境使用亚马逊跨机房的三节点集群执行SQL insert操作时YugabyteDB的TPS达到58,877而CockroachDB的TPS是34,587。YugabyteDB集群三个节点上的CPU都得到了充分使用而CockroachDB集群中负载主要集中在一个节点上另外两个节点的CPU多数情况都处于空闲状态。
为什么CockroachDB的节点负载这么不均衡呢这是由于CockroachDB默认设置为Range分片而测试程序的生成主键是单调递增的所以新写入的数据往往集中在一个 Range 范围内而Range又是数据调度的最小单位只能存在于单节点那么这时集群就退化成单机的写入性能不能充分利用分布式读写的扩展优势了。当所有写操作都集中在集群的一个节点时就出现了我们常说的数据访问热点Hotspot
图中也体现了CockroachDB改为Hash分片时的情况因为数据被分散到多个Range所以TPS一下提升到61,113性能达到原来的1.77倍。
现在性能问题的根因已经找到了就是同时使用自增主键和Range分片。在[第6讲](https://time.geekbang.org/column/article/275696)我们已经介绍过了Range分片很多优势这使得Range分片成为一个不能轻易放弃的选择。于是主流产品的默认方案是保持Range分片放弃自增主键转而用随机主键来代替。
## 随机主键方案
随机主键的产生方式可以分为数据库内置和应用外置两种方式。当然对于应用开发者来说,内置方式使用起来会更加简便。
### 内置UUID
UUIDUniversally Unique Identifier可能是最经常使用的一种唯一ID算法CockroachDB也建议使用UUID作为主键并且内置了同名的数据类型和函数。UUID是由32个的16进制数字组成所以每个UUID的长度是128位16^32 = 2^128。UUID作为一种广泛使用标准有多个实现版本影响它的因素包括时间、网卡MAC地址、自定义Namesapce等等。
但是UUID的缺点很明显那就是键值长度过长达到了128位因此存储和计算的代价都会增加。
### 内置Radom ID
TiDB默认是支持自增主键的对未声明主键的表会提供了一个隐式主键_tidb_rowid因为这个主键大体上是单调递增的所以也会出现我们前面说的“尾部热点”问题。
TiDB也提供了UUID函数而且在4.0版本中还提供了另一种解决方案AutoRandom。TiDB 模仿MySQL的 AutoIncrement提供了AutoRandom关键字用于生成一个随机ID填充指定列。
<img src="https://static001.geekbang.org/resource/image/4y/86/4yy3c805599ebb98ba418a4c63220986.jpg" alt="">
这个随机ID是一个64位整型分为三个部分。
- 第一部分的符号位没有实际作用。
- 第二部分是事务开始时间默认为5位可以理解为事务时间戳的一种映射。
- 第三部分则是自增的序列号, 使用其余位。
AutoRandom可以保证表内主键唯一用户也不需要关注分片情况。
### 外置Snowflake
雪花算法Snowflake是Twitter公司分布式项目采用的ID生成算法。
<img src="https://static001.geekbang.org/resource/image/5a/e2/5a0738f8520a5d21582e896yy3413de2.jpg" alt="">
这个算法生成的ID是一个64位的长整型由四个部分构成
- 第一部分是1位的符号位并没有实际用处主要为了兼容长整型的格式。
- 第二部分是41位的时间戳用来记录本地的毫秒时间。
- 第三部分是机器ID这里说的机器就是生成ID的节点用10位长度给机器做编码那意味着最大规模可以达到1024个节点2^10
- 最后是12位序列序列的长度直接决定了一个节点1毫秒能够产生的ID数量12位就是40962^12
这样根据数据结构推算雪花算法支持的TPS可以达到419万左右2^22*1000我相信对于绝大多数系统来说是足够了。
但实现雪花算法时有个小问题往往被忽略那就是要注意时间回拨带来的影响。机器时钟如果出现回拨产生的ID就有可能重复这需要在算法中特殊处理一下。
## 小结
那么,今天的课程就到这里了,让我们梳理一下这一讲的要点。
1. 单体数据库普遍提供了自增主键或序列等方式,自动产生主键。单体数据库的自增主键保证主键唯一、单调递增,但在发生事务冲突时,并不能做到连续递增。在海量并发场景下,通常不能直接使用数据库的自增主键,因为它的性能不能满足要求。解决方式是应用系统进行优化,有数据库控制高位,应用系统控制低位,提升性能。但使用这种方案,主键不再是单调递增的。
1. 分布式数据库在产生自增主键和使用自增主键两方面都有问题。生成自增主键时要做到绝对的单调递增其复杂度等同于TSO全局时钟而且存在性能上限。使用自增主键时会导致写入数据集中在单个节点出现“尾部热点”问题。
1. 由于自增主键的问题有的分布式数据库如CockroachDB更推荐使用随机主键的方式。随机主键的产生机制可以分为数据库内置和应用系统外置两种思路。内置的技术方案我们介绍了CockraochDB的UUID和TiDB的RadomID。外置技术方案我们介绍了Snowflake。
<img src="https://static001.geekbang.org/resource/image/6f/4f/6f4b43cfb79e1f1696c0fcb741d9ed4f.jpg" alt="">
## 思考题
课程的最后我们来看看今天的思考题。我们说如果分布式数据库使用Range分片的情况下单调递增的主键会造成写入压力集中在单个节点上出现“尾部热点”问题。因此很多产品都用随机主键替换自增主键分散写入热点。我的问题就是你觉得使用随机主键是不是一定能避免出现“热点”问题呢
欢迎你在评论区留言和我一起讨论,我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对分布式架构下如何设计主键这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。
## 学习资料
CockroachDB: [**Yugabyte vs CockroachDB: Unpacking Competitive Benchmark Claims**](https://www.cockroachlabs.com/blog/unpacking-competitive-benchmarks/)

View File

@@ -0,0 +1,153 @@
<audio id="audio" title="18 | HTAP是不是赢者通吃的游戏" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d3/ef/d3a3991378d995d79660e818658aa8ef.mp3"></audio>
你好我是王磊你也可以叫我Ivan。
这一讲的关键词是HTAP在解释这个概念前我们先要搞清楚它到底能解决什么问题。
有关OLTP和OLAP的概念我们在[第1讲](https://time.geekbang.org/column/article/271373)就已经介绍过了。OLTP是面向交易的处理过程单笔交易的数据量很小但是要在很短的时间内给出结果而OLAP场景通常是基于大数据集的运算。
<img src="https://static001.geekbang.org/resource/image/38/1a/382508db9c5e251760d2eb443ebcc41a.jpg" alt="">
OLAP和OLTP通过ETL进行衔接。为了提升OLAP的性能需要在ETL过程中进行大量的预计算包括数据结构的调整和业务逻辑处理。这样的好处是可以控制OLAP的访问延迟提升用户体验。但是因为要避免抽取数据对OLTP系统造成影响所以必须在日终的交易低谷期才能启动ETL过程。这样一来 OLAP与OLTP的数据延迟通常就在一天左右习惯上大家把这种时效性表述为T+1。其中T日就是指OLTP系统产生数据的日期T+1日是OLAP中数据可用的日期两者间隔为1天。
你可能已经发现了这个体系的主要问题就是OLAP系统的数据时效性T+1太慢了。是的进入大数据时代后商业决策更加注重数据的支撑而且数据分析也不断向一线操作渗透这都要求OLAP系统更快速地反映业务的变化。
## 两种解决思路
说到这你应该猜到了HTAP要解决的就是OLAP的时效性问题不过它也不是唯一的选择这个问题有两种解决思路
1. 用准实时数据计算替代原有批量ETL过程重建OLAP体系
1. 弱化甚至是干脆拿掉OLAP直接在OLTP系统内扩展也就是HTAP。
### 重建OLAP体系
我们先来看第一种思路。重建OLAP体系重视数据加工的时效性正是近年来大数据技术的主要发展方向。Kappa架构就是新体系的代表它最早由LinkedIn的Jay Kreps在2014年的[一篇文章](https://www.oreilly.com/radar/questioning-the-lambda-architecture/)中提出。
<img src="https://static001.geekbang.org/resource/image/6b/52/6b204f3de97cdf4b75dd197672db2452.jpg" alt="">
在Kappa架构中原来的批量文件传输方式完全被Kafka替代通过流计算系统完成数据的快速加工数据最终落地到Serving DB中提供查询服务。这里的Serving DB泛指各种类型的存储可以是HBase、Redis或者MySQL。
要注意的是Kappa架构还没有完全实现因为在实践中流计算仍然无法替代批量计算Serving DB也无法满足各种类型的分析查询需求。未来Kappa架构需要在两方面继续完善
1. 流计算能力的增强这需要用到Kafka和Flink等软件
1. Serving DB即时计算能力的增强这就寄希望于OLAP数据库的突破就像ClickHouse已经做的那样。
总的来说新的OLAP体系试图提升即时运算能力去除批量ETL降低数据延迟。这个新体系是流计算的机遇也是OLAP数据库的自我救赎。
### 新建HTAP系统
第二种思路是HTAP。HTAPHybrid Transaction/Analytical Processing就是混合事务分析处理它最早出现在 2014年[Gartner的一份报告中](https://www.gartner.com/en/documents/2657815)很巧和Kappa架构是同一年。Gartner用HTAP来描述一种新型数据库它打破了OLTP和OLAP之间的隔阂在一个数据库系统中同时支持事务型数据库场景和分析型数据库场景。这个构想非常美妙HTAP可以省去繁琐的ETL操作避免批量处理造成的滞后更快地对最新数据进行分析。
这个构想很快表现出它侵略性的一面由于数据产生的源头在OLTP系统所以HTAP概念很快成为OLTP数据库尤其是NewSQL风格的分布式数据库向OLAP领域进军的一面旗帜。
那么NewSQL在初步解决OLTP场景的高并发、强一致性等问题后能不能兼顾OLAP场景形成赢者通吃的局面呢
其实还很难讲因为从技术实践看重建OLAP路线的相关技术似乎发展得更快参与厂商也更加广泛在实际生产环境的落地效果也不断改善。
相比之下HTAP的进展比较缓慢鲜有生产级的工业实践但仍有不少厂商将其作为产品的演进方向。目前厂商官宣的HTAP至少包括TiDB和TBase,而OceanBase也宣布在近期版本中推出OLAP场景的特性。基于商业策略的考虑我相信未来还会有更多分布式数据库竖起HTAP的大旗。那么接下来我们分析下HTAP面临的挑战让你更好地识别什么是HTAP。
## HTAP的两种存储设计
这就要先说回OLTP和OLAP在架构上它们的差异在于计算和存储两方面。
计算是指计算引擎的差异目标都是调度多节点的计算资源做到最大程度地并行处理。因为OLAP是海量数据要追求高吞吐量而OLTP是少量数据更重视低延迟所以它们计算引擎的侧重点不同。
存储是指数据在磁盘上的组织方式不同而组织方式直接决定了数据的访问效率。OLTP和OLAP的存储格式分别为行式存储和列式存储它们的区别我稍后会详细说明。
分布式数据库的主流设计理念是计算与存储分离那么计算就比较容易实现无状态化所以在一个HTAP系统内构建多个计算引擎显然不是太困难的事情而真的要将HTAP概念落地为可运行系统根本性的挑战就是存储。面对这个挑战业界有两个不同的解决思路
1. Spanner使用的融合性存储PAXPartition Attributes Across试图同时兼容两类场景。
1. TiDB4.0版本中的设计,在原有行式存储的基础上,新增列式存储,并通过创新性的设计,保证两者的一致性。
### Spanner存储合一
首先我们一起看看Spanner的方案。Spanner2017论文“Spanner: Becoming a SQL System”中介绍了它的新一代存储Ressi其中使用了类似PAX的方式。这个PAX并不是Spanner的创新早在VLDB2002的论文 “[Data Page Layouts for Relational Databases on Deep Memory Hierarchies](http://research.cs.wisc.edu/multifacet/papers/vldbj02_pax.pdf)” 中就被提出了。论文从CPU缓存友好性的角度对不同的存储方式进行了探讨涉及NSM、DSM、PAX三种存储格式。
#### NSM (行式存储)
NSMN-ary Storage Model就是行式存储也是OLTP数据库默认的存储方式始终伴随着关系型数据库的发展。我们常用的OLTP数据库比如MySQLInnoDB、PostgreSQL、Oracle和SQL Server等等都使用了行式存储。
顾名思义行式存储的特点是将一条数据记录集中存在一起这种方式更加贴近于关系模型。写入的效率较高在读取时也可以快速获得一个完整数据记录这种特点称为记录内的局部性Intra-Record Spatial Locality
<img src="https://static001.geekbang.org/resource/image/aa/6c/aa9a1c530231f625d2fde65d59ba5c6c.jpg" alt="">
但是行式存储对于OLAP分析查询并不友好。OLAP系统的数据往往是从多个OLTP系统中汇合而来单表可能就有上百个字段。而用户一次查询通常只访问其中的少量字段如果以行为单位读取数据查询出的多数字段其实是无用的也就是说大量I/O操作都是无效的。同时大量无效数据的读取又会造成CPU缓存的失效进一步降低了系统的性能。
<img src="https://static001.geekbang.org/resource/image/b6/2b/b61bac48bd1d05f8fe58b7ae4904932b.jpg" alt="">
图中显示CPU缓存的处理情况我们可以看到很多无效数据被填充到缓存中挤掉了那些原本有机会复用的数据。
#### DSM列式存储
DSMDecomposition Storage Model就是列式存储它的出现要晚于行式存储。典型代表系统是C-Store它是迈克尔 · 斯通布雷克Micheal Stonebraker主导的开源项目后来的商业化产品就是Vertica。
列式存储就是将所有列集中存储不仅更加适应OLAP的访问特点对CACHE也更友好。这种特点称为记录间的局部性Inter-Record Spatial Locality。列式存储能够大幅提升查询性能以速度快著称的ClickHouse就采用了列式存储。
列式存储的问题是写入开销更大这是因为根据关系模型在逻辑上数据的组织单元仍然是行改为列式存储后同样的数据量会被写入到更多的数据页page而数据页直接对应着物理扇区那么磁盘I/O的开销自然增大了。
<img src="https://static001.geekbang.org/resource/image/bd/96/bd96a2edb8bfe131bf0ec5aa91509596.jpg" alt="">
列式存储的第二个问题,就是很难将不同列高效地关联起来。毕竟在多数应用场景中,不只是使用单列或单表数据,数据分散后,关联的成本会更高。
#### PAX
<img src="https://static001.geekbang.org/resource/image/70/08/70c94c6aef3d1d8d27d263d52e6e2c08.jpg" alt="">
PAX增加了minipage这个概念是原有的数据页下的二级单位这样一行数据记录在数据页上的基本分布不会被破坏而相同列的数据又被集中地存储在一起。PAX本质上还是更接近于行式存储但它也在努力平衡记录内局部性和记录间局部性提升了OLAP的性能。
理论上PAX提供了一种兼容性更好的存储方式可让人有些信心不足的是其早在2002年提出但在Spanner之前却少有落地实现。
与这个思路类似的设计还有HyPer的[DataBlock](http://db.in.tum.de/downloads/publications/datablocks.pdf)(SIGMOD2016)DataBlock构造了一种独有的数据结构同时面向OLTP和OLAP场景。
### TiFlash存储分离
如果底层存储是一份数据那么天然就可以保证OLTP和OLAP的数据一致性这是PAX的最大优势但是由于访问模式不同性能的相互影响似乎也是无法避免只能尽力选择一个平衡点。TiDB展现了一种不同的思路介于PAX和传统OLAP体系之间那就是OLTP和OLAP采用不同的存储方式物理上是分离的然后通过创新性的复制策略保证两者的数据一致性。
TiDB是在较早的版本中就提出了HTAP这个目标并增加了TiSpark作为OLAP的计算引擎但仍然共享OLTP的数据存储TiKV所以两种任务之间的资源竞争依旧不可避免。直到近期的4.0版本中TiDB正式推出了TiFlash作为OLAP的专用存储。
<img src="https://static001.geekbang.org/resource/image/7f/83/7fd1f6b5fe9fa85bf85da382fec68083.jpg" alt="">
我们的关注点集中在TiFlash与TiKV之间的同步机制上。其实这个同步机制仍然是基于Raft协议的。TiDB在Raft协议原有的Leader和Follower上增加了一个角色Learner。这个Learner和Paxos协议中的同名角色有类似的职责就是负责学习已经达成一致的状态但不参与投票。这就是说Raft Group在写入过程中统计多数节点时并没有包含Learner这样的好处是Learner不会拖慢写操作但带来的问题是Learner的数据更新必然会落后于Leader。
看到这里你可能会问这不就是一个异步复制吗换了个马甲而已有啥创新的。这也保证不了AP与TP之间的数据一致性吧
Raft协议能够实现数据一致性是因为限制了只有主节点提供服务否则别说是Learner就是Follower直接对外服务都不能满足数据一致性。所以这里还有另外一个设计。
Learner每次接到请求后首先要确认本地的数据是否足够新而后才会执行查询操作。怎么确认足够新呢 Learner会拿着读事务的时间戳向Leader发起一次请求获得Leader 最新的 Commit Index就是已提交日志的顺序编号。然后就等待本地日志继续Apply直到本地的日志编号等于Commit Index后数据就足够新了。而在本地 Region 副本完成同步前,请求会一直等待直到超时。
这里你可能又会产生疑问。这种同步机制有效运转的前提是TiFlash不能落后太多否则每次请求都会带来数据同步操作大量请求就会超时也就没法实际使用了。但是TiFlash是一个列式存储列式存储的写入性能通常不好TiFlash怎么能够保持与TiKV接近的写入速度呢
这就要说到TiFlash的存储引擎Delta Tree它参考了B+ Tree和LSM-Tree的设计分为Delta Layer 和 Stable Layer两层其中Delta Layer保证了写入具有较高的性能。因为目前还没有向你介绍过存储引擎的背景知识所以这里不再展开Delta Tree的内容了我会在第22讲再继续讨论这个话题。
当然TiFlash毕竟是OLAP系统首要目标是保证读性能因此写入无论多么重要都要让位于读优化。作为分布式系统还有最后一招可用那就是通过扩容降低单点写入的压力。
## 小结
好了,今天的内容我们就聊到这了,最后让我们梳理一下这一讲的要点。
1. OLTP通过ETL与OLAP衔接所以OLAP的数据时效性通常是T+1不能及时反映业务的变化。这个问题有两种解决思路一种是重建OLAP体系通过流计算方式替代批量数据处理缩短OLAP的数据延迟典型代表是Kappa架构。第二种思路是Gartner提出的HTAP。
1. HTAP的设计要点在计算引擎和存储引擎其中存储引擎是基础。对于存储引擎也两种不同的方案一种是以PAX为代表用一份物理存储融合行式和列式的特点Spanner采用了这种方式。另一种是TiDB的TiFlash为OLTP和OLAP分别设置行式存储和列式存储通过创新性的同步机制保证数据一致。
1. TiDB的同步机制仍然是基于Raft协议的通过增加Learner角色实现异步复制。异步复制必然带来数据的延迟Learner在响应请求前通过与Leader同步增量日志的方式满足数据一致性但这会带来通讯上的额外开销。
1. TiFlash作为列存首先要保证读取性能但因为要保证数据一致性所以也要求具备较高的写入性能TiFlash通过Delta Tree的设计来平衡读写性能。这个问题我们没有展开将在22讲继续讨论。
总的来说HTAP是解决传统OLAP的一种思路但是推动者只是少数OLTP数据库厂商。再看另一边以流计算为基础的新OLAP体系这些技术本身就是大数据技术生态的一部分有更多的参与者不断有新的成果落地。至于HTAP具有绝对优势的数据一致性其实在商业场景中也未必有足够多的刚性需求。所以从实践效果出发我个人更加看好后者也就是新OLAP体系。
当然HTAP也具有相对优势那就是通过全家桶方案避免了用户集成多个技术产品整体技术复杂度有所降低。最后TiDB给出的解决方案很有新意但是能否覆盖足够大的OLAP场景仍有待观察。
<img src="https://static001.geekbang.org/resource/image/17/73/1782dfac5604f15f3daee70cfbfb4e73.jpg" alt="">
## 思考题
课程的最后我们来看看今天的思考题。今天我们介绍了TiDB的OLAP组件TiFlash它保持数据一致性的方法是每次TiFlash接到请求后都会向TiKV Leader请求最新的日志增量本地replay日志后再继续处理请求。这种模式虽然能够保证数据足够新但比起TiFlash独立服务多了一次网络通讯在延迟上有较大的影响。我的问题就是你觉得这个模式还能优化吗在什么情况下不需要与Leader通讯
欢迎你在评论区留言和我一起讨论我会在答疑篇回复这个问题。如果你身边的朋友也对HTAP这个话题感兴趣你也可以把今天这一讲分享给他我们一起讨论。
## 学习资料
Anastassia Ailamaki et al.: [**Data Page Layouts for Relational Databases on Deep Memory Hierarchies**](http://research.cs.wisc.edu/multifacet/papers/vldbj02_pax.pdf)
Harald Lang et al: [**Data Blocks: Hybrid OLTP and OLAP on Compressed Storage using both Vectorization and Compilation**](http://db.in.tum.de/downloads/publications/datablocks.pdf)
Jay Kreps: [**Questioning the Lambda Architecture**](https://www.oreilly.com/radar/questioning-the-lambda-architecture/)
Nigel Rayner et al.: [**Hybrid Transaction/Analytical Processing Will Foster Opportunities for Dramatic Business Innovation**](https://www.gartner.com/en/documents/2657815)

View File

@@ -0,0 +1,149 @@
<audio id="audio" title="19 | 查询性能优化:计算与存储分离架构下有哪些优化思路?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/27/c0/2751ea0eac6e85c9a5a736c0a07871c0.mp3"></audio>
你好我是王磊你也可以叫我Ivan。
我在[第4讲](https://time.geekbang.org/column/article/274200)介绍架构风格时曾经提到过分布式数据库的主体架构是朝着计算和存储分离的方向发展的这一点在NewSQL架构中体现得尤其明显。但是计算和存储是一个完整的过程架构上的分离会带来一个问题是应该将数据传输到计算节点(Data Shipping),还是应该将计算逻辑传输到数据节点(Code Shipping)
从直觉上说肯定要选择Code Shipping因为Code的体量远小于Data因此它能传输得更快让系统的整体性能表现更好。
这个将code推送到存储节点的策略被称为“计算下推”是计算存储分离架构下普遍采用的优化方案。
## 计算下推
将计算节点的逻辑推送到存储节点执行,避免了大量的数据传输,也达到了计算并行执行的效果。这个思路还是很好理解的,我们用一个例子来具体说明下。
假如有一张数据库表test目前有四条记录。
<img src="https://static001.geekbang.org/resource/image/59/82/5963d1c56f0bf56349a5769f5414d882.jpg" alt="">
我们在客户端执行下面这条查询SQL。
```
select value from test where cond=C1
```
计算节点接到这条SQL后会将过滤条件“cond=C1“下推给所有存储节点。
<img src="https://static001.geekbang.org/resource/image/b7/e7/b7ed160cc495c7aaf1f2c06d88aca2e7.jpg" alt="">
存储节点S1有符合条件的记录则返回计算节点其他存储节点没有符合的记录返回空。计算节点直接将S1的结果集返回给客户端。
这个过程因为采用了下推方式,网络上没有无效的数据传输,否则,就要把四个存储节点的数据都送到计算节点来过滤。
这个例子是计算下推中比较典型的“谓词下推”(Predicate Pushdown)很直观地说明了下推的作用。这里的谓词下推就是把查询相关的条件下推到数据源进行提前的过滤操作表现形式主要是Where子句。但场景更复杂时比如事务包含了写入操作时对于某些分布式数据库来说就没这么简单了。
### TiDB的挑战
下面的例子就是关于TiDB如何处理下推的我们首先来看这组SQL。
```
begin;
insert into test (id, value, cond) values(5,V5,C4);
select * from test where cond=C4;
```
SQL的逻辑很简单先插入一条记录后再查询符合条件的所有记录。结合上一个例子中test表的数据存储情况得到的查询结果应该是两条记录一条是原有ID等于4的记录另一条是刚插入的ID等于5的记录。这对单体数据库来说是很平常的操作但是对于TiDB来说就是一个有挑战的事情了。
我在[第10讲](https://time.geekbang.org/column/article/279660)曾经介绍过TiDB采用了“缓存写提交”技术就是将所有的写SQL缓存起来直到事务commit时再一起发送给存储节点。这意味着执行事务中的select语句时insert的数据还没有写入存储节点而是缓存在计算节点上的那么select语句下推后查询结果将只有ID为4的记录没有ID等于5的记录。
<img src="https://static001.geekbang.org/resource/image/43/b7/431217592095f4967523d3c5ce3174b7.jpg" alt="">
这个结果显然是错误的。为了解决这个问题TiDB开始的设计策略是当计算节点没有缓存数据时就执行下推否则就不执行下推。
这种策略限制了下推的使用对性能的影响很大。所以之后TiDB又做了改进将缓存数据也按照存储节点的方式组织成Row格式再将缓存和存储节点返回结果进行Merge就得到了最后的结果。这样缓存数据就不会阻碍读请求的下推了。
<img src="https://static001.geekbang.org/resource/image/ba/db/ba31c136bc13e44f75a4376e9a8399db.jpg" alt="">
### 分区键与Join下推
除了谓词下推,还有一个对下推来讲很重要的关联设计,那就是分区键。分区键是沿用单体数据库的说法,这里的分区实质是指分片,也就是在定义建表语句时,显式指定的分片对应的键值。
如果你已经学习过[第6讲](https://time.geekbang.org/column/article/275696)一定记得不同的分片机制与架构风格直接相关。通常在PGXC架构中是显式指定分片的所以会出现分区键而NewSQL主要采用Range分片用户感受不到分片的存在所以往往无法利用这个特性。
只要SQL的谓词条件中包含分区键那么很多时候是可以下推到各个存储节点执行的。就算是面对多表关联的情况只要这些表使用了相同的分区键也是可以下推的类似的方式在PolarDB中被称为“Join下推”在Greenplum中被称为本地连接Local Joins。Join下推可以保证数据移动最少减少网络开销。
但是,多表使用相同的分区键并不是一个通用的方法,很多时候会在性能的均衡上面临挑战。例如,我们对用户表和交易表同样使用“机构”来做分区键,这时在每个分片内用户数量和交易数量往往不成正比。这是因为少量用户贡献了多数的交易,同时这些少量用户可能又会集中在几个节点上,就会出现局部资源紧张。
最后也不是所有计算都能下推的。比如排序操作业务需求往往不只是在一个分区内进行排序还有关联查询Join即使关联的多张表都使用了分区键但如果查询条件中没有包含分区键也是很难处理。关联查询的有关内容我在第20讲还会详细介绍。
## 索引分布
分布式数据库执行计算下推的目的就是为了加速查询。我想问问你,单体数据库的查询优化手段是什么呢?嗯,你一定会告诉我,索引优化。
的确,索引是数据库加速查询的重要手段。索引优化的基本逻辑是:索引实质是数据库表的子表,它的数据量更少,所以查询索引比查询数据表更高效。那么,先通过索引确定记录的主键后再“回表”查询,也就比直接查询数据表的速度更快。当然,在有些情况下,索引包含的数据项已经能够满足查询的需要,可以免去“回表”这个步骤,性能表现会更好。
索引优化对于分布式数据库来说仍然是重要的优化手段,并且和前面介绍的计算下推有密切的关系。
对于单体数据库,索引和数据表必然在同一节点上;而在分布式架构下,索引和数据既可能是同节点的,也可能是跨节点的,而这对于读写性能有很大影响。我们按照索引的分布情况和作用范围,可以分为全局索引和分区索引两种类型。在很多分布式数据库中都有对应实现,支持情况稍有差异。
### 分区索引
分区索引就是索引与数据在同一分区这个分区实际就是我们之前说的分片。因为分片是最小调度单位那就意味着在分区索引下索引和数据是确保存储在同一物理节点。我们把索引和数据在同一个物理节点的情况称为同分布co_located
分区索引的优点很明显,那就是性能好,因为所有走索引的查询都可以下推到每个存储节点,每个节点只把有效查询结果返回给计算节点,避免了大量无效的数据传输。分区索引的实现难点在于如何保证索引与数据始终同分布。
索引与数据同分布又和分片的基本策略有关。我们在[第6讲](https://time.geekbang.org/column/article/275696)介绍了动态分片的分拆和调度,都会影响同分布。[Spanner2017论文](https://dl.acm.org/doi/10.1145/3035918.3056103)中简短地介绍了父子表模式parent-child的同分布策略原理就是利用键值存储系统左前缀匹配Key区间的特性通过设置子表记录与父表记录保持相同的前缀来实现两者的同分布。索引作为数据的子表也采用了类似的设计理念。
NewSQL分布式数据库的底层就是分布式键值存储系统所以我们下面用BigTable的开源实现HBase来介绍具体实现原理。
在HBase下每个分片都有一个不重叠的Key区间这个区间左闭右开。当新增一个键值对Key/Value系统会先判断这个Key与哪个分片的区间匹配而后就分配到那个匹配的分片中保存匹配算法一般采用左前缀匹配方式。
<img src="https://static001.geekbang.org/resource/image/c0/d6/c0ca6200c5c48e7bdd55665ba7a000d6.jpg" alt="">
这个场景中我们要操作的是一张用户信息表T_USER它有四个字段分别是主键PID、客户名称Name、城市City和年龄Age。T_USER映射到HBase这样的键值系统后主键PID作为Key其他数据项构成Value。事实上HBase的存储格式还要更复杂些这里为了便于你理解做了简化。
<img src="https://static001.geekbang.org/resource/image/be/88/be44d5f54d968507bb1a22103427c188.jpg" alt="">
我们在“Ctiy”字段上建立索引索引与数据行是一对一的关系。索引存储也是KV形式Key是索引自身的主键IDValue是反序列化信息用于解析主键内容。索引主键由三部分构成分别是分片区间起始值、索引值和所指向数据行的主键PID。因为PID是唯一的索引主键在它的基础上增加了前缀所以也必然是唯一的。
<img src="https://static001.geekbang.org/resource/image/74/ec/7421287e27f875d8990f0f8f2492fdec.jpg" alt="">
整个查询的流程是这样的:
1. 客户端发起查询SQL。
1. 计算节点将SQL下推到各个存储节点。
1. 存储节点在每个Region上执行下推计算取Region的起始值加上查询条件中的索引值拼接在一起作为左前缀扫描索引数据行。
1. 根据索引扫描结果中的PID回表查询。
1. 存储节点将Region查询结果反馈给计算节点。
1. 计算节点汇总结果,反馈给客户端。
实现分区索引的难点在于如何始终保持索引与数据的同分布尤其是发生分片分裂时这是很多索引方案没有完美解决的问题。有些方案是在分裂后重建索引这样开销太大而且有短暂的不一致。我在自研软件Phaors时专门对这个处理做了设计优化这里和你分享一下。其实设计思想并不复杂那就是把同分布的索引和数据装入一个更小的组织单元(Bucket)而在分片分裂时要保持Bucket的完整性。这样一来因为Bucket的粒度足够小就不会影响分片分裂本身的目标也就是平衡分片的数据量和访问压力又能维持索引数据同分布。
### 全局索引
当然,分区索引也是有缺陷的。如果你期望的是一个唯一索引,那么分区索引就无法实现。因为唯一值的判定显然是一个全局性的约束,而所有全局性的约束,都无法在一个分片内完成。
唯一索引对应的方案就是全局索引。全局索引并不保持索引与数据同分布,于是就带来两个问题:
1. 读操作的通讯成本更高,计算节点要与存储节点做两轮通讯,第一次查询索引信息,第二次回表查询数据。
1. 写操作的延迟更长,因为任何情况下索引应该与数据保持一致,如果同分布,那么数据变更时可以通过本地事务保证,但在全局索引下就变成了一个分布式事务,代价当然更高了。
所以,在使用分布式数据库时,是否有必要建立全局索引,是一个非常谨慎的决定。
回到产品层面并不是所有分布式数据库都支持了分区索引和全局索引供用户选择比如TiDB的二级索引只支持全局索引。
## 小结
那么,今天的课程就到这里了,让我们梳理一下这一讲的要点。
1. 计算与存储分离是分布式数据库的指导思想,但容易造成数据的无效传输,降低整体性能。所以将计算逻辑推送到存储节点,是一个主要的优化方向,这个策略被称为“计算下推”。
1. 计算下推的逻辑非常简单但并不是每个产品都能轻松实现的。比如在TiDB中因为缓存了写操作需要使用更复杂的机制来对冲。
1. 索引是数据优化的重要手段,在分布式数据库下具体实现包括分区索引和全局索引。分区索引可以兼容计算下推的要求,将负载分散到各个存储节点。
1. 分区索引的难点在于分片分裂时如何保持索引与数据的同分布,可以通过引入更小的数据组织单元解决这个问题。
1. 分区索引无法实现唯一索引,只能用全局索引支持。但是全局索引会带来两个问题,一是查询索引与数据是两轮通讯,延迟更长,二是索引必须与数据同步更新,这就增加了一个分布式事务,造成数据更新的代价更大。
其实,计算与存储分离架构自诞生起,就伴随着对其性能的质疑,这也推动了各种分布式数据库进行计算下推的优化,在存储节点支持更多的计算函数。但是计算下推终归要受到运算逻辑的限制,并不是所有计算都可以无冗余地下推。分区索引是计算下推的一种特殊形式,但很多分布式数据库并没支持这个特性,而是用实现起来更简单的全局索引代替,也因此增加了读、写两个方面的性能开销。
<img src="https://static001.geekbang.org/resource/image/f5/22/f5dc3aac1399bf113f4a16cee43c4f22.jpg" alt="">
## 思考题
课程的最后,我们来看看今天的思考题。我们今天的关键词是计算下推,在课程的最后我提到一个“无冗余下推”的概念。其实,我想表达的意思是,有时候虽然可以下推但是计算量会被增大,这是因为运算逻辑无法等价地拆分成若干个子任务分散到存储节点上,那么增加出来的计算量就是一种冗余。我的问题就是,如果将“单表排序”操作进行下推,该如何设计一种有冗余的下推算法呢?
欢迎你在评论区留言和我一起讨论,我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对计算下推这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。
## 学习资料
David F. Bacon et al.: [**Spanner: Becoming a SQL System**](https://dl.acm.org/doi/10.1145/3035918.3056103)

View File

@@ -0,0 +1,198 @@
<audio id="audio" title="20 | 关联查询如何提升多表Join能力" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/80/f9/804f2a914209dfddc47920acb900faf9.mp3"></audio>
你好我是王磊你也可以叫我Ivan。
今天我们会继续学习查询场景中的处理技术。这一讲的关键词是“多表关联”也就是数据库中常见的Join操作。无论是单体数据库还是分布式数据库关联操作的语义始终没有变一些经典算法也保持了很好的延续性。
关联算法作为一个稍微细节点的设计,在不同数据库中是有差异的,我们还是秉承课程的整体思路,不陷入具体的设置参数、指令等内容。这样安排的依据是,只要你掌握关联算法的基本原理,就能快速掌握具体数据库的实现了。同时,有了这些原理作为基础,你也能更容易地掌握分布式数据库的优化思路。
那么,我们先来看看这些经典的关联算法吧。
## 三类关联算法
常见的关联算法有三大类分别是嵌套循环Nested Loop Join、排序归并Sort-Merge Join和哈希Hash Join
### 嵌套循环连接算法
所有的嵌套循环算法都由内外两个循环构成分别从两张表中顺序取数据。其中外层循环表称为外表Outer表内层循环表则称为内表Inner表。因为这个算法的过程是由遍历Outer表开始所以Outer表也称为驱动表。在最终得到的结果集中记录的排列顺序与Outer表的记录顺序是一致的。
根据在处理环节上的不同嵌套循环算法又可以细分为三种分别是Simple Nested-Loop JoinSNLJ、Block Nested-Loop JoinBNJ和Index Lookup JoinILJ
#### Simple Nested Loop Join
SNLJ是最简单粗暴的算法所以也称为Simple Nested-Loop Join。有些资料中会用NLJ指代SNLJ。
<img src="https://static001.geekbang.org/resource/image/eb/a6/eb3865308b203ab1e16b98e5831b76a6.jpg" alt="">
SNLJ的执行过程是这样的
1. 遍历 Outer 表,取一条记录 r1
1. 遍历 Inner 表,对于 Inner 表中的每条记录与r1做join操作并输出结果
1. 重复步骤 1和2直至遍历完 Outer 表中的所有数据,就得到了最后的结果集。
这样看SNLJ算法虽然简单但也很笨拙存在非常明显的性能问题。原因在于每次为了匹配Outer表的一条记录都要对Inner表做一次全表扫描操作。而全表扫描的磁盘I/O开销很大所以SNLJ的成本很高。
#### Block Nested-Loop Join
BNJ是对SNLJ的一种优化改进点就是减少Inner表的全表扫描次数。BNJ的变化主要在于步骤1读取Outer表时不再只取一条记录而是读取一个批次的x条记录加载到内存中。这样执行一次Inner表的全表扫描就可以比较x条记录。在MySQL中这个x对应一个叫做Join Buffer的设置项它直接影响了BNJ的执行效率。
<img src="https://static001.geekbang.org/resource/image/dd/32/dd9fd8424774265bf31741c2862cd832.jpg" alt="">
与SNLJ相比BNJ虽然在时间复杂度都是O(m*n)m和n分别是Outer表和Inner表的记录行数但磁盘I/O的开销却明显降低了所以效果优于SNLJ。
#### Index Lookup Join
你应该也注意到了SNLJ和BNJ都是直接在数据行上扫描并没有使用索引。所以这两种算法的磁盘I/O开销还是比较大的。
<img src="https://static001.geekbang.org/resource/image/df/93/dfd3484bb9f27685a8e36febb6322693.jpg" alt="">
Index Lookup JoinILJ就是在BNJ的基础上使用了索引算法执行过程是这样的
1. 遍历 Outer 表,取一个批次的记录 ri
1. 通过连接键Join Key和ri可以确定对Inner表索引的扫描范围再通过索引得到对应的若干条数据记录记为sj
1. 将ri的每一条记录与sj的每一条记录做Join操作并输出结果
1. 重复前三步,直到遍历完 Outer 表中的所有数据,就得到了最后结果集。
看到这里ILJ的主要优化点也很明显了就是对Inner表进行索引扫描。那么你可能会问了为什么不让Outer表也做索引扫描呢
我认为Outer表当然也可以走索引。但是BNJ在Inner表上要做多次全表扫描成本最高所以Inner表上使用索引的效果最显著也就成为了算法的重点。而对Outer表来说因为扫描结果集要放入内存中暂存这意味着它的记录数是比较有限的索引带来的效果也就没有Inner表那么显著所以在定义中没有强调这部分。
关联算法的定义是为了让我们专注其中的重点而不是僵化地去理解它。比如我们会在有些教材上看到对ILJ的定义就是直接在SNLJ增加索引并不是在BNJ上拓展。而要真正在工程中应用关联算法都要结合具体场景进一步优化。
### 排序归并连接算法
排序归并算法就是Sort-Merge JoinSMJ也被称为Merge Join。SMJ可以分为排序和归并两个阶段
1. 第一阶段是排序就是对Outer表和Inner表进行排序排序的依据就是每条记录在连接键上的数值。
1. 第二阶段就是归并因为两张表已经按照同样的顺序排列所以Outer表和Inner表各一次循环遍历就能完成比对工作了。
<img src="https://static001.geekbang.org/resource/image/2d/7c/2dc317241f1294607b323b4fb43e567c.jpg" alt="">
简单来说SMJ就是先要把两个数据集合变成两个数据序列也就是有序的数据单元然后再做循环比对。这样算下来它的计算成本是两次排序再加两次循环。你可能会觉得奇怪这成本是不是比NLJ还要高呀
是的。所以选择SMJ是有前提的而这个前提就是表的记录本身就是有序的否则就不划算了。我们知道索引是天然有序的如果表的连接键刚好是索引列那么SMJ就是三种嵌套循环算法中成本最低的它的时间复杂度只有O(m+n)。
### 哈希连接算法
哈希连接的基本思想是取关联表的记录计算连接键上数据项的哈希值再根据哈希值映射为若干组然后分组进行匹配。这个算法体现了一种分治思想。具体来说常见的哈希连接算法有三种分别是Simple Hash Join、Grace Hash Join和Hybrid Hash Join。
#### Simple Hash Join
Simple Hash Join也称为经典哈希连接Classic Hash Join它的执行过程包括建立阶段Build Phase和探测阶段Probe Phase
<img src="https://static001.geekbang.org/resource/image/9a/56/9a98c6fc5552405ba40bf4ce74804a56.jpg" alt="">
1. 建立阶段
选择一张表作为Inner表对其中每条记录上的连接属性Join Attribute使用哈希函数得到哈希值从而建立一个哈希表。在计算逻辑允许的情况下建立阶段选择数据量较小的表作为Inner表以减少生成哈希表的时间和空间开销。
1. 探测阶段
另一个表作为Outer表扫描它的每一行并计算连接属性的哈希值与建立阶段生成的哈希表进行对比。当然哈希值相等不代表连接属性相等还要再做一次判断返回最终满足条件的记录。
通过Simple Hash Join这个命名我们就能知道它也是一个简单的算法。这里的简单是说它做了非常理想化的假设也就是Inner表形成的哈希表小到能够放入内存中。可实际上即使对于单体数据库来说这个哈希表也是有可能超过内存容量的。
哈希表无法全部放入内存怎么办呢这时就要使用Grace HashJoin算法了。
#### Grace Hash Join
GHJ算法与SHJ的不同之处在于GHJ正视了哈希表大于内存这个问题将哈希表分块缓存在磁盘上。GHJ中的Grace并不是指某项技术而是首个采用该算法的数据库的名字。
<img src="https://static001.geekbang.org/resource/image/da/5e/daa0f237fd7ffa739f1f24fedfb8d15e.jpg" alt="">
GHJ算法的执行过程也是分为两个阶段。
第一阶段Inner表的记录会根据哈希值分成若干个块Bucket写入磁盘而且每个Bucket必须小于内存容量。Outer表也按照同样的方法被分为若干Bucket写入磁盘但它的大小并不受到内存容量限制。
第二阶段和SHJ类似先将Inner表的Bucket加载到内存再读取Outer表对应Bucket的记录进行匹配所有Inner表和Outer表的Bucket都读取完毕后就得到了最终的结果集。
#### Hybrid Hash Join
Hybrid Hash Join也就是混合哈希字面上是指Simple Hash Join和Grace Hash Join的混合。实际上它主要是针对Grace Hash Join的优化在内存够用的情况下可以将Inner表的第一个Bucket和Outer表的第一个Bucket都保留在内存中这样建立阶段一结束就可以进行匹配节省了先写入磁盘再读取的两次I/O操作。
总体来说,哈希连接的核心思想和排序归并很相似,都是对内外表的记录分别只做一次循环。哈希连接算法不仅能够处理大小表关联,对提升大表之间关联的效率也有明显效果,但限制条件就是适用于等值连接。
## 分布式数据库实现
学习了基本的关联算法后我们聚焦到分布式数据库的范畴内继续讨论。其实在学习GHJ的过程中你是不是已经嗅到一点分布式架构的味道了GHJ就是将一个大任务拆解成若干子任务并执行的过程这些子任务本身是独立的如果调度到不同的节点上运行那这就是一个并行框架。由此我们可以说分布式架构下关联算法的优化和并行框架密切相关。
### 并行框架
在[第19讲](https://time.geekbang.org/column/article/288220)中我们提到了计算下推,换个角度看,其实它就是一种并行框架,不过是最简单的并行框架。因为在很多情况下,计算任务的执行节点和对应数据的存储节点并不是完全对应的,也就没办法只依据数据分布就拆分出子任务。
那么要想在数据交错分布的情况下合理地划分和调度子任务就需要引入更复杂的计算引擎。这种并行执行引擎在OLAP数据库中比较常见通常称为MPPMassively Parallel Processing。很明显MPP已经超出了OLTP计算引擎的范畴并不是所有分布式数据库都支持的。
比如我们前面介绍过的TiDB在最初的TiDB + TiKV的体系中就没有MPP引擎。TiDB的存储节点之间是不能通讯的除了Raft协议这就意味着如果子任务之间有数据传输就必须以计算节点为通道。这样计算节点很容易成为瓶颈同时增加了网络传输负载。由此可见必须经过计算节点这个约束是生成高效并行计划的一个障碍。后来TiDB也没有打破这个约束而是通过引入Spark来处理复杂的OLAP计算任务这就是TiSpark组件。
但并不是所有分布式数据库都采用引入外部组件的方式比如OceanBase就在原有设计中拓展了并行执行框架实现了更复杂的任务调度在存储节点间也可以直接进行数据交换。
<img src="https://static001.geekbang.org/resource/image/69/fa/690bd7ac4133edebb43ac6a3a1863bfa.jpg" alt="">
OceanBase大致也是P2P架构每个Observer部署了相同的服务在运行过程中动态的承担不同角色。图中一个Observer节点承担了入口处的查询协调器其他节点作为子查询协调器上面的工作线程是真正的任务执行者。
理解了并行框架的必要性,我们再回到多表关联这个具体场景。多表关联的复杂度,主要看参与表的数据量。其中,小表之间的关联都比较简单,所以我们接下来主要关注小表与大表关联和大表之间的关联。
### 大小表关联(复制表)
大小表关联时,可以把小表复制到相关存储节点,这样全局关联就被转换为一系列的本地关联,再汇总起来就得到了最终结果。这种算法的具体实现方式有两种。
1. 静态的方式
静态的方式其实就是在创建表的时候直接使用关键字将表声明为复制表这样每个节点上都会保留一份数据副本。当它与大表关联时计算节点就可以将关联操作下推到每个存储节点进行。很多分布式数据库比如TBase、TDSQL等都支持定义复制表。
1. 动态方式
动态方式也称为“小表广播”,这种方式不需要人工预先定义,而是在关联发生时,系统自行处理。这就是说,当关联的某张表足够小时,在整个集群中分发不会带来太大的网络开销,系统就将其即时地复制到相关的数据节点上,实现本地关联。
下面这张图体现了小表广播的过程。
<img src="https://static001.geekbang.org/resource/image/ef/24/efcb024e2413077018958bdbb1d66424.jpg" alt="">
动态方式和并行执行引擎有直接的联系例如Spark并行执行引擎中的Broadcast Hash Join就是先采用动态广播方式而后在每个节点上再执行哈希连接。
当然,这里的 “复制”和“广播”只表达了自然语义不能作为静态还是动态的判断标准。比如TDSQL中的“广播表”TBase中的“复制表”说的都是指静态方式。
### 大表关联(重分布)
复制表解决了大小表关联的问题,还剩下最棘手的大表间关联,它的解决方案通常就是重分布。
我们直接看一个例子现在要对A、B两张大表进行关联执行下面的SQL
```
select A.C1,B.C2 from A,B where A.C1=B.C1;
```
这个SQL可能会引发两种不同的重分布操作。
第一种如果C1是A表的分区键但不是B表的分区键则B表按照C1做重分布推送到A的各个分片上实现本地关联。
第二种如果两张表的分区键都不是C1则两张表都要按照C1做重分布而后在多个节点上再做本地关联。当然这种情况的执行代价就比较高了。
这个基于重分布的关联过程其实和MapReduce、Spark等并行计算引擎的思路是一样的基本等同于它们的Shuffle操作。我们可以用Spark的Shuffle Hash Join来对比学习一下。
<li>
shuffle阶段分别将两个表按照连接键进行分区将相同连接键的记录重分布到同一节点数据就会被分配到尽量多的节点上增大并行度。
</li>
<li>
hash join阶段每个分区节点上的数据单独执行单机hash join算法。
</li>
## 小结
那么,今天的课程就到这里了,让我们梳理一下这一讲的要点。
1. 关联是数据库中比较复杂的操作相关算法主要分为三类分别是嵌套循环、排序归并和哈希。嵌套循环是比较基础的排序算法大多数数据库都会支持又细分为SNLJ、BNLJ、ILJ三种。排序归并算法仅适用于关联数据有序的情况比如连接键是关联表的索引列时在这个前提下排序归并算法的成本低于嵌套循环。
1. 哈希算法适用于大小表关联和大表关联的场景并不是OLTP数据库的标配。在海量数据下哈希算法比嵌套循环和排序归并这两种算法的效果更好所以在OLAP数据库和大数据技术产品中比较常见。常用的哈希算法包括SHJ、GHJ和HHJ。
1. 分布式数据库下关联算法的优化依赖于并行框架MPP而并行框架更多地出现在OLAP数据库中不是分布式数据库的标配。
1. 大小表关联的方法是复制表有静态和动态两种实现方式。静态方式是预先将小表存储在所有节点动态方式是在关联发生时决定是否广播小表。大表间关联的方法是重分布。当A和B两张表关联时如果A表的分区键与连接键相同只需要对B表做单表重分布否则两表都需要重分布代价更大。
关联计算是查询场景中比较复杂的操作即使面向OLTP场景的传统单体数据库也没有完善的处理比如MySQL直到8.0版本才支持Hash Join。而分布式数据库也由于自身定位不同对关联算法支持程度存在差异。总的来说越倾向于支持OLAP场景对关联算法的支持度也就越高。
<img src="https://static001.geekbang.org/resource/image/ca/a7/cab2cf6bc91f261b273d614336086fa7.jpg" alt="">
## 思考题
课程的最后我们来看看今天的思考题。我在介绍哈希算法时说“在计算逻辑允许的情况下建立阶段选择数据量较小的表作为Inner表”我的问题就是在什么情况下系统无法根据数据量决定Inner表呢
欢迎你在评论区留言和我一起讨论,我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对关联查询这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。

View File

@@ -0,0 +1,250 @@
<audio id="audio" title="21 | 查询执行引擎:如何让聚合计算加速?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/a8/9fdf120dd92293a24028805decc11ba8.mp3"></audio>
你好,我是王磊。
在19、20两讲中我已经介绍了计算引擎在海量数据查询下的一些优化策略包括计算下推和更复杂的并行执行框架。这些策略对应了从查询请求输入到查询计划这个阶段的工作。那么整体查询任务的下一个阶段就是查询计划的执行承担这部分工作的组件一般称为查询执行引擎。
单从架构层面看,查询执行引擎与分布式架构无关,但是由于分布式数据库要面对海量数据,所以对提升查询性能相比单体数据库有更强烈的诉求,更关注这部分的优化。
你是不是碰到过这样的情况,对宽口径数据做聚合计算时,系统要等待很长时间才能给出结果。那是因为这种情况涉及大量数据参与,常常会碰到查询执行引擎的短板。你肯定想知道,有优化办法吗?
当然是有的。查询执行引擎是否高效与其采用的模型有直接关系,模型主要有三种:火山模型、向量化模型和代码生成。你碰到的情况很可能是没用对模型。
## 火山模型
火山模型Volcano Model也称为迭代模型Iterator Model是最著名的查询执行模型早在1990年就在论文“[Volcano, an Extensible and Parallel Query Evaluation System](https://core.ac.uk/download/pdf/54846488.pdf)”中被提出。主流的OLTP数据库Oracle、MySQL都采用了这种模型。
在火山模型中一个查询计划会被分解为多个代数运算符Operator。每个Operator就是一个迭代器都要实现一个next()接口,通常包括三个步骤:
<li>
调用子节点Operator的next()接口获取一个元组Tuple
</li>
<li>
对元组执行Operator特定的处理
</li>
<li>
返回处理后的元组。
</li>
通过火山模型查询执行引擎可以优雅地将任意Operator组装在一起而不需要考虑每个Operator的具体处理逻辑。查询执行时会由查询树自顶向下嵌套调用next()接口数据则自底向上地被拉取处理。所以这种处理方式也称为拉取执行模型Pull Based
为了更好地理解火山模型的拉取执行过程让我们来看一个聚合计算的例子它来自Databricks的[一篇文章](https://databricks.com/blog/2016/05/23/apache-spark-as-a-compiler-joining-a-billion-rows-per-second-on-a-laptop.html)Sameer Agarwal et al. (2016))。
```
select count(*) from store_sales where ss_item_sk = 1000;
```
<img src="https://static001.geekbang.org/resource/image/0e/14/0e5069dd9e298cab40c1f07420b23314.jpg" alt="">
开始从扫描运算符TableScan获取数据通过过滤运算符Filter开始推动元组的处理。然后过滤运算符传递符合条件的元组到聚合运算符Aggregate。
你可能对“元组”这个词有点陌生其实它大致就是指数据记录Record因为讨论算法时学术文献中普遍会使用元组这个词为了让你更好地与其他资料衔接起来我们这里就沿用“元组”这个词。
火山模型的优点是处理逻辑清晰每个Operator 只要关心自己的处理逻辑即可,耦合性低。但是它的缺点也非常明显,主要是两点:
1. 虚函数调用次数过多造成CPU资源的浪费。
1. 数据以行为单位进行处理不利于发挥现代CPU的特性。
### 问题分析
我猜你可能会问,什么是虚函数呢?
在火山模型中处理一个元组最少需要调用一次next()函数这个next()就是虚函数。这些函数的调用是由编译器通过虚函数调度实现的虽然虚函数调度是现代计算机体系结构中重点优化部分但它仍然需要消耗很多CPU指令所以相当慢。
第二个缺点是没有发挥现代CPU的特性那具体又是怎么回事
#### CPU寄存器和内存
在火山模型中每次一个算子给另外一个算子传递元组的时候都需要将这个元组存放在内存中在18讲我们已经介绍过以行为组织单位很容易带来CPU缓存失效。
#### 循环展开Loop unrolling
当运行简单的循环时现代编译器和CPU是非常高效的。编译器会自动展开简单的循环甚至在每个CPU指令中产生单指令多数据流SIMD指令来处理多个元组。
#### 单指令多数据流SIMD
SIMD 指令可以在同一CPU时钟周期内对同列的不同数据执行相同的指令。这些数据会加载到SIMD 寄存器中。
Intel 编译器配置了 AVX-512高级矢量扩展指令集SIMD 寄存器达到 512 比特,就是说可以并行运算 16 个 4 字节的整数。
在过去大概20年的时间里火山模型都运行得很好主要是因为这一时期执行过程的瓶颈是磁盘I/O。而现代数据库大量使用内存后读取效率大幅提升CPU就成了新的瓶颈。因此现在对火山模型的所有优化和改进都是围绕着提升CPU运行效率展开的。
### 改良方法(运算符融合)
要对火山模型进行优化一个最简单的方法就是减少执行过程中Operator的函数调用。比如通常来说Project和Filter都是常见的Operator在很多查询计划中都会出现。OceanBase1.0就将两个Operator融合到了其它的Operator中。这样做有两个好处
1. 降低了整个查询计划中Operator的数量也就简化了Operator间的嵌套调用关系最终减少了虚函数调用次数。
1. 单个Operator的处理逻辑更集中增强了代码局部性能力更容易发挥CPU的分支预测能力。
#### 分支预测能力
你可能还不了解什么是分支预测能力,我这里简单解释一下。
分支预测是指CPU执行跳转指令时的一种优化技术。当出现程序分支时CPU需要执行跳转指令在跳转的目的地址之前无法确定下一条指令就只能让流水线等待这就降低了CPU效率。为了提高效率设计者在CPU中引入了一组寄存器用来专门记录最近几次某个地址的跳转指令。
这样,当下次执行到这个跳转指令时,就可以直接取出上次保存的指令,放入流水线。等到真正获取到指令时,如果证明取错了则推翻当前流水线中的指令,执行真正的指令。
这样即使出现分支也能保持较好的处理效率,但是寄存器的大小总是有限的,所以总的来说还是要控制程序分支,分支越少流水线效率就越高。
刚刚说的运算符融合是一种针对性的优化方法,优点是实现简便而且快速见效,但进一步的提升空间很有限。
因此学术界还有一些更积极的改进思路主要是两种。一种是优化现有的迭代模型每次返回一批数据而不是一个元组这就是向量化模型Vectorization另一种是从根本上消除迭代计算的性能损耗这就是代码生成Code Generation
我们先来看看向量化模型。
## 向量化TiDB&amp;CockroachDB
向量化模型最早提出是在[MonerDB-X100Vectorwise](http://cs.brown.edu/courses/cs227/archives/2008/Papers/ColumnStores/MonetDB.pdf)系统,已成为现代硬件条件下广泛使用的两种高效查询引擎之一。
向量化模型与火山模型的最大差异就是其中的Operator是向量化运算符是基于列来重写查询处理算法的。所以简单来说向量化模型是由一系列支持向量化运算的Operator组成的执行模型。
我们来看一下向量化模型怎么处理聚合计算。
<img src="https://static001.geekbang.org/resource/image/69/91/69ca42d16f6888fe7f64d48867235391.jpg" alt="">
通过这个执行过程可以发现向量化模型依然采用了拉取式模型。它和火山模型的唯一区别就是Operator的next()函数每次返回的是一个向量块,而不是一个元组。向量块是访问数据的基本单元,由固定的一组向量组成,这些向量和列 / 字段有一一对应的关系。
向量处理背后的主要思想是,按列组织数据和计算,充分利用 CPU把从多列到元组的转化推迟到较晚的时候执行。这种方法在不同的操作符间平摊了函数调用的开销。
向量化模型首先在OLAP数据库中采用与列式存储搭配使用可以获得更好的效果例如ClickHouse。
我们课程里定义的分布式数据库都是面向OLTP场景的所以不能直接使用列式存储但是可以采用折中的方式来实现向量化模型也就是在底层的Operator中完成多行到向量块的转化上层的Operator都是以向量块作为输入。这样改造后即使是与行式存储结合仍然能够显著提升性能。在TiDB和CockroachDB的实践中性能提升可以达到数倍甚至数十倍。
### 向量化运算符示例
我们以Hash Join为例来看下向量化模型的执行情况。
在[第20讲](https://time.geekbang.org/column/article/289299)我们已经介绍过Hash Join的执行逻辑就是两表关联时以Inner表的数据构建Hash表然后以Outer表中的每行记录分别去Hash表查找。
```
Class HashJoin
Primitives probeHash_, compareKeys_, bulidGather_;
...
int HashJoin::next()
//消费构建侧的数据构造Hash表代码省略
...
//获取探测侧的元组
int n = probe-&gt;next()
//计算Hash值
vec&lt;int&gt; hashes = probeHash_.eval(n)
//找到Hash匹配的候选元组
vec&lt;Entry*&gt; candidates = ht.findCandidates(hashes)
vec&lt;Entry*, int&gt; matches = {}
//检测是否匹配
while(candidates.size() &gt; 0)
vec&lt;bool&gt; isEqual = compareKeys_.eval(n, candidates)
hits, candidates = extractHits(isEqual, candidates)
matches += hits
//从Hash表收集数据为下个Operator缓存
buildGather_.eval(matches)
return matches.size()
```
我们可以看到这段处理逻辑中的变量都是Vector还有事先定义一些专门处理Vector的元语Primitives
总的来说,向量化执行模型对火山模型做了针对性优化,在以下几方面有明显改善:
1. 减少虚函数调用数量,提高了分支预测准确性;
1. 以向量块为单位处理数据利用CPU的数据预取特性提高了CPU缓存命中率
1. 多行并发处理发挥了CPU的并发执行和SIMD特性。
## 代码生成OceanBase
与向量化模型并列的另一种高效查询执行引擎就是“代码生成”这个名字听上去可能有点奇怪但确实没有更好翻译。代码生成的全称是以数据为中心的代码生成Data-Centric Code Generation也被称为编译执行Compilation
在解释“代码生成”前我们先来分析一下手写代码和通用性代码的执行效率问题。我们还是继续使用讲火山模型时提到的例子将其中Filter算子的实现逻辑表述如下
```
class Filter(child: Operator, predicate: (Row =&gt; Boolean))
extends Operator {
def next(): Row = {
var current = child.next()
while (current == null || predicate(current)) {
current = child.next()
}
return current
}
}
```
如果专门对这个操作编写代码(手写代码),那么大致是下面这样:
```
var count = 0
for (ss_item_sk in store_sales) {
if (ss_item_sk == 1000) {
count += 1
}
}
```
在两种执行方式中手写代码显然没有通用性但Databricks的工程师对比了两者的执行效率测试显示手工代码的吞吐能力要明显优于火山模型。
<img src="https://static001.geekbang.org/resource/image/d9/31/d94714b9229820f4114c2ca74c861031.jpg" alt="" title="引自Sameer Agarwal et al. (2016)">
手工编写代码的执行效率之所以高就是因为它的循环次数要远远小于火山模型。而代码生成就是按照一定的策略通过即时编译JIT生成代码可以达到类似手写代码的效果。
此外代码生成是一个推送执行模型Push Based这也有助于解决火山模型嵌套调用虚函数过多的问题。与拉取模型相反推送模型自底向上地执行执行逻辑的起点直接就在最底层Operator其处理完一个元组之后再传给上层Operator继续处理。
Hyper是一个深入使用代码生成技术的数据库[Hyper实现的论文](https://www.vldb.org/pvldb/vol4/p539-neumann.pdf)Thomas Neumann (2011))中有一个例子,我这里引用过来帮助你理解它的执行过程。
要执行的查询语句是这样的:
```
select * from R1,R3,
(select R2.z,count(*)
from R2
where R2.y=3
group by R2.z) R2
where R1.x=7 and R1.a=R3.b and R2.z=R3.c
```
SQL解析后会得到一棵查询树就是下图的左侧的样子我们可以找到R1、R2和R3对应的是三个分支。
<img src="https://static001.geekbang.org/resource/image/b2/b1/b27e0ae0f2fb329259ed9c2a8ea9afb1.png" alt="" title="引自Thomas Neumann (2011)">
要获得最优的CPU执行效率就要使数据尽量不离开CPU的寄存器这样就可以在一个CPU流水线Pipeline上完成数据的处理。但是查询计划中的Join操作要生成Hash表加载到内存中这个动作使数据必须离开寄存器称为物化Materilaize。所以整个执行过程会被物化操作分隔为4个Pipeline。而像Join这种会导致物化操作的Operator在论文称为Pipeline-breaker。
通过即时编译生成代码得到对应Piepline的四个代码段可以表示为下面的伪码
<img src="https://static001.geekbang.org/resource/image/3b/a9/3be2c569a657553e752fc224173e41a9.png" alt="" title="引自Thomas Neumann (2011)">
代码生成消除了火山模型中的大量虚函数调用让大部分指令可以直接从寄存器取数极大地提高了CPU的执行效率。
代码生成的基本逻辑清楚了但它的工程实现还是挺复杂的所以会有不同粒度的划分。比如如果是整个查询计划的粒度就会称为整体代码生成Whole-Stage Code Generation这个难度最大相对容易些的是代码生成应用于表达式求值Expression Evaluation也称为表达式代码生成。在OceanBase 2.0版本中就实现了表达式代码生成。
如果你想再深入了解代码生成的相关技术,就需要有更全面的编译器方面的知识做基础,比如你可以学习宫文学老师的编译原理课程。
## 小结
那么,今天的课程就到这里了,让我们梳理一下这一讲的要点。
1. 火山模型自1990年提出后是长期流行的查询执行模型至今仍在Oracle、MySQL中使用。但面对海量数据时火山模型有CPU使用率低的问题性能有待提升。
1. 火山模型仍有一些优化空间,比如运算符融合,可以适度减少虚函数调用,但提升空间有限。学术界提出的两种优化方案是向量化和代码生成。
1. 简单来说向量化模型就是一系列向量化运算符组成的执行模型。向量化模型首先在OLAP数据库和大数据领域广泛使用配合列式存储取得很好的效果。虽然OLTP数据库的场景不适于列式存储但将其与行式存储结合也取得了明显的性能提升。
1. 代码生成是现代编译器与CPU结合的产物也可以大幅提升查询执行效率。代码生成的基础逻辑是针对性的代码在执行效率上必然优于通用运算符嵌套。代码生成根据算法会被划分成多个在Pipeline执行的单元提升CPU效率。代码生成有不同的粒度包括整体代码生成和表达式代码生成粒度越大实现难度越大。
向量化和代码生成是两种高效查询模型并没有最先出现在分布式数据库领域反而是在OLAP数据库和大数据计算领域得到了更广泛的实践。ClickHouse和Spark都同时混用了代码生成和向量化模型这两项技术。目前TiDB和CockroachDB都应用向量化模型查询性能得到了一个数量级的提升。OceanBase中则应用了代码生成技术优化了表达式运算。
<img src="https://static001.geekbang.org/resource/image/62/f4/621203a4ce7332b835915e97135d13f4.jpg" alt="">
## 思考题
课程的最后我们来看看今天的思考题。这一讲我们主要讨论了查询执行引擎的优化核心是如何最大程度发挥现代CPU的特性。其实这也是基础软件演进中一个普遍规律每当硬件技术取得突破后就会引发软件的革新。那么我的问题就是你了解的基础软件中哪些产品分享了硬件技术变革的红利呢
欢迎你在评论区留言和我一起讨论,我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对查询执行引擎这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。
## 学习资料
Goetz Graefe: [**Volcano, an Extensible and Parallel Query Evaluation System**](https://core.ac.uk/download/pdf/54846488.pdf)
Peter Boncz et al.: [**MonetDB/X100: Hyper-Pipelining Query Execution**](http://cs.brown.edu/courses/cs227/archives/2008/Papers/ColumnStores/MonetDB.pdf)
Sameer Agarwal et al.: [**Apache Spark as a Compiler: Joining a Billion Rows per Second on a Laptop**](https://databricks.com/blog/2016/05/23/apache-spark-as-a-compiler-joining-a-billion-rows-per-second-on-a-laptop.html)
Thomas Neumann: [**Efficiently Compiling Efficient Query Plans for Modern Hardware**](https://www.vldb.org/pvldb/vol4/p539-neumann.pdf)

View File

@@ -0,0 +1,243 @@
<audio id="audio" title="22RUM猜想想要读写快还是存储省又是三选二" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ee/86/ee900c73fb480ed47c7f8247d195a386.mp3"></audio>
你好,我是王磊。
从第18讲我们开始介绍查询过程中全部重要节点的相关技术从并行框架到查询执行引擎再从关联运算符到行式和列式存储。今天这一讲我们面临最后的一个步骤直接和磁盘打交道实现最终的数据存储这就是存储引擎。
## RUM猜想
说到数据存储,我相信很多人都有一种直觉,那就是读写两种操作的优化在很多时候是互斥的。
我们可以想一下,数据以什么形式存储,可以实现最快的写入速度?答案肯定是按照顺序写入,每次新增数据都追加在文件尾部,因为这样物理磁盘的磁头移动距离最小。
但这种方式对于读取显然是不友好的,因为没有任何有意义的数据结构做辅助,读操作必须从头到尾扫描文件。反过来,如果要实现更高效的读取,就要设计更复杂的数据结构,那么写入的速度当然就降低了,同时在存储空间上也会有额外的要求。
2016年的一篇论文将我们这种朴素的理解提升到理论层面这就是RUM猜想。RUM猜想来自论文“[Designing Access Methods: The RUM Conjecture](https://stratos.seas.harvard.edu/files/stratos/files/rum.pdf)”Manos Athanassoulis et al.(2016)同时被SIGMOD和EDBT收录。它说的是对任何数据结构来说在Read Overhead、Update Overhead 和 Memory or Storage Overhead存储 中,同时优化两项时,需要以另一项劣化作为代价。论文用一幅图展示了常见数据结构在这三个优化方向中的位置,这里我摘录下来便于你理解。
<img src="https://static001.geekbang.org/resource/image/ea/15/eafcd1bd0c76c600f576b4a364bf7f15.png" alt="" title="引自Manos Athanassoulis et al.(2016)">
在这张图中我们可以看到两个非常熟悉的数据结构B-Tree和LSM它们被用于分布式数据库的存储引擎中前者实际是B+TreeB-Tree的变体主要用于PGXC后者则主要用于NewSQL。这是不是代表PGXC就是要针对读操作而NewSQL是针对写操作呢并没有这么简单还是要具体分析数据结构的使用过程。
下面我们先从B+Tree说起它也是单体数据库广泛使用的数据结构。
## 存储结构
### B+Tree
B+Tree是对读操作优化的存储结构能够支持高效的范围扫描叶节点之间保留链接并且按主键有序排列扫描时避免了耗时的遍历树操作。
我们用一个例子来演示下MySQL数据库的B+Tree写操作过程。
下面这张图中展示了一棵高度为2的B+Tree数据存储在5个页表中每页可存放4条记录。为了方便你理解我略去了叶子节点指向数据的指针以及叶子节点之间的顺序指针。
<img src="https://static001.geekbang.org/resource/image/6b/c8/6bd6149970b0b760bf61a80fea54ffc8.jpg" alt="">
B+Tree由内节点InterNode和叶节点LeafNode两类节点构成前者仅包含索引信息后者则携带了指向数据的指针。当插入一个索引值为70的记录由于对应页表的记录已满需要对B+Tree重新排列变更其父节点所在页表的记录并调整相邻页表的记录。完成重新分布后的效果如下
<img src="https://static001.geekbang.org/resource/image/60/46/6069eba71bb31754e85940d34526ae46.jpg" alt="">
在这个写入过程中存在两个问题:
1. 写放大
本来我们仅需要一条写入记录黄色标注实际上更新了3个页表中的7条索引记录额外的6条记录绿色标注是为了维护B+Tree结构产生的写放大。
为了度量写放大的程度相关研究中引入了写放大系数Write Amplification FactorWAF这个指标就是指实际写入磁盘的数据量和应用程序要求写入数据量之比。对于空间放大有类似的度量单位也就是空间放大系数Space Amplification Factor, SAF
我们这个例子中的WAF是7。
1. 存储不连续
虽然新增叶节点会加入到原有叶节点构成的有序链表中,整体在逻辑上是连续的,但是在磁盘存储上,新增页表申请的存储空间与原有页表很可能是不相邻的。这样,在后续包含新增叶节点的查询中,将会出现多段连续读取,磁盘寻址的时间将会增加。
也就是说虽然B+Tree结构是为读取做了优化但如果进行大量随机写还是会造成存储的碎片化从而导致写放大和读放大。
#### 填充因子
填充因子Factor Fill是一种常见的优化方法它的原理就是在页表中预留一些空间这样不会因为少量的数据写入造成树结构的大幅变动。但填充因子的设置也很难拿捏过大则无法解决写放大问题过小会造成页表数量膨胀增大对磁盘的扫描范围降低查询性能。
相对于PGXCNewSQL风格分布式数据库的底层存储引擎则主要采用LSM-Tree。
### LSM-Tree
LSM-TreeLog Structured-Merge Tree由Patrick ONeil在1996年的[同名论文](http://db.cs.berkeley.edu/cs286/papers/lsm-acta1996.pdf)中首先提出。而后Google在[Bigtable](https://www2.cs.duke.edu/courses/cps399.28/current/papers/osdi06-ChangDeanEtAl-bigtable.pdf)Fay Chang et al.(2008))中使用了这个模型,它的大致处理过程如下图所示:
<img src="https://static001.geekbang.org/resource/image/21/39/21dc63cef862c71f5614445cb6477839.png" alt="" title="引自Fay Chang et al.(2008)">
系统接收到写操作后会记录日志Tablet Log并将数据写入内存Memtable这时写操作就可以返回成功了。而在系统接收到读操作时会在内存和磁盘文件中查找对应的数据。
你肯定会说不对呀,如果写入的数据都在内存中,磁盘文件又是哪来的呢?
的确这中间还少了重要的一步就是数据落盘的过程这也是LSM-Tree最有特点的设计。其实LSM是分成三步完成了数据的落盘。
<img src="https://static001.geekbang.org/resource/image/71/8e/71ae4696ce4d5cbbfedfc6f5d9f0518e.jpg" alt="">
1. 第一步已经说过了就是写入Memtable同时记录Tablet Log
1. 当Memtable的数据达到一定阈值后系统会把其冻结并将其中的数据顺序写入磁盘上的有序文件Sorted String TableSSTable这个操作被称为Flush当然执行这个动作的同时系统会同步创建一个新的Memtable处理写入请求。
1. 根据第二步的处理逻辑Memtable会周期性地产生SSTable。当满足一定的规则时这些SSTable会被合并为一个大的SSTable。这个操作称为Compact。
与B+Tree的最大不同是LSM将随机写转换为顺序写这样提升了写入性能。另外Flush操作不会像B+Tree那样产生写放大。
我猜你会说不对呀Flush是没有写放大但还有Compact呢
说的没错真正的写放大就发生在Compact这个动作上。Compact有两个关键点一是选择什么时候执行二是要将哪些SSTable合并成一个SSTable。这两点加起来称为“合并策略”。
我们刚刚在例子中描述的就是一种合并策略称为Size-Tiered Compact Strategy简称Tiered。BigTable和HBase都采用了Tiered策略。它的基本原理是每当某个尺寸的SSTable数量达到既定个数时将所有SSTable合并成一个大的SSTable。这种策略的优点是比较直观实现简单但是缺点也很突出。下面我们从RUM的三个角度来分析下
1. 读放大
执行读操作时由于单个SSTable内部是按照Key顺序排列的那么查找方法的时间复杂度就是O(logN)。因为SSTable文件是按照时间间隔产生的在Key的范围上就会存在交叉所以每次读操作都要遍历所有SSTable。如果有M个SSTable整体时间复杂度就是O(M**logN)。执行Compact后时间复杂度降低为O(log(M**N))。在只有一个SSTable时读操作没有放大。
1. 写放大
Compact会降低读放大但却带来更多的写放大和空间放大。其实LSM只是推迟了写放大短时间内可以承载大量并发写入但如果是持续写入则需要一部分I/O开销用于处理Compact。
你可能听说到过关于B+Tree和LSM的一个争论就是到底谁的写放大更严重。如果是采用Tiered策略LSM的写放大比B+Tree还严重。此外Compact是一个很重的操作占用大量的磁盘I/O会影响同时进行的读操作。
1. 空间放大
从空间放大的角度看Tiered策略需要有两倍于数据大小的空间分别存储合并前的多个SSTable和合并后的一个SSTable所以SAF是2而B+Tree的SAF是1.33。
看到这你可能会觉得奇怪LSM好像也不是太优秀呀。只有短时间内写入速度快和阶段性的控制读放大这两个优势在写放大和空间放大上都做得不好而且还有Compact这种耗费I/O的重量级操作。那LSM为什么还这么流行呢?
这是因为LSM还有另一种合并策略Leveled Compact Strategy简称Leveled策略。
#### Leveled Compact Strategy
Tiered策略之所以有严重的写放大和空间放大问题主要是因为每次Compact需要全量数据参与开销自然就很大。那么如果每次只处理小部分SSTable文件就可以改善这个问题了。
Leveled就是这个思路它的设计核心就是将数据分成一系列Key互不重叠且固定大小的SSTable文件并分层Level管理。同时系统记录每个SSTable文件存储的Key的范围。Leveled策略最先在LevelDB中使用也因此得名。后来从LevelDB上发展起来的RocksDB也采用这个策略。
接下来,我们详细说明一下这个处理过程。
1. 第一步处理Leveled和Tiered是一样的。当内存的数据较多时就会Flush到SSTable文件。对应内存的这一层SSTable定义为L0其他层的文件我们同样采用Ln的形式表示n为对应的层数。因为L0的文件仍然是按照时间顺序生成的所以文件之间就可能有重叠的Key。L0不做整理的原因是为了保证写盘速度。
<img src="https://static001.geekbang.org/resource/image/40/ac/404015015046166f426ab3f159864bac.jpg" alt="">
1. 通常系统会通过指定SSTable数量和大小的方式控制每一个层的数据总量。当L0超过预定文件数量就会触发L0向L1的Compact。因为在L0的SSTable中Key是交叉的所以要读取L0的所有SSTable写入L1完成后再删除L0文件。从L1开始SSTable都是保证Key不重叠的。
<img src="https://static001.geekbang.org/resource/image/a7/95/a7c9c1ca7723cfaa180461c1b3a8fd95.jpg" alt="">
1. 随着L1层数据量的增多SSTable可能会重新划分边界目的是保证数据相对均衡的存储。
<img src="https://static001.geekbang.org/resource/image/f6/d6/f65536e12e95565ebe36c1596c53f9d6.jpg" alt="">
1. 由于L1的文件大小和数量也是受限的所以随着数据量的增加又会触发L1向L2的Compact。因为L1的文件是不重叠的所以不用所有L1的文件都参与Compact这就延缓了磁盘I/O的开销。而L2的单个文件容量会更大通常从L1开始每层的存储数据量以10倍的速度增长。这样每次Ln到L(n+1)的compact只会涉及少数的SSTable间隔的时间也会越来越长。
<img src="https://static001.geekbang.org/resource/image/8c/50/8c891780dd19146d4454a6636c052c50.jpg" alt="">
说完处理流程我们再从RUM三个角度来分析下。
1. 读放大
因为存在多个SSTableLeveled策略显然是存在读放大的。因为SSTable是有序的如果有M个文件则整体计算的时间复杂度是O(MlogN)。这个地方还可以优化。通常的方法是在SSTable中引入Bloom FilterBF这是一个基于概率的数据结构可以快速地确定一个Key是否存在。这样执行Get操作时先读取每一个SSTable的BF只有这个Key存在才会真的去文件中查找。
那么对于多数SSTable时间复杂度是O(1)。L0层的SSTable无序所有都需要遍历而从L1层开始每层仅需要查找一个SSTable。那么优化后的整体时间复杂度就是O(X+L-1+logN)其中X是L0的SSTable数量L是层数。
1. 写放大
Leveled策略对写放大是有明显改善的除了L0以外每次更新仅涉及少量SSTable。但是L0的Compact比较频繁所以仍然是读写操作的瓶颈。
1. 空间放大
数据在同层的SSTable不重叠这就保证了同层不会存在重复记录。而由于每层存储的数据量是按照比例递增的所以大部分数据会存储在底层。因此大部分数据是没有重复记录的所以数据的空间放大也得到了有效控制。
## 分布式数据库的实现
到这里我们已经介绍了存储结构的实现原理和它们在单体数据库中的运转过程。在分布式数据库中数据存储是在每个数据节点上各自进行的所以原理和过程是完全一样的。因此TiDB和CockroachDB干脆直接使用RocksDB作为单机存储引擎。在这两个分布式数据库的架构中RocksDB都位于Raft协议之下承担具体的数据落地工作。
<img src="https://static001.geekbang.org/resource/image/19/9f/19a017c90e3cb51c59a96f54563f019f.jpg" alt="">
他们为什么选择RocksDB呢CockroachDB的官方解释是他们的选择完全不是因为要考虑LSM的写优化能力而是因为RocksDB作为一个成熟的单机存储引擎可以加速CockroachDB的开发过程而RocksDB本身也非常优秀是他们能找到的最好选择。另一方面TiDB虽然没有提到选择一个写优化的存储引擎是否有特殊意义但同样也认为RocksDB 是一个非常优秀开源项目,可以满足他们对单机引擎的各种要求。
不过很有意思的地方是经过了早期版本的演进TiDB和CockroachDB不约而同都推出了自己的单机存储引擎。
你猜是什么原因呢我在稍后再给出答案。现在让我们先看看另一款NewSQL风格分布式数据库OceanBase的实现。
### OceanBase
OceanBase选择了自研方式也就没有直接引用RocksDB但它的存储模型基本上也是LSM也就同样需要面对Compact带来的一系列问题。我们来看看OceanBase是怎么优化的。
#### 宏块与微块
在Compact过程中被合并文件中的所有数据都要重写到新文件中。其实对于那些没有任何修改的数据这个过程是可以被优化的。
OceanBase引入了宏块与微块的概念微块是数据的组织单元宏块则由微块组成。这样在进行Compact操作时可以在宏块和微块两个级别判断是否可以复用。如果在这两个级别数据没有发生实质变化则直接进行块级别的拷贝这样就省去了更细粒度的数据解析、编码以及计算校验和Checksum等操作。
#### 轮转合并
OceanBase还在多副本基础上设计了轮转合并机制。我们知道根据Raft协议或者Paxos协议总有多个副本同时存储着最新的数据那么我们就可以用多副本来解耦Compact操作和同时段的查询操作避免磁盘I/O上的竞争。
它的大致设计思路是这样的将Compact操作放在与Leader保持数据同步的Follower上执行而Leader节点则保持对外提供查询服务。当Compact完成后改由那个Follower对外提供查询服务Leader和其他副本则去执行Compact。
OceanBase的这两项优化虽然没有降低写放大系数但却有效减少了Compact过程中的I/O竞争。
### TiDBWiscKey
OceanBase的优化还停留在工程层面那么还有没有更好的理论模型呢?
2016年真的出现了新的模型这就是WiscKey。论文“[WiscKey: Separating Keys from Values in SSD-conscious Storage](https://www.usenix.org/system/files/conference/fast16/fast16-papers-lu.pdf)”Lanyue Lu et al.(2016)阐述了这种模型的设计思想。WiscKey 提出的改进是通过将 value 分离出LSM-Tree的方法来降低写放大。
WiscKey的主要设计思想是在SSTable的重写过程中核心工作是对Key进行整理保证多个SSTable的Key范围不重叠且内部有序。而这个过程中Value的重写是没有太大价值的而从实践看Value占用的存储空间远远大于Key。这意味着大量的写操作和空间成本被浪费了。所以WiscKey提出将Value从SSTable中分离出来单独存储这样就降低了写放大系数。
<img src="https://static001.geekbang.org/resource/image/d0/a9/d0eda0529ce4c9796f777c95d46914a9.png" alt="" title="引自Lanyue Lu et al.(2016)">
Value单独存储的问题是按照Key连续读取数据时对应的Value并不是连续存储磁盘寻址成本增大。而WiscKey的设计前提是使用SSD替换HDDSSD的随机读写效率接近于顺序读写所以能够保持较高的整体效率。事实上过高的写放大也会严重缩短SSD的使用寿命。WiscKey就是针对SSD提出的存储模型。
说完WiscKey你或许猜到了TiDB和Cockroach放弃RocksDB的原因。
没错TiDB的新存储引擎TiTan就是受到WiscKey的启发它目标之一就是将 Value 从 LSM-Tree 中分离出来单独存储,以降低写放大。
### CockroachDBPebble
我们再来看看CockroachDB的单机存储引擎Pebble。这里可能会让你有点意外Pebble并不是由于WiscKey的模型改进它的出现完全是工程因素。首先是Go与C之间的调用障碍CGo barrier
#### CGo barrier
CockroachDB采用Go作为编程语言而RocksDB是由C++编写的。所以CockroachDB要面临Go到C的之间的调用障碍。测试表明每一次对RocksDB的调用都要额外付出70纳秒的延迟。这个数字虽然看上去并不大但是由于K/V操作非常频繁总体成本仍然很可观。CockroachDB也通过一些设计来降低延迟比如将多次K/V操作合并成一次对RocksDB的操作但是这一方面带来设计的复杂性另外也无法从根本上解决问题。
值得一提的是TiDB也使用了Go语言作为主力开发语言同样面临了这个问题。TiDB最终是在底层存储TiVK放弃Go而选择Rust的部分原因就是Rust与C++之间的调用成本要低得多。
#### 代码膨胀
CockroachDB替换存储引擎的另一个原因是RocksDB的代码量日益膨胀。早期RocksDB的代码行数仅是30k而今天已经增加到350k+。这很大程度是由于RocksDB的成功很多软件选择了RocksDB作为存储引擎包括MySQLMyRocks、Flink等这驱动RocksDB增加了更丰富的特性也导致体量越来越大。但这些丰富的特性对CockroachDB来说并不是必须的反而引入了不必要的变更风险。而Cockraoch开发的Pebble代码行数则仅有45k的确是小巧得多了。
所以总的来说CockraochDB替换存储引擎是工程原因而其中CGO barrier的这个问题更像是在偿还技术债。
### TiFlash
到这里分布式数据库下典型的存储引擎就介绍完了它们都适用于OLTP场景要求在小数据量下具有高效的读写能力。而OLAP下的存储引擎则有很大的不同通常不会对单笔写入有太高的要求但也有例外还记得我在[第18讲](https://time.geekbang.org/column/article/287246)提到过的OLAP存储引擎TiFlash吗由于实时分析的要求它必须及时落盘来自TiKV的数据同样需要很高的写入速度。
在18讲中并没对TiFlash展开说明我想现在你应该能够猜出TiFlash的关键设计了吧。是的高效写入的秘密就在于它的存储模型Delta Tree采用了类似LSM的结构。其中的Delta Layer 和 Stable Layer分别对应 LSM Tree 的 L0 和 L1Delta Layer可以顺序写入。
## 小结
那么,今天的课程就到这里了,让我们梳理一下这一讲的要点。
1. RUM猜想提出读负载、写负载、空间负载这三者之间只能优化两个目标另外一个目标只能被弱化。在典型数据结构中B+Tree是读取优化LSM是写入优化这两种存储结构正好对应了两种风格分布式数据库的存储引擎。
1. PGXC和单体数据库采用了B+Tree随机写会带来页表分裂和存储不连续导致写放大和读放大。常用的优化方式是通过填充因子预留页空间。LSM将随机写转换为顺序写提升了写入速度但只是延缓了写放大并没有真正减少写放大。如果采用Tiered策略LSM的写放大和空间放大都高于B+Tree。不过LSM可以很好地控制读放大读操作的时间复杂度是O(logN)。
1. Level策略降低了写放大和空间放大同时读操作的成本也没有大幅增加是一个比较广泛使用的存储模型。RocksDB采用了这种存储模型加上优秀的工程实现被很多软件引用。TiDB和CockroachDB也使用RocksDB作为单机存储引擎。OceanBase在工程层面对LSM进行了优化。
1. WiscKey是对LSM的理论模型优化通过Key/Value分离存储的方式降低写放大但这种设计会增加随机读写要以使用SSD为前提。TiDB受到WiscKey的启发自研了TiTan。
1. CockroachDB也推出了自己的存储引擎Pebble主要目的是解决工程问题包括解决CGo barrier问题和避免Rust功能膨胀带来的变更风险。
这一讲我们简单介绍了近年来的主流存储模型不难发现LSM占据了主导位置。但这并不代表B+Tree是一种失败的设计两者只是在RUM之间选择了不同的优化方向。同时是否有可靠的开源工程实现也直接影响到了对应存储模型的流行度这一点和Raft协议的大规模使用很相似。
我的一点建议是记下这些存储模型虽然重要但更加关键的是对RUM猜想的深入思考因为架构设计的精髓就在于做出适当的取舍。
<img src="https://static001.geekbang.org/resource/image/5b/32/5bbd48e644e90fda6ce91a0360e7e332.jpg" alt="">
## 思考题
课程的最后我们来看看今天的思考题。今天的课程中我们介绍了不同的存储模型其中也提到了Bloom Filter的使用。它是一种神奇的数据结构在RUM中是空间优化的典型用很少的空间就可以记录很多数值是否存在。这种数据结构提供了两种操作方式用于数值的写入和数值的检核但数据库查询更多是范围查询在K/V中体现为Scan操作显然不能直接对应。所以我的问题是Scan操作是否可以使用Bloom Filter来加速如果可以又该如何设计呢
欢迎你在评论区留言和我一起讨论,我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对存储引擎这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。
## 学习资料
Fay Chang et al.: [**Bigtable: A Distributed Storage System for Structured Data**](https://www2.cs.duke.edu/courses/cps399.28/current/papers/osdi06-ChangDeanEtAl-bigtable.pdf)
Lanyue Lu et al.: [**WiscKey: Separating Keys from Values in SSD-conscious Storage**](https://www.usenix.org/system/files/conference/fast16/fast16-papers-lu.pdf)
Manos Athanassoulis et al: [**Designing Access Methods: The RUM Conjecture**](https://stratos.seas.harvard.edu/files/stratos/files/rum.pdf)
Patrick ONeil et al.: [**The Log-Structured Merge-Tree (LSM-Tree)**](https://dsf.berkeley.edu/cs286/papers/lsm-acta1996.pdf)

View File

@@ -0,0 +1,142 @@
<audio id="audio" title="23 | 数据库查询串讲:重难点回顾+思考题答疑+知识全景图" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/01/f5/01e330a4c1ee6d01c155295dyy1fa0f5.mp3"></audio>
你好,我是王磊。
这一讲是我们课程的第三个答疑篇我会和你一起回顾第16讲到22讲的主要内容这部分内容是围绕着数据库的“查询”展开的。同时我也会集中解答留给大家思考题并回复一些大家关注的热点内容。
## 第16讲慎用存储过程
[第16讲](https://time.geekbang.org/column/article/285270)我首先分享了自己职业生涯中的一个小故事说的是如何用Oracle的存储过程将程序执行速度提升了60倍。这是个值得骄傲的事情但后来我也发现了存储过程的局限性就是难以移植、难以调试。所以我个人的建议是不使用或者少使用存储过程。
然后我们对分布式数据库的支持现状做了介绍只有OceanBase 2.0版本支持Oracle存储过程但官方并不建议在生产环境中使用。Google的F1中通过引入独立的UDF Server来支持存储过程可以使用Java等多种高级语言进行开发这样调试和迁移会更加方便但是普通企业的网络带宽能否支撑这种架构还不好说。
最后我们重点介绍了VoltDB。它是一款内存分布式数据库大量使用内存、单线程、主要使用存储过程封装SQL是它的三个特点。VoltDB的存储过程也支持Java开发更加开放。
这一讲的是思考题是“VoltDB的设计思路很特别在数据的复制上的设计也是别出心裁既不是NewSQL的Paxos协议也不是PGXC的主从复制你能想到是如何设计的吗
VoltDB数据复制的方式是K-safety也叫做同步多主复制其中K是指分区副本的数量。这种模式下当前分区上的任何操作都会发送给所有副本去执行以此来保证数据一致性。也就是说VoltDB是将执行逻辑复制到了多个分区上来得到同样的结果并不是复制数据本身。
对这个问题“佳佳的爸”在留言区给出了一个标准答案。同时针对存储过程的使用“Jxin”和“佳佳的爸”两位同学都谈了自己的经验和体会都讲得非常好点赞。
同时,我也看到有的同学表达了不同的观点,这个我还是蛮能理解的。调试难、移植难这都是主观判断,没有统一的标准。难还是易,说到底是和个体能力有关系。但是,今天已经是软件工程化的时代,越来越重视协作,孤胆英雄的开发模式在多数情况下已经不适用了。
如果你的整个团队都能低成本地使用这项技能确实可以考虑继续使用但这样也不是完全没有风险。作为管理者你肯定还要考虑团队技术栈和市场上多数程序员的技术栈是否一致来降低人员变动带来的风险。我碰到过不少项目开发语言选C++或Java都可以但就是因为C++程序员太少所以选择了Java。
## 第17讲自增主键 VS 随机主键
[第17讲](https://time.geekbang.org/column/article/285819)的核心内容是自增主键的使用它是一个具体的特性因为要依赖全局性约束所以很有代表性。我们首先介绍了MySQL的自增主键很多同学会认为它能够保证单调递增但如果发生事务冲突时自增主键是会留下空洞的。
而且当并发很大时也不能直接使用MySQL的自增列因为会成为性能瓶颈。然后我介绍了如何使用Oracle的Sequence配合应用系统共同支持海量并发。
在分布式数据库下自增主键与Range分片共用会引发“尾部热点”问题我们用CockroachDB与YugabyteDB的性能测试数据来验证这个判断。因为Range分片是一种普遍的选择所以通常是舍弃自增主键转而用随机主键替换仅保证ID的唯一性。具体技术方案分为数据库内置和应用系统外置两种内置方案包括UUID和Random外置方案包括Snowflake算法。
这一讲的是思考题是“使用Range分片加单调递增主键会引发尾部热点问题但是使用随机主键是不是一定能避免出现热点问题
答案是随机主键可能会出现热点问题。因为按照Range分片原理一张数据表初始仅有一个分片它的Key范围是从无穷小到无穷大。随着数据量的增加这个分片会发生分裂Split数据存储才逐渐散开。这意味着在一段时间内分片数量会远小于集群节点数量时所以仍然会出现热点。
解决的方法就是采用预分片机制Presplit在没有任何数据的情况下先初始化若干分片并分配不同的节点。这样在初始阶段写入负载就可以被分散开避免了热点问题。目前Presplit在分布式键值系统中比较常见例如HBase但不是所有的分布式数据库都支持。
## 第18讲HTAP VS Kappa
在[第18讲](https://time.geekbang.org/column/article/287246)中我们讨论了HTAP的提出背景、现状和未来。HTAP是Gartner提出的OLTP与OLAP一体化的解决思路旨在解决数据分析的时效性。同年LinkedIn提出的Kappa架构也是针对这个问题。所以我们将HTAP和Kappa作为两种互相比较的解决方案。
Kappa下的主要技术产品包括Kafka、Flink等是大数据生态的一部分近年来的发展比较迅速。HTAP的主要推动者是OLTP数据库厂商进展相对缓慢也没有强大的生态支持。所以我个人更看好Kappa架构这条路线。
要实现HTAP就要在计算和存储两个层面支持OLTP和OLAP其中存储是基础。OLTP通常使用行式存储OLAP则一般使用列式存储存在明显差异。HTAP有两种解决思路。一种是Spanner的PAX一种新的融合性存储在行存的基础上融合列存的特点。另外一种是TiDB提出的借助Raft协议在OLTP与OLAP之间异步复制数据再通过OLAP的特殊设计来弥补异步带来的数据不一致问题。
这一讲的思考题是“每次TiFlash接到请求后都会向TiKV Leader请求最新的日志增量本地replay日志后再继续处理请求。这种模式虽然能够保证数据一致性但会增加一次网络通讯。你觉得这个模式还能优化吗
问题的答案是可以利用Raft协议的特性进行优化。如果你有点记不清了可以回到第7讲复习一下Raft协议。Raft在同步数据时是不允许出现“日志空洞”的这意味着如果Follower节点收到时间戳为300的日志则代表一定已经收到了小于这个时间戳的所有日志。所以在TiFlash接收到查询请求时如果查询时间戳小于对应分片的最后写入时间戳那么本地分片的数据一定是足够新的不用再与TiKV的Leader节点通讯。
我在留言区看到“游弋云端”和“tt”同学都给出了自己的设计方案都很棒。“tt”同学的方案非常接近于我给出的答案“游弋云端”同学的心跳包方案也值得深入探讨。
## 第19讲计算下推的各种形式
[第19讲](https://time.geekbang.org/column/article/288220)中,我们谈的核心内容是计算下推,这是计算存储分离架构下主要的优化方法。
计算下推又可以细分为很多场景比较简单的处理是谓词下推就是将查询条件推送到数据节点执行。但在不同的架构下实现难度也有差异比如TiDB因为设计了“缓存写提交”所以就会更复杂些。分区键对于处理计算下推是个很好的帮助在PGXC架构中常见而NewSQL架构主要采用Range分片所以无法直接使用。
分布式数据库沿用了索引来加速计算。在分布式架构下,按照索引的实现方式可以分为分区索引和全局索引。分区索引可以保证索引与数据的同分布,那么基于索引的查询就可以下推到数据节点执行,速度更快。但是,分区索引无法实现全局性约束,比如就没法实现唯一索引,需要全局索引来实现。
不过,全局索引没有同分布的约束,写入数据会带来分布式事务,查询时也会有两轮通讯处理索引查询和回表,性能会更差。在分布式架构下要慎用全局索引。
这一讲的思考题是讲“将‘单表排序’操作进行下推,该如何设计一种有冗余的下推算法?”
排序是一个全局性的处理任何全局性的控制对分布式架构来说都是挑战。这个设计的关键是有冗余。假如我们执行下面这一条SQL查询账户余额最多的1,000条记录。
```
select * from balance_info order by balance_num limit 1000;
```
一个比较简单的思路是计算节点将这个SQL直接推送给所有数据节点每个数据节点返回top1,000再由计算节点二次排序选择前1,000条记录。
不过这个方式有点太笨拙。因为当集群规模比较大时比如有50个节点计算节点会收到50,000条记录其中49,000都是无效的如果limit数量再增加那无效的数据会更多。这种方式在网络传输上不太经济有一点像读放大情况。
我们可以基于集群节点的数量适当缩小下推的规模比如只取top 500这样能够降低传输成本。但相应地要增加判断逻辑因为也许数据分布很不均衡top 1,000账户都集中在某个节点上那么就要进行二次通讯。这个方式如果要再做优化就是在计算节点保留数据统计信息让数据量的分配符合各节点的情况这就涉及到CBO的概念了。
## 第20讲关联查询经典算法与分布式实现
在[第20讲](https://time.geekbang.org/column/article/289299)我们讨论了关联查询Join的实现方案。关联查询是数据库中比较复杂的计算经典算法分为三类嵌套循环、排序归并和哈希。这三类算法又有一些具体的实现我们依次做了介绍其中哈希算法下的Grace哈希已经有了分布式执行的特点。
有了算法的基础我又从分布式架构的角度讨论了关联查询的实现。首先涉及到并行执行框架的问题多数产品的执行框架比较简单只能做到计算下推这种比较简单的并行。因为数据节点之间是不通讯的所以计算节点容易成为瓶颈。另外一些产品比如OceanBase和CockroachDB引入了类似MPP的机制允许数据节点之间交换数据。
我们把关联查询这个问题聚焦到大小表关联和大表关联两个场景上。大小表关联的解决方案是复制表方式,具体又包括静态和动态两种模式。大表关联则主要通过数据重分布来实现,这个过程需要数据节点之间交换数据,和上一段的并行执行框架有很密切的关系。
这一讲的思考题是“当执行Hash Join时在计算逻辑允许的情况下建立阶段会优先选择数据量较小的表作为Inner表我的问题就是在什么情况下系统无法根据数据量决定Inner表呢
选择数据量较小的作为Inner表这是典型的基于代价的优化也就是CBOCost Based Optimizer属于物理优化阶段的工作。在这之前还有一个逻辑优化阶段进行基于关系代数运算的等价转化有时就是计算逻辑限制了系统不能按照数据量来选择Inner表。比如执行左外连接Left Outer Join它的语义是包含左表的全部行不管右表中是否存在与它们匹配的行以及右表中全部匹配的行。这样就只能使用右表充当 Inner 表并在之上建哈希表,使用左表来当 Outer 表,也就是我们的驱动表。
## 第21讲查询执行引擎的三个模型
[第21讲](https://time.geekbang.org/column/article/289971),我们的关键词是查询执行引擎,它的责任是确保查询计划能被快速执行,而执行速度则取决于引擎采用的执行模型。执行模型分为三种,火山模型、向量化模型和代码生成。
火山模型是多数数据库使用的模型有20年的历史运行非常稳定。火山模型由一组运算符嵌套组成运算符之间低耦合通用性高但它的缺点是无法使用现代CPU的特性尤其是虚函数过多。
向量化模型将运算符的输入从行集合变成向量块减少了调用虚函数的次数也提高了CPU使用效率。
代码生成的逻辑则是通过编译器生成针对性的代码,从根本上解决虚函数过多的问题。
向量化模型和代码生成是现代高效查询模型的代表已经获得越来越多认可在很多数据库中被使用。TiDB和CockroachDB都进行了向量化改造而OceanBase也实现了表达式级别的代码生成。
这一讲的思考题是“基础软件演进中一个普遍规律,每当硬件技术取得突破后就会引发软件的革新。那么,我的问题就是你了解的基础软件中,哪些产品分享了硬件技术变革的红利呢?”
就像问题中所说的每次硬件技术的突破都会引发软件的革新比如VoltDB出现的背景就是内存技术成熟价格日益降低即使使用内存作为主存储设备也有足够的商业竞争力。
通过这一讲你应该已经了解到查询引擎的优化就是围绕着现代CPU特性展开的。而在第22讲存储引擎部分我介绍了WiscKey模型它是伴随着硬盘技术的发展而提出的具体来说就是SSD的技术。
SSD的物理结构与传统的HDD非常不同没有物理磁头所以寻址成本更低对于随机写支持等更好但是反复擦写却更影响SSD的使用寿命。WiscKey模型就是基于适合这两个特性提出的。随着SSD价格逐步降低未来很可能成为服务器的标准配置。
## 第22讲存储引擎的优化选择
[第22讲](https://time.geekbang.org/column/article/291009),我们主要谈的是存储引擎,也就是数据落盘的最后一步。
在开始的部分我们先引入RUM猜想这个框架指出任何数据结构只能在读放大、写放大和空间放大三者之间优化两项。
然后我们又回到数据库架构下分析了B+ Tree与LSM的区别。它们并不是简单地读优化和写优化。LSM的两种策略Tiered和Leveled也会带来不同的效果其中Leveled是RocksDB采用的模型适用范围更广。
因为RocksDB是一个优秀的单机存储引擎所以TiDB和CockroachDB最初都直接引入了它。但是随着产品的演进TiDB和CockroachDB分别推出了自己的存储引擎TiTan和Pebble。
Titan是借鉴了新的存储模型WiscKey与LSM最大的差异是将Value分离出来单独存储这样的好处是在Compact环节减少了写放大。
选择Pebble的不是为了优化模型而是出于工程上的考虑。一方面是Go语言调用C++编写RocksDB是有额外的延迟另一方面是RocksDB的不断膨胀引入了更多的变更风险。而Pebble使用Go语言开发更加小巧满足了工程方面的要求。
这一讲的思考题是“Scan操作是否可以使用Bloom Filter来加速如果可以又该如何设计呢
Bloom Filter是很有意思的数据结构通过多个Hash函数将一个数值映射到某几个字节上。这样用少量的字节就可以存储大量的数值同时能快速地判断某个数值是否存在。虽然没有做映射的数值会有一定概率的误报但可以保证“数值不存在”是绝对准确的这就是假阳性。
这种模式显然是不能直接支持Scan操作的这是需要将数值做一定的转化。这个方法在RocksDB中称为“Prefix Bloom Filter”也就是取Key的左前缀Prefix进行判断。因为K/V系统是按照Key字典序排列的那就是说相邻的Key通常具有相同的Prefix这种匹配方式相当于对一组Key做了检验可以更好地适应Scan的特点。
对这个问题,“扩散性百万咸面包”和“可怜大灰狼”两位同学都给出了很准确的答案,点赞。
## 小结
第16到第22这七讲大多是围绕着查询这个主题展开的。之所以安排这么大的篇幅是因为我认为对数据库来说查询是除了事务以外最重要的功能。
当然OLTP的查询功能和OLAP还有很大的区别也不能满足所有的查询需求。但了解了这些可以让我们更准确地管理对分布式数据库的预期。如果你对查询场景更有兴趣希望这些内容能够为你奠定一个基础未来更高效地学习OLAP数据库的相关内容。
如果你对今天的内容有任何疑问,欢迎在评论区留言和我一起讨论。要是你身边的朋友也对数据库的查询执行过程感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。
## 分布式数据全景图3/4
<img src="https://static001.geekbang.org/resource/image/0d/69/0d3c516d959b91afae6984ca85ea1669.jpg" alt="">