mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-19 23:53:47 +08:00
del
This commit is contained in:
281
极客时间专栏/geek/分布式数据库30讲/开发篇/09|原子性:2PC还是原子性协议的王者吗?.md
Normal file
281
极客时间专栏/geek/分布式数据库30讲/开发篇/09|原子性:2PC还是原子性协议的王者吗?.md
Normal 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 Commit,2PC),这也是面向资源层的典型协议。
|
||||
|
||||
### 数据库领域最常用的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的流程,我们来对比下这两个协议。
|
||||
|
||||
相比于TCC,2PC的优点是借助了数据库的提交和回滚操作,不侵入业务逻辑。但是,它也存在一些明显的问题:
|
||||
|
||||
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 Commit,3PC)呢?
|
||||
|
||||
原因也很简单,因为3PC虽然试图解决2PC的问题,但它的通讯开销更大,在网络分区时也无法很好地工作,很少在工程实践中使用,所以我就没有介绍,你只要知道有这么个协议就好。
|
||||
|
||||
另外,我还要提示一个容易与2PC协议混淆的概念,也就是两阶段封锁协议(Two-Phase Locking,2PL)。
|
||||
|
||||
我认为,这种混淆并不只是因为名字相似。从整个分布式事务看,原子性协议之外还有一层隔离性协议,由后者保证事务能够成功申请到资源。在相当长的一段时间里,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)
|
||||
183
极客时间专栏/geek/分布式数据库30讲/开发篇/10 | 原子性:如何打破事务高延迟的魔咒?.md
Normal file
183
极客时间专栏/geek/分布式数据库30讲/开发篇/10 | 原子性:如何打破事务高延迟的魔咒?.md
Normal 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&E 2013),Randy Wigginton等人在一场名为“Distributed Transactions in MySQL”的演讲中公布了一组XA事务与单机事务的对比数据。XA协议是2PC在数据库领域的具体实现,而MySQL(InnoDB存储引擎)正好就支持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中同时执行T1,T2两个事务,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个不同地区机房之间的RTT(Round-Rip Time,往返延迟)数据。
|
||||
|
||||
简单来说,RTT就是数据在两个节点之间往返一次的耗时。在讨论网络延迟的时候,为了避免歧义,我们通常使用RTT这个概念。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/37/10/37be4540d53b39372dd5ef7e623ef210.jpg" alt="">
|
||||
|
||||
实验中,地理跨度较大两个机房是巴西圣保罗和新加坡,两地之间的理论RTT是106.7毫秒(使用光速测算),而实际测试的RTT均值为362.8毫秒,P95(95%)RTT均值为649毫秒。将649毫秒代入公式,那$L_{txn}$就是接近1.3秒,这显然太长了。而考虑到共识算法的数据包更大,这个延迟还会更长。
|
||||
|
||||
### 并行提交(Parallel Commits)
|
||||
|
||||
但是,像CockroachDB、YugabyteDB这样分布式数据库,它们的目标就是全球化部署,所以还要努力去压缩事务延迟。
|
||||
|
||||
可是,还能怎么压缩呢?准备阶段的操作已经压缩到极限了,commit这个动作也不能少呀,那就只有一个办法,让这两个动作并行执行。
|
||||
|
||||
在优化前的处理流程中,CockroachDB会记录事务的提交状态:
|
||||
|
||||
```
|
||||
TransactionRecord{
|
||||
Status: COMMITTED,
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
并行执行的过程是这样的。
|
||||
|
||||
准备阶段的操作,在CockroachDB中被称为意向写。这个并行执行就是在执行意向写的同时,就写入事务标志,当然这个时候不能确定事务是否提交成功的,所以要引入一个新的状态“Staging”,表示事务正在进行。那么这个记录事务状态的落盘操作和意向写大致是同步发生的,所以只有一轮共识算法开销。事务表中写入的内容是类似这样的:
|
||||
|
||||
```
|
||||
TransactionRecord{
|
||||
Status: STAGING,
|
||||
Writes: []Key{"A", "C", ...},
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
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**
|
||||
191
极客时间专栏/geek/分布式数据库30讲/开发篇/11|隔离性:读写冲突时,快照是最好的办法吗?.md
Normal file
191
极客时间专栏/geek/分布式数据库30讲/开发篇/11|隔离性:读写冲突时,快照是最好的办法吗?.md
Normal 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 Control,MVCC)就是**通过记录数据项历史版本的方式,来提升系统应对多事务访问的并发处理能力**。今天,几乎所有主流的单体数据库都实现了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 Committed,RC)。
|
||||
|
||||
那么,我们先来看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-2),T1到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)又深入探讨了物理时钟和逻辑时钟如何控制时间误差。那么,你觉得时间误差会影响读写冲突的处理吗?
|
||||
|
||||
如果你想到了答案,又或者是触发了你对相关问题的思考,都可以在评论区和我聊聊,我会在下一讲和你一起探讨。最后,希望这节课能带给你一些收获,也欢迎你把它分享给周围的朋友,一起进步。
|
||||
125
极客时间专栏/geek/分布式数据库30讲/开发篇/12 | 隔离性:看不见的读写冲突,要怎么处理?.md
Normal file
125
极客时间专栏/geek/分布式数据库30讲/开发篇/12 | 隔离性:看不见的读写冲突,要怎么处理?.md
Normal 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="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我要留给你一道思考题。
|
||||
|
||||
今天,我们继续探讨了读写冲突的话题,在引入了时间误差后,整个处理过程变得更复杂了,而无论是“读等待”还是“写等待”都会让系统的性能明显下降。说到底是由多个独立时间源造成的,而多个时间源是为了支持全球化部署。那么,今天的问题就是,你觉得在什么情况下,不用“等待”也能达到线性一致性或因果一致性呢?
|
||||
|
||||
欢迎你在评论区留言和我一起讨论,我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对时间误差下的读写冲突这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。
|
||||
132
极客时间专栏/geek/分布式数据库30讲/开发篇/13 | 隔离性:为什么使用乐观协议的分布式数据库越来越少?.md
Normal file
132
极客时间专栏/geek/分布式数据库30讲/开发篇/13 | 隔离性:为什么使用乐观协议的分布式数据库越来越少?.md
Normal 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)
|
||||
164
极客时间专栏/geek/分布式数据库30讲/开发篇/14 | 隔离性:实现悲观协议,除了锁还有别的办法吗?.md
Normal file
164
极客时间专栏/geek/分布式数据库30讲/开发篇/14 | 隔离性:实现悲观协议,除了锁还有别的办法吗?.md
Normal 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 Locking,2PL)
|
||||
|
||||
基于锁的协议显然不只是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 2PL,C2PL),事务在开始时设置它需要的所有锁。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/66/81/6680049eacdd2bab61a65eyy0e36a881.png" alt="">
|
||||
|
||||
**严格两阶段封锁协议**(Strict 2PL,S2PL),事务一直持有已经获得的所有写锁,直到事务终止。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/ec/b7291c52385b627282f5c4f010acacec.png" alt="">
|
||||
|
||||
**强两阶段封锁协议**(Strong Strict 2PL,SS2PL),事务一直持有已经获得的所有锁,包括写锁和读锁,直到事务终止。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是一种隔离级别的命名,最早来自PostgreSQL,CockroachDB沿用了这个名称。它是在SI基础上实现的可串行化隔离。同样,作为SSI核心的SGT也不是CockroachDB首创,学术界早就提出了这个理论,但真正的工程化实现要晚得多。
|
||||
|
||||
### 理论来源:PostgreSQL
|
||||
|
||||
PostgreSQL在论文“[Serializable Snapshot Isolation in PostgreSQL](http://vldb.org/pvldb/vol5/p1850_danrkports_vldb2012.pdf)”中最早提出了SSI的工程实现方案,这篇论文也被VLDB2012收录。
|
||||
|
||||
为了更清楚地描述SSI方案,我们先要了解一点理论知识。
|
||||
|
||||
串行化理论的核心是串行化图(Serializable Graph,SG)。这个图用来分析数据库事务操作的冲突情况。每个事务是一个节点,事务之间的关系则表示为一条有向边。那么,什么样的关系可以表示为边呢?
|
||||
|
||||
串行化图的构建规则是这样的,事务作为节点,当一个操作与另一个操作冲突时,在两个事务节点之间就可以画上一条有向边。
|
||||
|
||||
具体来说,事务之间的边又分为三类情况:
|
||||
|
||||
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 Graph,DAG)。能够构建出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) -> T3.W(batch),得到T2到T3的RW依赖;再由T3.W(batch)->T1.R(batch),得到 T3到T1的WR依赖;最后由T1.R(reps)->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条件是“>=1 and <=5”,读取到1、2、5三个值,T1完成后,事务T2在该表插入了4,因为RTC记录的是范围区间[1,5],所以4也可以被检测出存在RW依赖。这个地方,有点像MySQL间隙锁的原理。
|
||||
|
||||
RTC是一个大小有限的,采用LRU(Least 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图时,这些事务才是可串行化的。这个理论不仅适用于SGT,2PL的最终调度结果也同样是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)
|
||||
146
极客时间专栏/geek/分布式数据库30讲/开发篇/15 | 分布式事务串讲:重难点回顾+思考题答疑+知识全景图.md
Normal file
146
极客时间专栏/geek/分布式数据库30讲/开发篇/15 | 分布式事务串讲:重难点回顾+思考题答疑+知识全景图.md
Normal 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对应,分别是TM(Transaction Manager,事务管理者)也就是事务协调者,RM(Resource 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: Google’s 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>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: Google’s 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)
|
||||
129
极客时间专栏/geek/分布式数据库30讲/开发篇/16 | 为什么不建议你使用存储过程?.md
Normal file
129
极客时间专栏/geek/分布式数据库30讲/开发篇/16 | 为什么不建议你使用存储过程?.md
Normal 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影响B,B又影响到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(
|
||||
" SELECT TOP 1 county, abbreviation, population "
|
||||
+ " FROM people, states WHERE people.state_num=?"
|
||||
+ " AND people.state_num=states.state_num"
|
||||
+ " ORDER BY population ASC;" );
|
||||
|
||||
//执行入口
|
||||
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)
|
||||
165
极客时间专栏/geek/分布式数据库30讲/开发篇/17 | 为什么不建议你使用自增主键?.md
Normal file
165
极客时间专栏/geek/分布式数据库30讲/开发篇/17 | 为什么不建议你使用自增主键?.md
Normal 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
|
||||
|
||||
UUID(Universally 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位就是4096(2^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/)
|
||||
153
极客时间专栏/geek/分布式数据库30讲/开发篇/18 | HTAP是不是赢者通吃的游戏?.md
Normal file
153
极客时间专栏/geek/分布式数据库30讲/开发篇/18 | HTAP是不是赢者通吃的游戏?.md
Normal 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。HTAP(Hybrid 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使用的融合性存储PAX(Partition 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 (行式存储)
|
||||
|
||||
NSM(N-ary Storage Model)就是行式存储,也是OLTP数据库默认的存储方式,始终伴随着关系型数据库的发展。我们常用的OLTP数据库,比如MySQL(InnoDB)、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(列式存储)
|
||||
|
||||
DSM(Decomposition 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)
|
||||
149
极客时间专栏/geek/分布式数据库30讲/开发篇/19 | 查询性能优化:计算与存储分离架构下有哪些优化思路?.md
Normal file
149
极客时间专栏/geek/分布式数据库30讲/开发篇/19 | 查询性能优化:计算与存储分离架构下有哪些优化思路?.md
Normal 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是索引自身的主键ID,Value是反序列化信息用于解析主键内容。索引主键由三部分构成,分别是分片区间起始值、索引值和所指向数据行的主键(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)
|
||||
198
极客时间专栏/geek/分布式数据库30讲/开发篇/20 | 关联查询:如何提升多表Join能力?.md
Normal file
198
极客时间专栏/geek/分布式数据库30讲/开发篇/20 | 关联查询:如何提升多表Join能力?.md
Normal 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 Join(SNLJ)、Block Nested-Loop Join(BNJ)和Index Lookup Join(ILJ)。
|
||||
|
||||
#### 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 Join(ILJ)就是在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 Join(SMJ),也被称为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数据库中比较常见,通常称为MPP(Massively 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表呢?
|
||||
|
||||
欢迎你在评论区留言和我一起讨论,我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对关联查询这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。
|
||||
250
极客时间专栏/geek/分布式数据库30讲/开发篇/21 | 查询执行引擎:如何让聚合计算加速?.md
Normal file
250
极客时间专栏/geek/分布式数据库30讲/开发篇/21 | 查询执行引擎:如何让聚合计算加速?.md
Normal 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&CockroachDB
|
||||
|
||||
向量化模型最早提出是在[MonerDB-X100(Vectorwise)](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->next()
|
||||
//计算Hash值
|
||||
vec<int> hashes = probeHash_.eval(n)
|
||||
//找到Hash匹配的候选元组
|
||||
vec<Entry*> candidates = ht.findCandidates(hashes)
|
||||
vec<Entry*, int> matches = {}
|
||||
//检测是否匹配
|
||||
while(candidates.size() > 0)
|
||||
vec<bool> 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 => 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)
|
||||
243
极客时间专栏/geek/分布式数据库30讲/开发篇/22|RUM猜想:想要读写快还是存储省?又是三选二.md
Normal file
243
极客时间专栏/geek/分布式数据库30讲/开发篇/22|RUM猜想:想要读写快还是存储省?又是三选二.md
Normal file
@@ -0,0 +1,243 @@
|
||||
<audio id="audio" title="22|RUM猜想:想要读写快还是存储省?又是三选二" 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+Tree,B-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 Factor,WAF)这个指标,就是指实际写入磁盘的数据量和应用程序要求写入数据量之比。对于空间放大有类似的度量单位,也就是空间放大系数(Space Amplification Factor, SAF)。
|
||||
|
||||
我们这个例子中的WAF是7。
|
||||
|
||||
1. 存储不连续
|
||||
|
||||
虽然新增叶节点会加入到原有叶节点构成的有序链表中,整体在逻辑上是连续的,但是在磁盘存储上,新增页表申请的存储空间与原有页表很可能是不相邻的。这样,在后续包含新增叶节点的查询中,将会出现多段连续读取,磁盘寻址的时间将会增加。
|
||||
|
||||
也就是说,虽然B+Tree结构是为读取做了优化,但如果进行大量随机写还是会造成存储的碎片化,从而导致写放大和读放大。
|
||||
|
||||
#### 填充因子
|
||||
|
||||
填充因子(Factor Fill)是一种常见的优化方法,它的原理就是在页表中预留一些空间,这样不会因为少量的数据写入造成树结构的大幅变动。但填充因子的设置也很难拿捏,过大则无法解决写放大问题;过小会造成页表数量膨胀,增大对磁盘的扫描范围,降低查询性能。
|
||||
|
||||
相对于PGXC,NewSQL风格分布式数据库的底层存储引擎则主要采用LSM-Tree。
|
||||
|
||||
### LSM-Tree
|
||||
|
||||
LSM-Tree(Log Structured-Merge Tree)由Patrick O’Neil在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 Table,SSTable)中,这个操作被称为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. 读放大
|
||||
|
||||
因为存在多个SSTable,Leveled策略显然是存在读放大的。因为SSTable是有序的,如果有M个文件,则整体计算的时间复杂度是O(MlogN)。这个地方还可以优化。通常的方法是在SSTable中引入Bloom Filter(BF),这是一个基于概率的数据结构,可以快速地确定一个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竞争。
|
||||
|
||||
### TiDB:WiscKey
|
||||
|
||||
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替换HDD,SSD的随机读写效率接近于顺序读写,所以能够保持较高的整体效率。事实上,过高的写放大也会严重缩短SSD的使用寿命。WiscKey就是针对SSD提出的存储模型。
|
||||
|
||||
说完WiscKey,你或许猜到了TiDB和Cockroach放弃RocksDB的原因。
|
||||
|
||||
没错,TiDB的新存储引擎TiTan就是受到WiscKey的启发,它目标之一就是将 Value 从 LSM-Tree 中分离出来单独存储,以降低写放大。
|
||||
|
||||
### CockroachDB:Pebble
|
||||
|
||||
我们再来看看,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作为存储引擎,包括MySQL(MyRocks)、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 和 L1,Delta 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 O’Neil et al.: [**The Log-Structured Merge-Tree (LSM-Tree)**](https://dsf.berkeley.edu/cs286/papers/lsm-acta1996.pdf)
|
||||
142
极客时间专栏/geek/分布式数据库30讲/开发篇/23 | 数据库查询串讲:重难点回顾+思考题答疑+知识全景图.md
Normal file
142
极客时间专栏/geek/分布式数据库30讲/开发篇/23 | 数据库查询串讲:重难点回顾+思考题答疑+知识全景图.md
Normal 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表,这是典型的基于代价的优化,也就是CBO(Cost 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="">
|
||||
Reference in New Issue
Block a user