147-分布式数据库30讲

开篇词｜为什么要学习分布式数据库？

你好，我是王磊，你也可以叫我 Ivan，现在是光大银行首席数据架构师。这门课，我想和你聊聊分布式数据库这个话题。

说起分布式数据库啊，很多人的第一反应是，这东西还很新吧？一般的公司是不是根本就用不上？我有必要学吗？

分布式数据库可以解决什么问题？

简单来说，分布式数据库就是用分布式架构实现的关系型数据库。注意，我们说的是关系型数据库，所以像 MongoDB 这样的 NoSQL 产品，不是我们这门课要讲的重点。

那为什么要用分布式架构呢？原因很简单，就是性能和可靠性。由于各种原因，IBM 大型机这样的专用设备已经不再是多数企业的可选项，而采用 x86 架构的通用设备在单机性能和可靠性上都不能满足要求，因此分布式架构就成为了一个必然的选择。

你可能会问，哪来那么多高性能和高可靠性需求，有人用吗？别说，还真不少。近几年，阿里巴巴、腾讯、百度、字节跳动、美团、滴滴、快手、知乎、58 等互联网公司，都已经开始使用分布式数据库；而传统的金融、电信行业，也在快速跟进，据我所知，像交通银行、中信银行、光大银行、北京银行和一些城市商业银行，也都已经上线了分布式数据库。可以说，在各种因素的推动下，分布式数据库已经成为一种技术潮流，甚至是新基建的一部分。

分布式数据库能得到广泛使用，其中很重要的因素就是供应商不再是 Oracle 这样的国外商业巨头，越来越多的国内公司和开源软件杀入这个领域。

比如，阿里巴巴的 OceanBase 是高举高打的方式，每年双十一大促都要秀一下性能，虽然这个性能统计方法有待商榷，但毕竟已经应用在关键业务上了。TiDB 也在努力培育市场，技术社区做得有声有色，在互联网领域有了大量实施案例。GoldenDB 已经随着中信银行的新一代核心业务系统上线投产，截至目前平稳运行了三个月左右。其他分布式数据库包括 CockroachDB、YugabyteDB、TBase、TDSQL、巨杉、VoltDB、GaussDB 300 等等，还有很多产品正在赶来的路上。

你看，分布式数据库是名副其实的“供需两旺”。

我们要学分布式数据库的另一个原因在于，你可以通过学习它的设计思想，提高自己的架构设计水平和代码能力。分布式数据库是学术研究与工业实践的完美结合，深入其中你会看到很多极致的设计方法。通过学习分布式数据库的架构设计，形成内化的设计能力，一定是架构师的要诀之一。

比如，我就受益于分布式数据库的设计思想，带领团队一起开发了一款叫作 Pharos 的软件，实现了百亿海量数据下的复杂查询。

抓住主线，高效学习分布式数据库

我猜你可能会觉得分布式数据库很复杂，学起来太难。其实完全不用担心，我们这门课的使命就是要破除神秘感，找出分布式数据库的学习路径，帮你抓住它的核心内容。

那怎么找到这条学习路径呢，这就得从数据库说起了。数据库其实就做了两个操作，读和写。但就这两件事，有时也会冲突，写入快、读取可能就会慢，另外还得考虑存储空间的成本。有个 RUM 猜想就是说这个事情，读放大、写放大、存储空间放大，最多只能避免两个，三选二。这是第一个部分，存储的设计。

系统总是要多人使用吧，这就带来并发的问题，出现写写冲突和读写冲突时采用什么策略，这是第二部分事务模型。

数据库的操作接口是 SQL，基于关系模型来定义数据结构和操作原语，而且还有各种索引、优化措施，让 SQL 执行得更快，这是第三个部分查询引擎。

任何架构都要避免单点故障，所以数据库会有一个复制机制，多个节点形成主备关系，主备之间同步数据，这样可靠性就有了保障，这是第四部分复制。

最后，还有一些必备的辅助工作，客户端接入、权限控制、元数据存储。这样一个基本的数据库就可以运行了。

归纳一下，数据库就是要做好五件事，存储、事务、查询、复制和其他。对分布式数据库来说，不仅要继续做这五件事，还要多出一件事，分片。在这六件事中，存储和其他这两件事与单体数据库差不多，难点就在事务、查询、复制和分片这四件。

我们来具体说说这四件事。

第一件，也就是多出的那一件事，叫分片元数据存储和分片调度。

既然已经是多个节点，那一张表的数据还放在一个节点上吗，是不是该分散一下提高性能? 这样，表就不再是数据的最小存储单元了，换成了分片，也就是表的水平切分下来的一部分，这和分区的概念很像。但是，这一分散，使用数据时总得知道去哪找吧? 这就是分片元数据。另外，这分片也不是静止的，有很多因素会导致分片在节点间移动，比如分片存储的数据太多或者访问压力太大，这就需要对分片进行拆分、合并以及调度。

第二件是事务，准确地说是分布式事务。它和单机事务完全不一样，虽然数据库早就有了 XA 协议作为标准，理论上支持跨库事务，可是那性能实在太差啊。使用 XA 协议的 MySQL 集群，操作延时是单机的 10 倍。这是什么概念？根本没法在生产环境用。所以，还得研究更加高效的分布式事务模型。

第三件是查询，查到数据很容易，难的还是高性能。而且数据都分片了，一个查询任务如何分配，是在某个节点上集中数据还是把逻辑推给各个节点，这都是要设计权衡的。

第四件是复制，也就是高可靠设计，原来的单机复制机制也可以延用，但是在这种复制机制下，只有主节点工作，备节点闲着。现在，新的设计是在分片基础上用 Paxos 协议建立复制组，这样就有了更小的高可靠单元，让每个复制组的主副本交叉部署在多个节点上，就可以充分利用机器资源。

你看，只要抓住了这四件事，是不是就掌握了分布式数据库的学习要点。

采用这种抓主线的学习方式，还能让你避免一下子就陷入安装部署、操作指令等细节中，摆脱学完以后还是不知道产品原理、碰到没见过的问题依然是束手无策的窘境。所以在这门课里，我会带你摆脱这些细节，从原理层面深入分析。具体来说，我会以一个中立的视角去给你剖析主流产品的运行机制和理论依据，横向比较它们的差异，分析这些技术决策背后的动机，帮助你快速建立起对分布式数据库全面的认知体系。

我是怎么设计这门课的？

接下来，我要和你说说整个课程的设计。

我会在基础篇为你讲解分布式数据库的基本概念、主流产品的架构风格、一些基本功能，以及分布式数据库设计的难点，帮助你建立对分布式数据库的整体认知。

在开发篇，我会带你深入到一个个关键功能的设计中，挖掘其背后可选择的理论设计方案，分析方案之间的差异，以及工业界产品在落地实现时的改进。也就是说，开发篇的设计思路是从问题到解决方案，再到产品实现。

这样一来，你不仅能在纵向上搭建一个分布式数据库的多层知识目录，还能从横向上针对每一个关键功能对比各种主流产品的设计选择，最终形成一个网络化的体系。

在实践篇我会聚焦于架构选型，告诉你在企业中引入分布式数据库需要关注哪些事情、做些什么准备，比如会给运维带来哪些冲击、怎么去做测试，其他企业是基于什么原因选择分布式数据库的。同时，我还会为你梳理一份分布式数据库的产品图鉴，带你一起检阅这个时代最酷的基础软件。你也可以将它当成产品维度的课程索引，反向检索产品的设计。

还有，我必须再次声明一下：各种分布式数据库产品的安装部署、操作指令、性能调优等都不在这次课程的范畴内。一方面，这确实超出了我的个人能力，毕竟要面对如此多的产品；另一方面，只要你选择了正确的方向，就很容易从其他渠道获得详实的资料和具体的指导。

另外，关于必备基础我也要提一下，想要学习这门课，是不是得对数据库的内部运行机制或者分布式技术有深刻的认识呀？你放心，不需要这么多基础。我上面介绍的课程设计思路，其实就是为了帮你可以低门槛地学习，只要你具备一定的编程基础，有一些数据库的使用经验，以及对 SQL 运行优化有直观的感受，就能够从课程里汲取前人的智慧、提升自己的技术竞争力。

关于我

说了这么多，还没有和你介绍下我自己。我在数据领域有超过 15 年的工作经验了，一直在关注企业数据架构、大数据生态体系以及分布式架构，服务过多家大型金融机构。

从 2013 年开始，作为数据领域的主要设计者，我推动了光大银行从传统数据仓库向大数据生态的转型，主导了大数据开发平台、数据中台等多个重要系统的架构设计工作，获得了银行业的多个技术奖项，是大数据技术在金融行业的第一批践行者。

2018 年，光大银行启动了分布式数据库选型工作，我作为技术专家深度参与了这项工作。在调研过程中，我有幸与很多产品专家进行了深入的讨论，甚至是争论。这个过程，让我有机会了解各种产品在设计背后的考虑和权衡，也拓展了对当前工业界工艺水准的整体认识。

最后我想说的是，分布式数据库凝聚了无数学者与工程师的智慧，灿若星辰。希望这个课程能带你穿越时空，开启一场与大师的对话之旅。

如果你身边也有些想要或者必须要学习分布式数据库的朋友，我希望你把这个课程分享给他 / 她，你们可以一起学习，互相鼓励。

欢迎你多多给我留言，与分布式数据库相不相关都可以，只要是我熟悉的领域就一定会认真给你答复。今天是开篇词，也希望你留言说说自己对这门课的期待，或者自己目前遇到的问题，我们下一讲见！

01｜什么是分布式数据库？

你好，我是王磊，你也可以叫我 Ivan。

在这门课的第 1 讲，我想和你探讨一个最基本的问题：什么是分布式数据库？

回答这个问题，其实就是在给分布式数据库下定义。

分布式数据库和很多技术概念一样，没有权威机构来做这个定义，甚至对于哪些机构是权威机构，我们都很难有共识。

作为技术人员，我们常会提到一个概念，叫“事实标准”。当一个技术产品占据市场的主导位置时，它自然就成了同类产品的事实标准。例如，对于关系型数据库，可以说 Oracle 就是事实标准，因为所有数据库产品发布新版本时，都要拿自己的特性去和 Oracle 比一比。

很遗憾，分布式数据库作为一个新兴的基础软件，还没有一款产品占据“事实标准”的位置。既然没有参照，我们就自己动手，一起来定义分布式数据库这个概念吧。

由表及里、由外到内是人们认识事物的普遍规律，所以我们让也从内外部两个视角来观察。

外部视角：外部特性

外部视角，就是看看分布式数据库具备哪些特性，能解决什么问题。

通常，业务应用系统可以按照交易类型分为联机交易（OLTP）场景和联机分析（OLAP）场景两大类。OLTP 是面向交易的处理过程，单笔交易的数据量小，但是要在很短的时间内给出结果，典型场景包括购物、缴费、转账等；而 OLAP 场景通常是基于大数据集的运算，典型场景包括生成个人年度账单和企业财务报表等。

OLTP 与 OLAP 两种场景有很大的差异，所以很难在一款产品中完全满足两者的需求，因此在单体数据库时代就演化出了两个的不同技术体系，也就是两类不同的关系型数据库。向分布式架构演进后，两者在架构设计上也采用了完全不同的策略，很难在一个框架下说清楚。

所以，为了让你有更好的学习体验，在这个课程中，我们先专注于讨论 OLTP 场景下的分布式数据库。

说到这里，我想和你统一一下概念，如果没有特别说明，这个课程中出现的“数据库”都默认为“关系型数据库”，分布式数据库也都是指支持关系模型的分布式数据库。这就是说，NoSQL 不是我们要讨论的核心内容。

你可能会说，NoSQL 也很重要呀，MongoDB 多火呀。

NoSQL 当然很重要，MongoDB 确实也在一些细分场景中取得了成功。但是，从整体看，关系型数据库由于支持 SQL、提供 ACID 事务保障，显然具有更好的通用性，在更广泛的场景中无法被 NoSQL 取代。这一点通过 NoSQL 十余年的发展已经被证明。

事实上，分布式数据库的目标正是融合传统关系型数据库与 NoSQL 数据库的优势，而且已经取得了不错的效果。

定义 1.0 OLTP 关系型数据库

仅用“OLTP 场景”作为定语显然不够精准，我们来进一步看看 OLTP 场景具体的技术特点。

OLTP 场景的通常有三个特点：

写多读少，而且读操作的复杂度较低，一般不涉及大数据集的汇总计算；

低延时，用户对于延时的容忍度较低，通常在 500 毫秒以内，稍微放大一些也就是秒级，超过 5 秒的延时通常是无法接受的；

高并发，并发量随着业务量而增长，没有理论上限。

我们是不是可以有这样一个结论：分布式数据库是服务于写多读少、低延时、高并发的 OLTP 场景的数据库。

定义 2.0 + 海量并发

你可能会说这个定义有问题，比如 MySQL 和 Oracle 这样的关系型数据库也是服务于 OLTP 场景的，但它们并不是分布式数据库。

你的感觉没错，确实有问题。

那么，相对于传统关系型数据库，分布式数据库最大的差异是什么呢？答案就是分布式数据库远高于前者的并发处理能力。

传统关系型数据库往往是单机模式，也就是主要负载运行在一台机器上。这样，数据库的并发处理能力与单机的资源配置是线性相关的，所以并发处理能力的上限也就受限于单机配置的上限。这种依靠提升单机资源配置来扩展性能的方式，被称为垂直扩展（Scale Up）。

在一台机器中，随随便便就能多塞进些 CPU 和内存来提升提性能吗？当然没那么容易。所以，物理机单机配置上限的提升是相对缓慢的。

这意味着，在一定时期内，依赖垂直扩展的数据库总会存在性能的天花板。很多银行采购小型机或大型机的原因之一，就是相比 x86 服务器，这些机器能够安装更多的 CPU 和内存，可以把天花板推高一些。

而分布式数据库就不同了，在维持关系型数据库特性不变的基础上，它可以通过水平扩展，也就是增加机器数量的方式，提供远高于单体数据库的并发量。这个并发量几乎不受单机性能限制，我将这个级别的并发量称为“海量并发”。

听到这里你可能还要追问，这个“海量并发”到底是多大呢，有没有一个数字？

很遗憾，据我所知并没有权威数字。虽然理论上是可以找一台世界上最好的机器来测试一下，但考虑到商业因素，这个数字不会有什么实际价值。不过，我可以给出一个经验值，这个“海量并发”的下限大致是 10,000TPS。如果你有相关的经验，也欢迎你在评论区留言，我们一起讨论。

现在，基于这些理解，我们可以再得到一个 2.0 版本的定义：分布式数据库是服务于写多读少、低延时、海量并发 OLTP 场景的关系型数据库。

定义 3.0 + 高可靠

这个定义你觉得满意吗？

其实，这个 2.0 版本仍然有问题。

是不是没有海量并发需求，就不需要使用分布式数据库了呢？不是的，你还要考虑数据库的高可靠性。

一般来说，可靠性是与硬件设备的故障率有关的。

与银行不同，很多互联网公司和中小企业通常是采用 x86 服务器的。x86 服务器有很多优势，但故障率会相对高一些，坊间流传的年故障率在 5% 左右。

一些更加可靠的数据来自 Google 的论文Failure Trends in a Large Disk Drive Population，文中详细探讨了通用设备磁盘的故障情况。它给出的磁盘年度故障率的统计图，如下所示：

可以看到，前三个月会超过 2% 的磁盘损坏率，到第二年这个数字会上升到 8% 左右。

你可能会说，这个数字也不是很高啊。

但你要知道，对金融行业的关键应用系统来说，通常是要求具备 5 个 9 的可靠性（99.999%），也就是说，一年中系统的服务中断时间不能超过 5.26 分钟（365*24*60*（1-99.999%） ≈ 5.26 ）。

而且，不只是金融行业，随着人们对互联网的依赖，越来越多的系统都会有这样高的可靠性要求。

根据这两个数字，我们可以设想一下，如果你所在的公司有四、五个关键业务系统，十几台数据库服务器，磁盘数量一定会超过 100 个吧？那么我们保守估计，按照损坏率 2% 来算，一年中就会碰到 2 次磁盘损坏的情况，要达到 5 个 9 的可靠性你就只有 5.26 分钟，能处理完一次磁盘故障吗？这几乎是做不到的，可能你刚冲到机房，时间就用完了。

我猜你会建议用 RAID（独立冗余磁盘阵列）来提高磁盘的可靠性。这确实是一个办法，但也会带来性能上的损耗和存储空间上的损失。分布式数据库的副本机制可以比 RAID 更好地平衡可靠性、性能和空间利用率三者的关系。副本机制就是将一份数据同时存储在多个机器上，形成多个物理副本。

回到数据库的话题上，可靠性还要更复杂一点，包括两个度量指标，恢复时间目标（Recovery Time Objective, RTO）和恢复点目标（Recovery Point Objective, RPO）。RTO 是指故障恢复所花费的时间，可以等同于可靠性；RPO 则是指恢复服务后丢失数据的数量。

数据库存储着重要数据，而金融行业的数据库更是关系到客户资产安全，不能容忍任何数据丢失。所以，数据库高可靠意味着 RPO 等于 0，RTO 小于 5 分钟。

传统上，银行通过两种方法配合来实现这个目标。

第一种还是采购小型机和大型机，因为它们的稳定性优于 x86 服务器。

第二种是引入专业存储方案，例如 EMC 的 Symmetrix 远程镜像软件（Symmetrix Remote Data Facility, SRDF）。数据库采用主备模式，在高端共享存储上保存数据库文件和日志，使数据库近似于无状态化。主库一旦出现问题，备库启动并加载共享存储的文件，继续提供服务。这样就可以做到 RPO 为零，RTO 也比较小。

但是，这套方案依赖专用的软硬件，不仅价格昂贵，而且技术体系封闭。在去 IOE（IBM 小型机、Oracle 数据库和 EMC 存储设备）的大背景下，我们必须另辟蹊径。分布式数据库则是一个很好的备选方案，它凭借节点之间的互为备份、自动切换的机制，降低了 x86 服务器的单点故障对系统整体的影响，提供了高可靠性保障。

令人兴奋的是，这种单点故障处理机制甚至可以延展到机房层面，通过远距离跨机房部署。如此一来，即使在单机房整体失效的情况下，系统仍然能够正常运行，数据库永不宕机。

至此，我们得出一个 3.0 版本的定义，分布式数据库是服务于写多读少、低延时、海量并发 OLTP 场景的，高可靠的关系型数据库。

定义 4.0 + 海量存储

还有没有 4.0 版本呢？

你猜的没错，我们还要补充一些存储能力的变化。

虽然单体数据库依靠外置存储设备可以扩展存储能力，但这种方式本质上不是数据库的能力。现在，借助分布式的横向扩展架构，通过物理机的本地磁盘就可以获得强大的存储能力，这让海量存储成为分布式数据库的标配。

最后，我们终于得到一个 4.0 终极版本的定义，分布式数据库是服务于写多读少、低延时、海量并发 OLTP 场景的，具备海量数据存储能力和高可靠性的关系型数据库。

内部视角：内部构成

只通过外部视角来看分布式数据库，已经足够了吗？其实，具有相同的外在特性和功效，未必就是同样的事物。

举个例子，哥白尼刚提出“日心说”来反驳“地心说”的时候，要用到 34 个圆周来解释天体的运动轨迹；而 100 多年后，开普勒只用 7 个椭圆就达到了同样的效果，彻底摧毁了“地心说”。从哥白尼到开普勒，效果近似，简洁程度却大不一样，这背后代表的是巨大的科学进步。

因此，讲完分布式数据库的外部特性之后，我们还要从内部视角来进行观察。

事实上，为了应对海量存储和海量并发，很多解决方案在效果上跟我们 4.0 版本的定义很相似。但是，它们向用户暴露了太多的内部复杂性。在我看来，对用户约束太多、使用过程太复杂、不够内聚的方案，不能称为成熟的产品。同时，业界的主流观点并不认为它们是分布式数据库，所以我们这门课也就不重点讨论了。

为了让你看清其中的差别，我将这些方案简单地分类介绍一下。

客户端组件 + 单体数据库

通过独立的逻辑层建立数据分片和路由规则，实现单体数据库的初步管理，使应用能够对接多个单体数据库，实现并发、存储能力的扩展。其作为应用系统的一部分，对业务侵入较为深。

这种客户端组件的典型产品是 Sharding-JDBC。

代理中间件 + 单体数据库

以独立中间件的方式，管理数据规则和路由规则，以独立进程存在，与业务应用层和单体数据库相隔离，减少了对应用的影响。随着代理中间件的发展，还会衍生出部分分布式事务处理能力。

这种中间件的典型产品是 MyCat。

单元化架构 + 单体数据库

单元化架构是对业务应用系统的彻底重构，应用系统被拆分成若干实例，配置独立的单体数据库，让每个实例管理一定范围的数据。例如对于银行贷款系统，可以为每个支行搭建独立的应用实例，管理支行各自的用户，当出现跨支行业务时，由应用层代码通过分布式事务组件保证事务的 ACID 特性。

根据不同的分布式事务模型，应用系统要配合改造，复杂性也相应增加。例如 TCC 模型下，应用必须能够提供幂等操作。

在分布式数据库出现前，一些头部互联网公司使用过这种架构风格，该方案的应用系统的改造量最大，实施难度也最高。

看过这三种方案，我相信你能够明白，它们共同的特点是单体数据库仍然能够被应用系统感知到。相反，分布式数据库则是将技术细节收敛到产品内部，以一个整体面对业务应用。

我猜，看到这里你一定很想知道，分布式数据库的内部架构到底长什么样呢？它跟这三种方案有什么区别呢？回答这个复杂的问题，就是我们这门课的使命了。这里你也可以先记下自己的答案，等学完这门课以后再回过头来做个对比，也是对自己学习效果的一种检验。

小结

好了，以上就是今天的主要内容了，我们来小结一下。

我们通过逐层递进的方式，勾勒出分布式数据库的六个外部特性，分别是写多读少、低延时、海量并发、海量存储、高可靠性、关系型数据库。

同时，也存在一些与分布式数据库能力近似的解决方案，这些方案的不足之处是都需要对应用系统进行一定的改造，对应用的侵入程度更深；其优势则在于可以最大程度利用单体数据库的稳定可靠，毕竟这些特性已经历经无数次的考验。

最后，我想就分布式数据库的名称做一些延伸。

“分布式数据库”在字面上可以分解为“分布式”和“数据库”两部分，代表了它是跨学科的产物，它的理论基础来自两个领域。这同时也呼应了产品发展的两条不同路径，一些产品是从分布式存储系统出发，进而增加关系型数据库的能力；另外一些产品是从单体数据库出发，增加分布式技术元素。而随着分布式数据库的走向工业应用，在外部需求的驱动下，这两种发展思路又呈现出进一步融合的趋势。

思考题

在准备分布式数据库这门课的过程中，有的朋友建议我讲讲 Aurora，但其实 Aurora 和这里说的分布式数据库还是有明显差别的，所以没有纳入正式课程。你了解 Aurora 或者它的同类产品吗？你觉得它和我所说的分布式数据库之间的差异是什么？那导致这种差异的原因又是什么呢？

欢迎你在评论区留言和我一起讨论，我会在答疑篇回复这个问题，如果感兴趣的同学很多，说不定会有加餐哦。

最后，如果你身边也有朋友对如何定义分布式数据库这个概念有困惑，欢迎你把今天这一讲分享给他，我们一起讨论。

02｜强一致性：那么多数据一致性模型，究竟有啥不一样？

你好，我是王磊，你也可以叫我 Ivan。

我们经常会听到说，分布式数据库的一个优势在于，它能够支持 NoSQL 做不到的强一致性。你怎么看待这件事儿呢？

显然，要来分析这个问题，我们首先得明白“强一致性”意味着什么。

我也问过很多身边的朋友，他们的答案都不太一样。有人说，只要使用了 Paxos 或者 Raft 算法，就可以实现强一致性；也有人说，根据 CAP 原理只能三选二，分区容忍性和高可用性又是必不可少的，所以分布式数据库是做不到强一致性的。可是，这些观点或多或少都是有问题的。

那么，今天我们就来讲讲什么是“强一致性”。

一直以来，在“分布式系统”和“数据库”这两个学科中，一致性（Consistency）都是重要概念，但它表达的内容却并不相同。

对于分布式系统而言，一致性是在探讨当系统内的一份逻辑数据存在多个物理的数据副本时，对其执行读写操作会产生什么样的结果，这也符合 CAP 理论对一致性的表述。

而在数据库领域，“一致性”与事务密切相关，又进一步细化到 ACID 四个方面。其中，I 所代表的隔离性（Isolation），是“一致性”的核心内容，研究的就是如何协调事务之间的冲突。

因此，当我们谈论分布式数据库的一致性时，实质上是在谈论数据一致性和事务一致性两个方面。这一点，从 Google Spanner 对其外部一致性（External Consistency）的论述中也可以得到佐证。

数据一致性

今天，我会先介绍数据一致性，下一讲中，我再为你讲解事务一致性以及它们之间的关系。

包括分布式数据库在内的分布式存储系统，为了避免设备与网络的不可靠带来的影响，通常会存储多个数据副本。逻辑上的一份数据同时存储在多个物理副本上，自然带来了数据一致性问题。

讨论数据一致性还有一个前提，就是同时存在读操作和写操作，否则也是没有意义的。把两个因素加在一起，就是多副本数据上的一组读写策略，被称为“一致性模型”（Consistency Model）。一致性模型数量很多，让人难以分辨。为了便于你理解，我先建立一个简单的分析框架。

这里，我要借用论文“The many faces of consistency”中的两个概念，状态一致性（State Consistency）和操作一致性（Operation Consistency）。不要慌，这不是新的一致性模型，它们只是观察数据一致性的两个视角。

状态一致性是指，数据所处的客观、实际状态所体现的一致性；

操作一致性是指，外部用户通过协议约定的操作，能够读取到的数据一致性。

状态视角

从状态的视角来看，任何变更操作后，数据只有两种状态，所有副本一致或者不一致。在某些条件下，不一致的状态是暂时，还会转换到一致的状态，而那些永远不一致的情况几乎不会去讨论，所以习惯上大家会把不一致称为“弱一致”。相对的，一致就叫做“强一致”了。

下面，我以 MySQL 为例来说明状态视角的“强一致”。

强一致性：MySQL 全同步复制

现在有一个 MySQL 集群，由一主两备三个节点构成，那么在全同步复制（Fully Synchronous Replication）模式下，用户与 MySQL 交互的过程是这样的。

在该模式下，主库与备库同步 binlog 时，主库只有在收到两个备库的成功响应后，才能够向客户端反馈提交成功。

显然，用户获得响应时，主库和备库的数据副本已经达成一致，所以后续的读操作肯定是没有问题的，但这种模式的副作用非常大，体现在以下两点。

第一，性能差。主库必须等到两个备库均返回成功后，才能向用户反馈提交成功。图中由于网络阻塞，“备库 2”稍晚于“备库 1”返回响应，增加了数据库整体的延时。而下一次，拖后腿的可能变成“备库 1”。总之，主库的响应时间取决于两个备库中延时最长的那个。

第二，可用性问题。我们在第 1 讲提到过可用性概念，任何设备都有可能出现故障，尤其是 x86 这样的通用商业设备，故障率会更高。但在全同步复制模式下，集群中的三个节点被串联起来，如果单机可用性是 95%，那么集群整体的可用性就是 85.7%（95%*95%*95%=85.7%），跟单机相比反而降低了。

集群规模越大，这些问题就越严重，所以全同步复制模式在生产系统中也很少使用。更进一步说，在工程实践中，实现状态视角的强一致性需要付出的代价太大，尤其是与可用性有无法回避的冲突，所以很多产品选择了状态视角的弱一致性。

弱一致性：NoSQL 最终一致性

NoSQL 产品是应用弱一致性的典型代表，但对弱一致性的接受仍然是有限度的，这就是 BASE 理论中的 E 所代表的最终一致性（Eventually Consistency），弱于最终一致性的产品就几乎没有了。

对于最终一致性，你可以这样理解：在主副本执行写操作并反馈成功时，不要求其他副本与主副本保持一致，但在经过一段时间后这些副本最终会追上主副本的进度，重新达到数据状态的一致。

你再仔细推敲一下，是不是觉得这个定义还有点含糊？“经过一段时间”到底是多久呢？几秒还是几分钟？如果是一个不确定的数值，怎么在工程中使用呢？

这就需要我们从操作视角来分析了。

操作视角

最终一致性，在语义上包含了很大的不确定性，所以很多时候并不是直接使用，而是加入一些限定条件，也就衍生出了若干种一致性模型。因为它们是在副本不一致的情况下，进行操作层面的封装来对外表现数据的状态，所以都可以纳入操作视角。

接下来，我会挑选 5 个常见的一致性模型逐一讲解。

写后读一致性

首先来说“写后读一致性”（Read after Write Consistency），它也称为“读写一致性”，或“读自己写一致性”（Read My Writes Consistency）。你可能觉得最后一个名字听上去有些奇怪，但它却最准确地描述了这种一致性模型的使用效果。

我还是用一个例子来说明。

小明很喜欢在朋友圈分享自己的生活。这天是小明和女友小红的相识纪念日，小明特意在朋友圈分享了一张两人的情侣照。小明知道小红会很在意，特意又刷新了一下朋友圈，确认照片分享成功。

你是否意识到这个过程中系统已经实现了“写后读一致性”？我画了张流程图来表示这个过程。

小明发布照片的延时极短，用户体验很好。这是因为数据仅被保存在主副本 R1 上，就立即反馈保存成功。而其他副本在后台异步更新，由于网络的关系每个副本更新速度不同，在 T2 时刻上海的两个副本达成一致。从过程来看，这与前面所说的“最终一致性”完全相符。

要特别注意的是，小明有一个再次刷新朋友圈的动作，这时如果访问副本 R2，由于其尚未完成同步，情侣照将会消失，小明就会觉得自己的照片被弄丢了。此处，我们假定系统可以通过某种策略由写入节点的主副本 R1 负责后续的读取操作，这样就实现了写后读一致性，可以保证小明再次读取到照片。

自己写入成功的任何数据，下一刻一定能读取到，其内容保证与自己最后一次写入完全一致，这就是“读自己写一致性”名字的由来。当然，从旁观者角度看，可以称为“读你写一致性”（Read Your Writes Consistency），有些论文确实采用了这个名称。

单调读一致性

但是，小明发完朋友圈之后，小红一定能看到照片吗？会不会发生异常呢？

这次确实出问题了。

此时，小红也在刷朋友圈，看到了小明刚刚分享的照片，非常开心。然后，小红收到一条信息，简单回复了一下，又回到朋友圈再次刷新，发现照片竟然不见了！小红很生气，打电话质问小明，为什么这么快就把照片删掉？小明听了一脸蒙，心想我没有删除呀。

你猜这中间发生了什么呢？我用另一张流程图来演示这种异常。

在小明发布照片后的瞬间，小红也刷新了朋友圈，此时读取到副本 R1，所以小红看到了照片；片刻之后，小红再次刷新，此时读取到的副本是 R2，于是照片消失了。小红以为小明删除了照片，但实际上这完全是程序错误造成的，数据向后回滚，出现了“时光倒流”。

想要排除这种异常，系统必须实现单调读一致性（Monotonic Read Consistency）。关于单调读一致性的定义，常见的解释是这样的：一个用户一旦读到某个值，不会读到比这个值更旧的值。

是不是感觉有点蒙？让我来解释一下。

假如，变量 X 被赋值三次，依次是 10、20、30；之后读取变量 X，如果第一次读到了 20，那下一次只有读到 20 或 30 才是合理的。因为在第一次读到 20 的一刻，意味着 10 已经是过期数据，没有意义了。

实现单调读一致性的方式，可以是将用户与副本建立固定的映射关系，比如使用哈希算法将用户 ID 映射到固定副本上，这样避免了在多个副本中切换，也就不会出现上面的异常了。

前缀一致性

但是，在一些更复杂的场景下还是会出现时间的扭曲。我再用一个例子来说明。

这天小明去看 CBA 总决赛，刚开球小明就拍了一张现场照片发到朋友圈，想要炫耀一下。小红也很喜欢篮球，但临时有事没有去现场，就在评论区问小明：“现在比分是多少？”小明回复：“4:2。”

小明的同学，远在加拿大的小刚，却看到了一个奇怪的现象，评论区先出现了小明的回复“4:2。”，而后才刷到小红的评论“现在比分是多少？”。难道小明能够预知未来吗？

这是什么原因呢？我们还是看图说话。

小明和小红的评论分别写入了节点 N1 和 N2，但是它们与 N3 同步数据时，由于网络传输的问题，N3 节点接收数据的顺序与数据写入的顺序并不一致，所以小刚是先看到答案后看到问题。

显然，问题与答案之间是有因果关系的，但这种关系在复制的过程中被忽略了，于是出现了异常。

保持这种因果关系的一致性，被称为前缀读或前缀一致性（Consistent Prefix）。要实现这种一致性，可以考虑在原有的评论数据上增加一种显式的因果关系，这样系统可以据此控制在其他进程的读取顺序。

线性一致性

在“前缀一致性”的案例中，问题与答案之间存在一种显式声明，但在现实中，多数场景的因果关系更加复杂，也不可能要求全部做显式声明。

比如对于分布式数据库来说，它无法要求应用系统在每次变更操作时附带声明一下，这次变更是因为读取了哪些数据而导致的。

那么，在显式声明无法奏效的情况下，如何寻找因果关系呢？

不知道你有没有听过这句话，“你所经历的一切，造就了现在的你。”是不是有一点哲学的味道？一切对原因的推测都是主观的，之前发生的一切都可能是原因。

所以，更可靠的方式是将自然语意的因果关系转变为事件发生的先后顺序。

线性一致性（Linearizability）就是建立在事件的先后顺序之上的。在线性一致性下，整个系统表现得好像只有一个副本，所有操作被记录在一条时间线上，并且被原子化，这样任意两个事件都可以比较先后顺序。

这些事件一起构成的集合，在数学上称为具有“全序关系”的集合，而“全序”也称为“线性序”。我想，线性一致性大概就是因此得名。

但是，集群中的各个节点不能做到真正的时钟同步，这样节点有各自的时间线。那么，如何将操作记录在一条时间线上呢？这就需要一个绝对时间，也就是全局时钟。

从产品层面看，主流分布式数据库大多以实现线性一致性为目标，在设计之初或演进过程中纷纷引入了全局时钟，比如 Spanner、TiDB、OceanBase、GoldenDB 和巨杉等等。

工程实现上，多数产品采用单点授时（TSO），也就是从一台时间服务器获取时间，同时配有高可靠设计； 而 Spanner 以全球化部署为目标，因为 TSO 有部署范围上的限制，所以 Spanner 的实现方式是通过 GPS 和原子钟实现的全局时钟，也就是 True Time，它可以保证在全球范围内任意节点能同时获得的一个绝对时间，误差在 7 毫秒以内。

但是，对于线性一致性，学术界其实是有争议的。反对者的论据来自爱因斯坦的相对论的一个重要结论，“时间是相对的”。没有绝对时间，也就不存在全序的事件顺序，不同的观察者可能对于哪个事件先发生是无法达成一致的。因此，线性一致性是有局限性的。

当然，从工程角度看，因为我们的应用场景都在经典物理学适用范围内，所以线性一致性也是适用的。

因果一致性

既然线性一致性不够完美，那么有没有不依赖绝对时间的方法呢？

当然是有的，这就是因果一致性（Causal Consistency）。

因果一致性的基础是偏序关系，也就是说，部分事件顺序是可以比较的。至少一个节点内部的事件是可以排序的，依靠节点的本地时钟就行了；节点间如果发生通讯，则参与通讯的两个事件也是可以排序的，接收方的事件一定晚于调用方的事件。

基于这种偏序关系，Leslie Lamport 在论文“Time, Clocks, and the Ordering of Events in a Distributed System”中提出了逻辑时钟的概念。

借助逻辑时钟仍然可以建立全序关系，当然这个全序关系是不够精确的。因为如果两个事件并不相关，那么逻辑时钟给出的大小关系是没有意义的。

多数观点认为，因果一致性弱于线性一致性，但在并发性能上具有优势，也足以处理多数的异常现象，所以因果一致性也在工业界得到了应用。

具体到分布式数据库领域，CockroachDB 和 YugabyteDB 都在设计中采用了逻辑混合时钟（Hybrid Logical Clocks），这个方案源自 Lamport 的逻辑时钟，也取得了不错的效果。因此，这两个产品都没有实现线性一致性，而是接近于因果一致性，其中 CockroachDB 将自己的一致性模型称为“No Stale Reads”。

时间对于任何一种分布式系统来说都是非常重要的，在分布式数据库中还会牵扯到数据一致性以外的很多话题，所以有关时间、全局时钟和逻辑时钟的内容，我还会在后续课程中提到并作详细讨论。

小结

好了，今天的内容就到这里。我们一起学习了数据一致性，希望你能够记住以下几点：

一致性模型林林总总，数量繁多，但我们总可以从状态和操作这两个视角来观察，进而梳理出其读写操作的不同策略。

从状态视角看，数据一致性只有两种状态，强一致或弱一致，而在实际系统中强一致是非常少见的，最终一致性是弱一致性的特殊形式；

从操作视角看，最终一致性可以被封装成多种一致性模型，甚至是最强的线性一致性。

分布式数据库主要应用了线性一致性或因果一致性。线性一致性必须要有全局时钟，全局时钟可能来自授时服务器或者特殊物理设备（如原子钟），全局时钟的实现方式会影响到集群的部署范围；因果一致性可以通过逻辑时钟实现，不依赖于硬件，不会限制集群的部署范围。

今天介绍的几种一致性模型，用一致性强度来衡量的话：线性一致性强于因果一致性；而写后读一致性、单调读一致性、前缀一致性弱于前两者，但这三者之间无法比较强弱。还有一种常被提及的顺序一致性（Sequentially Consistent），其强度介于线性一致性与因果一致性之间，由于较少在分布式数据库中使用，所以并没有介绍。

综上所述，我们提到的一致性模型强度排序如下：

线性一致性 > 顺序一致性 > 因果一致性 > { 写后读一致性，单调一致性，前缀一致性 }

此外，还有一些常见的弱一致性模型今天并没有提到，包括有限旧一致性（Bounded Staleness）、会话一致性（Session Consistency）、单调写一致性（Monotonic Write Consistency）和读后写一致性（Write Follows Read Consistency）等。如果你感兴趣，可以在 Azure Cosmos DB 的官方文档找到非常详细的说明。

思考题

课程的最后，我要给你留一道思考题。我们今天集中讨论了数据一致性，但是并没有特别强调 Paxos 的作用。这等于是说，Paxos 不是实现强一致性的必要条件。可是，有些时候大家又会将 Paxos 称为一致性协议。你觉得这个“一致性协议”和数据一致性又是什么关系呢？

欢迎你在评论区留言和我一起讨论，我会在答疑篇回复这个问题。最后，谢谢你的收听，如果你身边的朋友也对强一致性或者数据一致性这个话题感兴趣，欢迎你把今天这一讲分享给他，我们一起讨论。

03｜强一致性：别再用BASE做借口，来看看什么是真正的事务一致性

你好，我是王磊，你也可以叫我 Ivan。

在上一讲的开头，我提了一个问题：对分布式数据库来说，“强一致性”意味着什么？我们经过分析后得出的结论是这个强一致性，包括数据一致性和事务一致性两个方面。然后，我们介绍了数据一致性是怎么回事儿。那么，今天我们会继续这个话题，谈谈事务一致性。

每次，我和熟悉 NoSQL 同学聊到事务这个话题时，都会提到 ACID 和 BASE。甚至，不少同学会觉得 ACID 有些落伍了，以 BASE 为理论基础的 NoSQL，才是当下的潮流。

那我们来看看 BASE 是什么？其实，它代表了三个特性，BA 表示基本可用性（Basically Available），S 表示软状态（Soft State），E 表示最终一致性（Eventual Consistency）：

基本可用性，是指某些部分出现故障，那么系统的其余部分依然可用。

软状态或柔性事务，是指数据处理过程中，存在数据状态暂时不一致的情况，但最终会实现事务的一致性。

最终一致性，是指单数据项的多副本，经过一段时间，最终达成一致。这个，我们在第 2 讲已经详细说过了。

总体来说，BASE 是一个很宽泛的定义，所做的承诺非常有限。我认为，BASE 的意义只在于放弃了 ACID 的一些特性，从而更简单地实现了高性能和可用性，达到一个新的平衡。但是，架构设计上的平衡往往都是阶段性的，随着新技术的突破，原来的平衡点也自然会改变。你看，不用说分布式数据库，就连不少 NoSQL 也开始增加对事务的支持了。

所以说，风水轮流转，今天 ACID 已经是新的后浪了。

事务的 ACID 特性

在数据库中，“事务”是由多个操作构成的序列。1970 年詹姆斯 · 格雷（Jim Gray）提出了事务的 ACID 四大特性，将广义上的事务一致性具化到了原子性、一致性、隔离性和持久性这 4 个方面。我们先来看一下他在 Transaction Processing Concepts and Techniques 中给出的定义：

Atomicity: Either all the changes from the transaction occur (writes, and messages sent), or none occur.

Consistency: The transaction preserves the integrity of stored information.

Isolation: Concurrently executing transactions see the stored information as if they were running serially (one after another).

Durability: Once a transaction commits, the changes it made (writes and messages sent) survive any system failures.

翻译过来的意思就是：

原子性：事务中的所有变更要么全部发生，要么一个也不发生。

一致性：事务要保持数据的完整性。

隔离性：多事务并行执行所得到的结果，与串行执行（一个接一个）完全相同。

持久性：一旦事务提交，它对数据的改变将被永久保留，不应受到任何系统故障的影响。

虽然 ACID 名义上并列为事务的四大特性，但它们对于数据库的重要程度并不相同。我用一张图来表示它们的关系。

我们依次来看下。

第一个是一致性，它无疑是其中存在感最低的特性，可以看作是对 “事务”整体目标的阐述。它并没有提出任何具体的功能需求，所以在数据库中也很难找到针对性的设计。

第二个是持久性，它不仅是对数据库的基本要求。如果你仔细琢磨下持久性的定义，就会发现它的核心思想就是要应对系统故障。怎么理解系统故障呢？我们可以把故障分为两种。

存储硬件无损、可恢复的故障。这种情况下，主要依托于预写日志（Write Ahead Log, WAL）保证第一时间存储数据。WAL 采用顺序写入的方式，可以保证数据库的低延时响应。WAL 是单体数据库的成熟技术，NoSQL 和分布式数据库都借鉴了过去。

存储硬件损坏、不可恢复的故障。这种情况下，需要用到日志复制技术，将本地日志及时同步到其他节点。实现方式大体有三种：第一种是单体数据库自带的同步或半同步的方式，其中半同步方式具有一定的容错能力，实践中被更多采用；第二种是将日志存储到共享存储系统上，后者会通过冗余存储保证日志的安全性，亚马逊的 Aurora 采用了这种方式，也被称为 Share Storage；第三种是基于 Paxos/Raft 的共识算法同步日志数据，在分布式数据库中被广泛使用。无论采用哪种方式，目的都是保证在本地节点之外，至少有一份完整的日志可用于数据恢复。

第三个是原子性，是数据库区别于其他存储系统的重要标志。在单体数据库时代，原子性问题已经得到妥善解决，但随着向分布式架构的转型，在引入不可靠的网络因素后，原子性又成为一个新的挑战。

要在分布式架构下支持原子性并不容易，所以不少 NoSQL 产品都选择绕过这个问题，聚焦到那些对原子性不敏感的细分场景。例如，大名鼎鼎的 Google BigTable 甚至是不支持跨行事务的。但是，这种妥协也造成了 NoSQL 的通用性不好。

我们在开篇词就说过，这门课程讨论的分布式数据库是在分布式架构上实现的关系型数据库，那么就必须支持事务，首先就要支持原子性。原子性，在实现机制上较为复杂，目标却很简单，和分成多个级别的隔离性不同，原子性就只有支持和不支持的区别。有关原子性的实现机制，我将在第 9 讲中专门介绍。

最后一个是隔离性，它是事务中最复杂的特性。隔离性分为多个隔离级别，较低的隔离级别就是在正确性上做妥协，将一些异常现象交给应用系统的开发人员去解决，从而获得更好的性能。

可以说，事务模型的发展过程就是在隔离性和性能之间不断地寻找更优的平衡点。我觉得，甚至可以说事务的核心就是隔离性。而不同产品在事务一致性上的差别，也完全体现在隔离性的实现等级上，所以我们必须搞清楚隔离等级具体是指什么。

ANSI SQL-92：对隔离级别最早、最正式的定义

最早、最正式的对隔离级别的定义，是 ANSI SQL-92（简称 SQL-92），它定义的隔离级别和异常现象如下所示：

SQL-92 定义了四个隔离级别和三种异常现象，这些内容网上很多文章都说得比较清楚，我就不再啰嗦了。如果还不放心，我推荐你去看林晓斌老师的课程《MySQL 实战 45 讲》。

不过，虽然 SQL-92 得到了广泛应用，不少数据库也都遵照这个标准来命名自己的隔离级别，但它对异常现象的分析还是过于简单了。所以在不久之后的 1995 年，Jim Gray 等人发表了论文“A Critique of ANSI SQL Isolation Levels”（以下简称 Critique），对于事务隔离性进行了更加深入的分析。我要特别提示一下，Critique 是数据库领域的经典论文，强烈推荐你阅读原文。

Critique：更严谨的隔离级别

幻读和写倾斜

Critique 丰富和细化了 SQL-92 的内容，定义了六种隔离级别和八种异常现象。其中，我们最关注的是快照隔离（Snapshot Isolation, SI）级别。为什么呢？这是因为在 SQL-92 中可重复读（Repeatable Read, RR）与可串行化（Serializable）两个隔离级别的主要差别是对幻读（Phantom）的处理。这似乎是说，解决幻读问题的就是可串行化。但随着 Critique 的发表，快照隔离被明确提出，这个说法就不适用了，因为快照隔离能解决幻读的问题，但却无法处理写倾斜（Write Skew）问题，也不符合可串行化要求。因为翻译的原因，有时写倾斜也被称为写偏序，都是一个意思。

因此，今天，使用最广泛的隔离级别有四个，就是已提交读、可重复读、快照隔离、可串行化。

而幻读和写倾斜无疑则是通往最高隔离级别的两座大山，那么让我来给你详细解释一下它们到底是什么异常现象。

Critique 对幻读的描述大致是这样的，事务 T1 使用特定的查询条件获得一个结果集，事务 T2 插入新的数据，并且这些数据符合 T1 刚刚执行的查询条件。T2 提交成功后，T1 再次执行同样的查询，此时得到的结果集会增大。这种异常现象就是幻读。

不少人会将幻读与不可重复读混淆，这是因为它们在自然语义上非常接近，都是在一个事务内用相同的条件查询两次，但两次的结果不一样。差异在于，对不可重复读来说，第二次的结果集相对第一次，有些记录被修改（Update）或删除（Delete）了；而幻读是第二次结果集里出现了第一次结果集没有的记录 (Insert)。一个更加形象的说法，幻读是在第一次结果集的记录“间隙”中增加了新的记录。所以，MySQL 将防止出现幻读的锁命名为间隙锁（Gap Lock）。

跟幻读相比，写倾斜要稍微复杂一点，我用一个黑白球的例子来说明。

首先，箱子里有三个白球和三个黑球，两个事务（T1,T2）并发修改，不知道对方的存在。T1 要让 6 个球都变成白色；T2 则希望 6 个球都变成黑色。

你看，最终的执行结果是，盒子里仍然有三个黑球和三个白球。如果你还没有发现问题，可以看看下面我画的串行执行的效果图，比较一下有什么不同。

如果先执行 T1 再执行 T2，6 个球都会变成黑色；调换 T1 与 T2 的顺序，则 6 个球都是白色。

根据可串行化的定义，“多事务并行执行所得到的结果，与串行执行（一个接一个）完全相同”。比照两张图，很容易发现事务并行执行没有达到串行的同等效果，所以这是一种异常现象。也可以说，写倾斜是一种更不易察觉的更新丢失。

好了，为了让你搞清 Critique 中六种隔离级别的强弱关系以及相互间的差距，我截取了原论文的一张配图。

你可以看到“快照隔离”与“可重复读”在强度上并列，“已提交读”则弱于这两者。事实上，今天大多数数据库支持的隔离级别就在这三者之中。

快照隔离 & MVCC

你可能会问，既然“快照隔离”这么重要，为什么会被 SQL-92 漏掉呢？

这是由于 SQL-92 主要考虑了基于锁（Lock-base）的并发控制，而快照隔离的实现基础则是多版本并发控制（MVCC），很可能是由于当时 MVCC 的应用还不普遍。当然，后来，MVCC 成为一项非常重要的技术，一些经典教材会将 MVCC 作为一种独立的选择，与乐观并发控制和悲观并发控制并列。其实，在现代数据库中 MVCC 已经成为一种底层技术，用于更高效地实现乐观或悲观并发控制。有了 MVCC 这个基础，快照隔离就成为一个普遍存在的隔离级别了。有关 MVCC 的话题，我会在第 11 讲中继续展开。

隔离性的产品实现

还有一个问题也许你一直想问，为什么不支持最高级别的可串行化呢？

答案可能会让你有点沮丧，那就是在很长一段时间内，学术界都没有找到足够高效的并发控制技术。可能你熟悉的很多数据库声称提供了“可串行化”级别，但这往往只是一种形象工程，因为它们都采用的是两阶段封锁协议，导致性能无法满足生产环境的要求。不过，有些消息让人振奋，虽然不是普适的方案，但少数产品的尝试已经取得进展。

这种尝试来自两个方向。

第一个方向是，用真正的串行化实现“可串行化”隔离。我们往往认为多线程并发在性能上更优，但 Redis 和 VoltDB 确实通过串行化执行事务的方式获得了不错的性能。考虑到 VoltDB 作为一款分布式数据库的复杂度，其成功就更为难得了。我想，其中部分原因可能在于内存的大量使用，加速了数据计算的过程。另外，VoltDB 以存储过程为逻辑载体的方式，也使得事务有了更多的优化机会。

如果说第一个方向有点剑走偏锋，那第二个方向就是硬桥硬马了。没错，还是在并发技术上继续做文章。PostgreSQL 在 2008 年提出了 Serializable Snapshot Isolation (SSI)，这实际就是可串行化。而后，兼容 PostgreSQL 生态的 CockroachDB，也同样选择支持 SSI，而且是唯一支持的隔离级别。

这两个方向的尝试都很有趣，我还会在后续的课程中与你深入探讨。

分布式数据库的强一致性

到这里，我们用两讲的篇幅分别介绍了数据一致性和事务一致性，它们共同构成了分布式数据库的强一致性这个概念。我借用一张图来体现三者的关系。

图片原始出处是论文“Highly Available Transactions: Virtues and Limitations”，此处引用的是Jepsen 网站的简化版。

这幅图展现了一个树状结构，左右两个分支上体现事务一致性和数据一致性的各个级别及强弱关系，根节点则体现了分布式数据库的一致性来自两者的融合。图中使用了不同颜色，简单来说，这是区别不同的一致性级别所需付出的性能代价。

对分布式数据而言，最高级别的一致性是严格串行化（Strict Serializable），Spanner 实现的“外部数据一致性”可以被视为与 “Strict Serializable” 等效。但由于两条路径上各自实现难度及性能上的损耗，少有分布式数据库在顶端汇合。即使强大的 Spanner 也提供了有界旧一致性（Bounded Stale），用于平衡性能和一致性之间的冲突。

下面，我总结了一些分布式数据库产品的“一致性”实现情况供你参考。

比较特别的是，OceanBase 在 2.2 版本还增加了对“可串行化”的支持，但这是一个被 Oracle 重新定义的“可串行化”，在这个级别 OceanBase 和 Oracle 一样都会出现写倾斜。所以，这不是我们标准的隔离级别，也就没有体现在表格中。

小结

好了，有关事务一致性就讨论到这里，最后让我们来回顾一下今天的重点内容。

数据一致性关注的是单对象、单操作在多副本上的一致性，事务一致性则是关注多对象、多操作在单副本上的一致性，分布式数据库的一致性是数据一致性与事务一致性的融合。

广义上的事务一致性被细化为 ACID 四个方面，其中原子性的实现依赖于隔离性的并发控制技术和持久性的日志技术。

隔离性是事务的核心。降低隔离级别，其实就是在正确性上做妥协，将一些异常现象交给应用系统的开发人员去解决，从而获得更好的性能。所以，除“可串行化”以外的隔离级别，都有无法处理的异常现象。

研究人员将隔离级别分为六级，你需要重点关注其中四个，分别是已提交读、可重复读、快照隔离、可串行化。前三者是单体数据库或分布式数据库中普遍提供的，可串行化仅在少数产品中提供。

好了，到这里，加上前一节“数据一致性”，我们用了两讲阐述了分布式数据“强一致性”的含义。在严格意义上，分布式数据库的“强一致性”意味着严格串行化（Strict Serializable），目前我们熟知的产品中只有 Spanner 达到了这个标准，其同时也带来了性能上的巨大开销。如果我们稍稍放松标准，那么“数据一致性”达到因果一致性且“事务一致性”达到已提交读，即可认为是相对的“强一致性”。还有一点非常重要，分布式数据一致性并不是越高越好，还要与可用性、性能指标结合，否则就成了形象工程。

思考题

课程的最后，我要留给你一道思考题。

我们在事务持久性部分提到了预写日志（WAL），它可以保证在系统发生故障时，数据也不会丢失。但是，如果写日志成功，而写数据表失败，又要如何处理呢？你可以根据自己的经验，讲讲该如何设计这个过程吗？

欢迎你在评论区留言和我一起讨论，我会在答疑篇回复这个问题。如果你身边的朋友也对事务一致性这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

04 | 架构风格：NewSQL和PGXC到底有啥不一样？

你好，我是王磊，你也可以叫我 Ivan。

分布式数据库已经是技术新潮流了，所以产品也越来越多，如果你要做技术选型或者想要学习，该如何下手呢？怎么能更高效地了解不同产品的特点呢？这就需要你把它们分分类，有些差不多的产品，熟悉了其中的一个，剩下的我们只要记下差异点就可以了。那下面的问题就是如何分类了，这个其实很简单，因为业界已经有共识，把产品按照架构风格划分到不同的阵营。

总的来说，分布式数据库大多可以分为两种架构风格，一种是 NewSQL，它的代表系统是 Google Spanner；另一种是从单体数据库中间件基础上演进出来的，被称为 Prxoy 风格，没有公认的代表系统。我觉得 Prxoy 这个名字太笼统，没有反映架构的全貌，还是要有一个具体的架构模板，才能便于你理解，所以我选了一个出现较早的产品来指代这种风格，这就是 PostgreSQL-XC（下文简称 PGXC）。

我在后面的课程中讲述分布式数据库的特性和原理的时候，也会沿着这两种架构风格的思路，帮助你去迅速抓住不同产品的要点。因此，我们今天就先用一讲来学习下这两种架构风格。

数据库的基本架构

要搞清楚分布式数据库的架构风格，就要先了解“数据库”的架构。当然，我们这里说的数据库仍然默认是关系型数据库。我们先通过一张架构图看看数据库的全貌。

这张图从约瑟夫 · 海勒斯坦 (Joseph M. Hellerstein) 等人的论文“Architecture of a Database System”中翻译而来。文中将数据库从逻辑上拆分为 5 个部分，分别是客户端通讯管理器 (Client Communications Manager)、查询处理器（Relational Query Processor）、事务存储管理器（Transactional Storage Manager）、进程管理器（Process Manager）和共享组件与工具 (Shared Components and Utilities)，每个部分下面又可以拆分成一些组件。

你在各种数据库产品中都能找到这 5 个部分的对应实现，比如 Oracle、DB2、SQL Server 和 MySQL，无一例外。下面，我依次介绍下这 5 个部分的功能。

客户端通讯管理器。这是应用开发者能够直观感受到的模块，通常我们使用 JDBC 或者 ODBC 协议访问数据库时，连接的就是这个部分。

进程管理器。连接建好了，数据库会为客户端分配一个进程，客户端后续发送的所有操作都会通过对应的进程来执行。当然，这里的进程只是大致的说法。事实上，Oracle 和 PostgreSQL 是进程的方式，而 MySQL 使用的则是线程。还有，进程与客户也不都是简单的一对一关系，但这部分功能不会影响你对分布式数据库的理解，可以略过。

查询处理器。它包括四个部分，功能上是顺序执行的。首先是解析器，它将接收到的 SQL 解析为内部的语法树。然后是查询重写（Query Rewrite），它也被称为逻辑优化，主要是依据关系代数的等价变换，达到简化和标准化的目的，比如会消除重复条件或去掉一些无意义谓词 ，还有将视图替换为表等操作。再往后就是查询算法优化（Query Optimizer），它也被称为物理优化，主要是根据表连接方式、连接顺序和排序等技术进行优化，我们常说的基于规则优化（RBO）和基于代价优化（CBO）就在这部分。最后就是计划执行器（Plan Executor），最终执行查询计划，访问存储系统。

事务存储管理器。它包括四个部分，其中访问方式（Access Methods）是指数据在磁盘的具体存储形式。锁管理（Lock Manager）是指并发控制。日志管理（Log Manager）是确保数据的持久性。缓存管理（Buffer Manager）则是指 I/O 操作相关的缓存控制。

共享组件和工具。在整个过程中还会涉及到的一些辅助操作，当然它们对于数据库的运行也是非常重要的。例如编目数据管理器（Catalog Manager）会记录数据库的表、字段、视图等元数据信息，并根据这些信息来操作具体数据内容。复制机制（Replication）也很重要，它是实现系统高可靠性的基础，在单体数据库中，通过主备节点复制的方式来实现数据的复制。

到这里，你应该对数据库的运行过程有了一个大致的理解，这样就能够串接起后续要讲到的 PGXC 和 NewSQL 两种架构风格的关键功能了。当然，数据库本身的运行机制是比较复杂的，就算只是其中的一个具体模块，我们用整整一讲都不一定能够说清楚。如果你希望进一步了解的话，可以仔细研读约瑟夫 · 海勒斯坦的这篇论文。

PGXC：单体数据库的自然演进

单体数据库的功能看似已经很完善了，但在面临高并发场景的时候，还是会碰到写入性能不足的问题，很难解决。因此，也就有了向分布式数据库演进的动力。要解决写入性能不足的问题，大家首先想到的，最简单直接的办法就是分库分表。

分库分表方案就是在多个单体数据库之前增加代理节点，本质上是增加了 SQL 路由功能。这样，代理节点首先解析客户端请求，再根据数据的分布情况，将请求转发到对应的单体数据库。

代理节点需要实现三个主要功能，它们分别是客户端接入、简单的查询处理器和进程管理中的访问控制。

另外，分库分表方案还有一个重要的功能，那就是分片信息管理，分片信息就是数据分布情况，是区别于编目数据的一种元数据。不过考虑到分片信息也存在多副本的一致性的问题，大多数情况下它会独立出来，更详细的原因我在第 7 讲中展开说明。

显然，如果把每一次的事务写入都限制在一个单体数据库内，业务场景就会很受局限。因此，跨库事务成为必不可少的功能，但是单体数据库是不感知这个事情的，所以我们就要在代理节点增加分布式事务组件。

同时，简单的分库分表不能满足全局性的查询需求，因为每个数据节点只能看到一部分数据，有些查询运算是无法处理的，比如排序、多表关联等。所以，代理节点要增强查询计算能力，支持跨多个单体数据库的查询。

随着分布式事务和跨节点查询等功能的加入，代理节点已经不再只是简单的路由功能，更多时候会被称为协调节点。

很多分库分表方案会演进到这个阶段，比如 MyCat。这时离分布式数据库还差重要的一步，就是全局时钟。我们在第 2 讲已经介绍了全局时钟的意义，它是实现数据一致性的必要条件。

加上这最后一块拼图，PGXC 区别于单体数据库的功能也就介绍完整了，它们是分片、分布式事务、跨节点查询和全局时钟。

协调节点与数据节点，实现了一定程度上的计算与存储分离，这也是所有分布式数据库的一个架构基调。但是，因为 PGXC 的数据节点本身就是完整的单体数据库，所以也具备很强的计算能力。

说了这么多，PGXC 风格的分布式数据库到底包括哪些产品呢？PGXC（PostgreSQL-XC）的本意是指以 PostgreSQL 为内核的开源分布式数据库。因为 PostgreSQL 的影响力和开放的软件版权协议（类似 BSD），很多厂商在 PGXC 上二次开发，推出自己的产品。不过，这些改动都没有变更主体架构风格，所以我把这类产品统称为 PGXC 风格，其中包括 TBase、GuassDB 300 和 AntDB 等。当然，这里所说的 PGXC 并不限于以 PostgreSQL 为内核，那些以 MySQL 为内核的产品往往也会采用同样的架构，例如 GoldenDB，所以我把它们也归入了 PGXC 风格。

NewSQL：革命性的新架构

相对于 PGXC，NewSQL 有着完全不同的发展路线。NewSQL 也叫原生分布式数据库，我觉得这个名字能更准确地体现这类架构风格的特点，就是说它的每个组件在设计之初都是基于分布式架构的，不像 PGXC 那样带有明显的单体架构痕迹。

NewSQL 的基础是 NoSQL，更具体地说，是类似 BigTable 的分布式键值（K/V）系统。分布式键值系统选择做了一个减法，完全放弃了数据库事务处理能力，然后将重点放在对存储和写入能力的扩展上，这个能力扩展的基础就是分片。引入分片的另一个好处是，系统能够以更小的粒度调度数据，实现各节点上的存储平衡和访问负载平衡。

分布式键值系统由于具备这些鲜明的特点，所以在不少细分场景获得了成功（比如电商网站对于商品信息的存储），但在面对大量的事务处理场景时就无能为力了（比如支付系统）。这种状况直到 Google Spanner 横空出世才被改变，因为 Spanner 基于 BigTable 构建了新的事务能力。

除了上述内容，NewSQL 还有两个重要的革新，分别出现在高可靠机制和存储引擎的设计上。

高可靠机制的变化在于，放弃了粒度更大的主从复制，转而以分片为单位采用 Paxos 或 Raft 等共识算法。这样，NewSQL 就实现了更小粒度的高可靠单元，获得了更高的系统整体可靠性。存储引擎层面，则是使用 LSM-Tree 模型替换 B+ Tree 模型，大幅提升了写入性能。

由于 NewSQL 在架构上的革新性，产品实现的难度比 PGXC 要大，所以产品就相对少一些。Spanner 是 NewSQL 的开山鼻祖，这个不用说了；其他知名度比较高的产品有 CockroachDB、TiDB 和 YugabyteDB，这三款数据库都宣称设计灵感来自 Spanner；另外就是阿里自研的 OceanBase，因为它有一个代理层，有时会被同行质疑，但是从整体架构风格看，我还是愿意把它归为 NewSQL。

从系统架构上看，我个人认为，NewSQL 的设计思想更加领先，具有里程碑意义，而 PGXC 的架构偏于保守。但 PGXC 的优势则在于稳健，直接采用单机数据库作为数据节点，大幅降低了工程开发的工作量，也减少了引入风险的机会。总的来说，NewSQL 的长处在架构设计，PGXC 的长处则在工程实现。

当然，NewSQL 的架构设计也不是完美无缺。比如，作为一个计算与存储分离得更加彻底的架构，NewSQL 的计算节点需要借助网络才能与存储节点通讯，这意味着要花费更大的代价来传输数据。随着 NewSQL 分布式数据库的应用实践越来越多，很多产品为了获得更好的计算性能，会尽量将更多计算下压到存储节点执行。这种架构上的修正，似乎也可以理解为，NewSQL 朝 PGXC 的方向做了一点回拨。

小结

关于分布式数据库的两种架构风格，我们今天就先学到这里了。最后，我们再一起复习下今天的重点内容。

从架构上，数据库可以被拆分为 5 个部分，分别是客户端通讯管理器、进程管理器、查询处理器、事务存储管理器和共享组件与工具。分布式数据库在此基础上增加四个主要功能，包括分片信息管理、分布式事务管理、跨节点查询和全局时钟。

PGXC 架构是从分库分表方案演进而来的。它设置了协调节点，在代理功能的基础上增加了分布式事务管理、跨节点查询功能；原有的单体数据继续作为数据节点；新增了全局时钟和分片信息管理两个功能，这两个功能又有两种实现情况，一是拆分为两个独立角色节点，例如 GoldenDB，二是合并为一个角色节点，例如 TBase。

NewSQL 架构是原生分布式数据库，架构中的每个层次的设计都是以分布式为目标。NewSQL 是从分布式键值系统演进而来，主要的工作负载由计算节点和存储节点承担，另外由管理节点承担全局时钟和分片信息管理功能。不过，这三类节点是逻辑功能上划分，在设计实现层面是可分可合的。比如，TiDB 是分为独立节点，CockroachDB 则是对等的 P2P 架构。

NewSQL 在架构上更加领先，而 PGXC 最大程度复用了单体数据库的工程实现，更加稳健。

今天我们从单体数据库架构出发，简单介绍了 PGXC 和 NewSQL 两种架构。为了帮助你迅速地把握要点，在内容上，我专门挑选了那些最能体现与单体数据库差异的部分。不过，这些内容尚不足以完全解释数据库的整体运作原理，但对于你理解两种架构风格的分布式数据库产品的基本框架足够了。如果你想更彻底、更全面地了解数据库架构，我建议你仔细研读“Architecture of a Database System”和另一本非常值得阅读的经典教材《数据库系统实现》。

思考题

按照惯例，最后是思考题时间。今天我们介绍了两种不同的架构风格，你会将自己熟悉的分布式数据库归入哪一类呢？或者如果你有熟悉的 NoSQL 产品，可以和 NewSQL 比较一下，谈谈它们架构上的差异。

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续探讨这个问题。如果你身边的朋友也对分布式数据库的架构风格感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

Joseph M. Hellerstein et al.：Architecture of a Database System

加西亚 - 莫利纳 等：《数据库系统实现》

05 | 全局时钟：物理时钟和逻辑时钟你Pick谁？

你好，我是王磊，你也可以叫我 Ivan。

今天，我想和你聊聊时间的话题。

“时光一去永不回，往事只能回味”，这种咏叹时光飞逝的歌曲，你一定听过很多。但是，在计算机的世界里，时间真的是一去不回吗？还真不一定。

还记得我在第 2 讲提到的 TrueTime 吗？作为全局时钟的一种实现形式，它是 Google 通过 GPS 和原子钟两种方式混合提供的授时机制，误差可以控制在 7 毫秒以内。正是在这 7 毫秒内，时光是可能倒流的。

为什么我们这么关注时间呢？是穿越剧看多了吗？其实，这是因为分布式数据库的很多设计都和时间有关，更确切地说是和全局时钟有关。比如，我们在第 2 讲提到的线性一致性，它的基础就是全局时钟，还有后面会讲到的多版本并发控制（MVCC）、快照、乐观协议与悲观协议，都和时间有关。

常见授时方案

那既然有这么多分布式数据库，授时机制是不是也很多，很复杂呢？其实，要区分授时机制也很简单，抓住三个要素就可以了。

时间源：单个还是多个

使用的时钟类型：物理时钟还是混合逻辑时钟

授时点：一个还是多个

根据排列组合，一共产生了 8 种可能性，其中 NTP（Network Time Protocol）误差大，也不能保证单调递增，所以就没有单独使用 NTP 的产品；还有一些方案在实践中则是不适用的（N/A）。因此常见的方案主要只有 4 类，我画了张表格，总结了一下。

1. TrueTime

Spanner 采用的方案是 TrueTime。它的时间源是 GPS 和原子钟，所以属于多时间源和物理时钟，同时它也采用了多点授时机制，就是说集群内有多个时间服务器都可以提供授时服务。

就像这一讲开头说的，TrueTime 是会出现时光倒流的。例如，A、B 两个进程先后调用 TrueTime 服务，各自拿到一个时间区间，如果在其中随机选择，则可能出现 B 的时间早于 A 的时间。不只是 TrueTime，任何物理时钟都会存在时钟偏移甚至回拨。

单个物理时钟会产生误差，而多点授时又会带来整体性的误差，那 TrueTime 为什么还要这么设计呢？

因为它也有两个显著的优势：首先是高可靠高性能，多时间源和多授时点实现了完全的去中心化设计，不存在单点；其次是支持全球化部署，客户端与时间服务器的距离也是可控的，不会因为两者通讯延迟过长导致时钟失效。

2. HLC

CockroachDB 和 YugabyteDB 也是以高性能高可靠和全球化部署为目标，不过 Truetime 是 Google 的独门绝技，它依赖于特定硬件设备的思路，不适用于开源软件。所以，它们使用了混合逻辑时钟（Hybrid Logical Clock，HLC），同样是多时间源、多点授时，但时钟采用了物理时钟与逻辑时钟混合的方式。HLC 在实现机制上也是蛮复杂的，而且和 TrueTime 同样有整体性的时间误差。

对于这个共性问题，Spanner 和 CockroachDB 都会通过一些容错设计来消除时间误差，我会在第 12 讲中具体介绍相关内容。

3. TSO

其他的分布式数据库大多选择了单时间源、单点授时的方式，承担这个功能的组件在 NewSQL 风格架构中往往被称为 TSO（Timestamp Oracle），而在 PGXC 风格架构中被称为全局事务管理器（Golobal Transcation Manager，GTM）。这就是说一个单点递增的时间戳和全局事务号基本是等效的。这种授时机制的最大优点就是实现简便，如果能够保证时钟单调递增，还可以简化事务冲突时的设计。但缺点也很明显，集群不能大范围部署，同时性能也有上限。TiDB、OceanBase、GoldenDB 和 TBase 等选择了这个方向。

4. STP

最后，还有一些小众的方案，比如巨杉的 STP(SequoiaDB Time Protoco)。它采用了单时间源、多点授时的方式，优缺点介于 HLC 和 TSO 之间。

到这里，我已经介绍了 4 种方案在技术路线上大致的区别。其中 TrueTime 是基于物理设备的外部授时方案，所以 Spanner 直接使用就可以了，自身不需要做专门的设计。而对于其他 3 种方案，如果我们想要深入理解，那么还得结合具体的产品来看。

中心化授时：TSO（TiDB）

首先，我们从最简单的 TSO 开始。

最早提出 TSO 的，大概是 Google 的论文“ Large-scale Incremental Processing Using Distributed Transactions and Notifications”。这篇论文主要是介绍分布式存储系统 Percolator 的实现机制，其中提到通过一台 Oracle 为集群提供集中授时服务，称为 Timestamp Oracle。所以，后来的很多分布式系统也用它的缩写来命名自己的单点授时机制，比如 TiDB 和 Yahoo 的 Omid。

考虑到 TiDB 的使用更广泛些，这里主要介绍 TiDB 的实现方式。

TiDB 的全局时钟是一个数值，它由两部分构成，其中高位是物理时间，也就是操作系统的毫秒时间；低位是逻辑时间，是一个 18 位的数值。那么从存储空间看，1 毫秒最多可以产生 262,144 个时间戳（2^18），这已经是一个很大的数字了，一般来说足够使用了。

单点授时首先要解决的肯定是单点故障问题。TiDB 中提供授时服务的节点被称为 Placement Driver，简称 PD。多个 PD 节点构成一个 Raft 组，这样通过共识算法可以保证在主节点宕机后马上选出新主，在短时间内恢复授时服务。

那问题来了，如何保证新主产生的时间戳一定大于旧主呢？那就必须将旧主的时间戳存储起来，存储也必须是高可靠的，所以 TiDB 使用了 etcd。但是，每产生一个时间戳都要保存吗？显然不行，那样时间戳的产生速度直接与磁盘 I/O 能力相关，会存在瓶颈的。

如何解决性能问题呢？TiDB 采用预申请时间窗口的方式，我画了张图来表示这个过程。

当前 PD（主节点）的系统时间是 103 毫秒，PD 向 etcd 申请了一个“可分配的时间窗口”。要知道时间窗口的跨度是可以通过参数指定的，系统的默认配置是 3 毫秒，示例采用了默认配置，所以这个窗口的起点是 PD 当前时间 103，时间窗口的终点就在 106 毫秒处。。写入 etcd 成功后，PD 将得到一个从 103 到 106 的“可分配时间窗口”，在这个时间窗口内 PD 可以使用系统的物理时间作为高位，拼接自己在内存中累加的逻辑时间，对外分配时间戳。

上述设计意味着，所有 PD 已分配时间戳的高位，也就是物理时间，永远小于 etcd 存储的最大值。那么，如果 PD 主节点宕机，新主就可以读取 etcd 中存储的最大值，在此基础上申请新的“可分配时间窗口”，这样新主分配的时间戳肯定会大于旧主了。

此外，为了降低通讯开销，每个客户端一次可以申请多个时间戳，时间戳数量作为参数，由客户端传给 PD。但要注意的是，一旦在客户端缓存，多个客户端之间时钟就不再是严格单调递增的，这也是追求性能需要付出的代价。

分布式授时：HLC（CockroachDB）

前面已经说过 TrueTime 依赖 Google 强大的工程能力和特殊硬件，不具有普适性。相反，HLC 作为一种纯软的实现方式，更加灵活，所以在 CockroachDB、YugabyteDB 和很多分布式存储系统得到了广泛使用。

HLC 不只是字面上的意思， TiDB 的 TSO 也混合了物理时钟与逻辑时钟，但两者截然不同。HLC 代表了一种计时机制，它的首次提出是在论文“Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases”中，CockroachDB 和 YugabyteDB 的设计灵感都来自于这篇论文。下面，我们结合图片介绍一下这个机制。

假如我们有 ABCD 四个节点，方框是节点上发生的事件，方框内的三个数字依次是节点的本地物理时间（简称本地时间，Pt）、HLC 的高位（简称 L 值）和 HLC 的低位（简称 C 值）。

A 节点的本地时间初始值为 10，其他节点的本地时间初始值都是 0。四个节点的第一个事件都是在节点刚启动的一刻发生的。首先看 A1，它的 HLC 应该是 (10,0)，其中高位直接取本地时间，低位从 0 开始。同理，其他事件的 HLC 都是 (0,0)。

然后我们再看一下，随着时间的推移，接下来的事件如何计时。

事件 D2 发生时，首先取上一个事件 D1 的 L 值和本地时间比较。L 值等于 0，本地时间已经递增变为 1，取最大值，那么用本地时间作为 D2 的 L 值。高位变更了，低位要归零，所以 D2 的 HLC 就是 (1,0)。

如果你看懂了 D2 的计时逻辑就会发现，D1 其实是一样的，只不过 D1 没有上一个事件的 L 值，只能用 0 代替，是一种特殊情况。

如果节点间有调用关系，计时逻辑会更复杂一点。我们看事件 B2，要先判断 B2 的 L 值，就有三个备选：

本节点上前一个事件 B1 的 L 值

当前本地时间

调用事件 A1 的 L 值，A1 的 HLC 是随着函数调用传给 B 节点的

这三个值分别是 0、1 和 10。按照规则取最大值，所以 B2 的 L 值是 10，也就是 A1 的 L 值，而 C 值就在 A1 的 C 值上加 1，最终 B2 的 HLC 就是 (10,1)。

B3 事件发生时，发现当前本地时间比 B2 的 L 值还要小，所以沿用了 B2 的 L 值，而 C 值是在 B2 的 C 值上加一，最终 B3 的 HLC 就是 (10,2)。

论文中用伪码表述了完整的计时逻辑，我把它们复制在下面，你可以仔细研究。

Initially l:j := 0; c:j := 0
Send or local event
l’:j := l:j;
l:j := max(l’:j; pt:j);
If (l:j=l’:j) then c:j := c:j + 1
Else c:j := 0;
Timestamp with l:j; c:j
Receive event of message m
l’:j := l:j;
l:j := max(l’:j; l:m; pt:j);
If (l:j=l’:j=l:m) then c:j := max(c:j; c:m)+1
Elseif (l:j=l’:j) then c:j := c:j + 1
Elseif (l:j=l:m) then c:j := c:m + 1
Else c:j := 0
Timestamp with l:j; c:j

其中，对于节点 J，l.j 表示 L 值，c.j 表示 C 值，pt.j 表示本地物理时间。

在 HLC 机制下，每个节点会使用本地时钟作为参照，但不受到时钟回拨的影响，可以保证单调递增。本质上，HLC 还是 Lamport 逻辑时钟的变体，所以对于不同节点上没有调用关系的两个事件，是无法精确判断先后关系的。比如，上面例子中的 C2 和 D2 有同样的 HLC，但从上帝视角看，C2 是早于 D2 发生的，因为两个节点的本地时钟有差异，就没有体现这种先后关系。HLC 是一种松耦合的设计，所以不会去校正节点的本地时钟，本地时钟是否准确，还要靠 NTP 或类似的协议来保证。

多层级中心化授时：STP（巨杉）

巨杉采用了单时间源、多点授时机制，它有自己的全局时间协议，称为 STP（Serial Time Protocol），是内部逻辑时间同步的协议，并不依赖于 NTP 协议。

下面是 STP 体系下各角色节点的关系。

STP 是独立于分布式数据库的授时方案，该体系下的各角色节点与巨杉的其他角色节点共用机器，但没有必然的联系。

STP 下的所有角色统称为 STP Node，具体分为两类：

STP Server。多个 STP Server 构成 STP Server 组，组内根据协议进行选主，主节点被称为 Primary，对外提供服务。

STP Client。按照固定的时间间隔，从 Primary Server 同步时间。

巨杉数据库的其他角色节点，如编目节点（CATALOG）、协调节点（COORD）和数据节点（DATA）等，都从本地的 STP Node 节点获得时间。

STP 与 TSO 一样都是单时间源，但通过增加更多的授时点，避免了单点性能瓶颈，而负副作用是多点授时就会造成全局性的时间误差，因此和 HLC 一样需要做针对性设计。

小结

好了，今天的内容就到这里了，我们一起回顾下这节课的重点。

分布式数据库有多种授时机制，它们的区别主要看三个维度。一，是单时间源还是多时间源；二，时间源采用的是物理时钟还是混合逻辑时钟；三，授时点是一个还是多个。

TrueTime 是多时间源、多授时点方案，虽然仍存在时间误差的问题，但实现了高可靠高性能，能够支持 Spanner 做到全球化部署，是一种非常强悍的设计方案。TrueTime 是 GPS 加原子钟的整合方案，可以看作为一种物理时钟，它完全独立于 Spanner 的授时服务，不需要 Spanner 做专门的设计。

HLC 同样是多时间源、多授时点，由于是纯软方案，所以具有更好的通用性。CockroachDB 和 YugabyteDB 都采用了这种方案，也都具备全球化部署能力。HLC 的设计基础是 Lamport 逻辑时钟，对 NTP 的时间偏移有一定的依赖。

TSO 是典型的单时间源、单点授时方案，实现简便，所以成为多数分布式数据库的选择。如果 TSO 能够做到单调递增，会简化读写冲突时候的处理过程，但缺点是集群部署范围受到极大的限制。

还有一些小众的方案，比如巨杉的 STP，也试图在寻求新的平衡点。

有关时间的话题我们就聊到这了。时间误差是普遍存在的，只不过长期在单体应用系统下开发，思维惯性让我们忽略了它，但随着分布式架构的普及，我相信更多的架构设计中都要考虑这个因素。我建议你收藏今天的内容，因为即使抛开分布式数据库不谈，这些设计依然是值得借鉴的。

思考题

最后，今天留给你的思考题还是关于时间的。在后续课程没有展开之前，我们不妨先来开放式地讨论一下，你觉得时间对于分布式数据库的影响是什么？或者你也可以谈谈在其他分布式系统中曾经遇到的关于时间的问题。

欢迎你在评论区留言和我一起讨论，我会在答疑篇回复这个问题。如果你身边的朋友也对全局时钟或者分布式架构下如何同步时间这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

Daniel Peng and Frank Dabek: Large-scale Incremental Processing Using Distributed Transactions and Notifications

Sandeep S. Kulkarni et al.: Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases

06 | 分片机制：为什么说Range是更好的分片策略？

你好，我是王磊，你也可以叫我 Ivan。

在这一讲的开头，我想请你思考一个问题，你觉得在大规模的业务应用下，单体数据库遇到的主要问题是什么？对，首先就是写入性能不足，这个我们在第 4 讲也说过，另外还有存储方面的限制。而分片就是解决性能和存储这两个问题的关键设计，甚至不仅是分布式数据库，在所有分布式存储系统中，分片这种设计都是广泛存在的。

所以今天，就让我们好好了解一下，分片到底是怎么回事儿。

什么是分片

分片在不同系统中有各自的别名，Spanner 和 YugabyteDB 中被称为 Tablet，在 HBase 和 TiDB 中被称为 Region，在 CockraochDB 中被称为 Range。无论叫什么，概念都是一样的，分片是一种水平切分数据表的方式，它是数据记录的集合，也是数据表的组成单位。

分布式数据库的分片与单体数据库的分区非常相似，区别在于：分区虽然可以将数据表按照策略切分成多个数据文件，但这些文件仍然存储在单节点上；而分片则可以进一步根据特定规则将切分好的文件分布到多个节点上，从而实现更强大的存储和计算能力。

分片机制通常有两点值得关注：

分片策略

主要有 Hash（哈希）和 Range（范围）两种。你可能还听到过 Key 和 List，其实 Key 和 List 可以看作是 Hash 和 Range 的特殊情况，因为机制类似，我们这里就不再细分了。

分片的调度机制

分为静态与动态两种。静态意味着分片在节点上的分布基本是固定的，即使移动也需要人工的介入；动态则是指通过调度管理器基于算法在各节点之间自动地移动分片。

我把分片机制的两个要点与第 5 讲提到的两种架构风格对应了一下，放到下面的表格中，希望能给你带来更直观的感受。

从表格中可以看出，PGXC 只支持静态的 Hash 分片和 Range 分片，实现机制较为简单，所以，我就从这里开始展开吧。

PGXC

Hash 分片

Hash 分片，就是按照数据记录中指定关键字的 Hash 值将数据记录映射到不同的分片中。我画了一张图来表示 Hash 分片的过程。

图中的表格部分显示了一个社交网站的记录表，包括主键、用户 ID、分享内容和分享时间等字段。假设以用户 ID 作为关键字进行分片，系统会通过一个 Hash 函数计算用户 ID 的 Hash 值而后取模，分配到对应的分片。模为 4 的原因是系统一共有四个节点，每个节点作为一个分片。

因为 Hash 计算会过滤掉数据原有的业务特性，所以可以保证数据非常均匀地分布到多个分片上，这是 Hash 分片最大的优势，而且它的实现也很简洁。但示例中采用的分片方法直接用节点数作为模，如果系统节点数量变动，模也随之改变，数据就要重新 Hash 计算，从而带来大规模的数据迁移。显然，这种方式对于扩展性是非常不友好的。

那接下来的问题就是，我们需要找一个方法提升系统的扩展性。你可能猜到了，这就是一致性 Hash，该算法首次提出是在论文“Consistent Hashing and Random Trees : Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web”当中。

要在工业实践中应用一致性 Hash 算法，首先会引入虚拟节点，每个虚拟节点就是一个分片。为了便于说明，我们在这个案例中将分片数量设定为 16。但实际上，因为分片数量决定了集群的最大规模，所以它通常会远大于初始集群节点数。

16 个分片构成了整个 Hash 空间，数据记录的主键和节点都要通过 Hash 函数映射到这个空间。这个 Hash 空间是一个 Hash 环。我们换一种方式画图，可以看得更清楚些。

节点和数据都通过 Hash 函数映射到 Hash 环上，数据按照顺时针找到最近的节点。

当我们新增一台服务器，即节点 E 时，受影响的数据仅仅是新服务器到其环空间中前一台服务器（即沿着逆时针方向的第一台服务器）之间数据。结合我们的示例，只有小红分享的消息从节点 B 被移动到节点 E，其他节点的数据保持不变。此后，节点 B 只存储 Hash 值 6 和 7 的消息，节点 E 存储 Hash 值 4 和 5 的消息。

Hash 函数的优点是数据可以较为均匀地分配到各节点，并发写入性能更好。

本质上，Hash 分片是一种静态分片方式，必须在设计之初约定分片的最大规模。同时，因为 Hash 函数已经过滤掉了业务属性，也很难解决访问业务热点问题。所谓业务热点，就是由于局部的业务活跃度较高，形成系统访问上的热点。这种情况普遍存在于各类应用中，比如电商网站的某个商品卖得比较好，或者外卖网站的某个饭店接单比较多，或者某个银行网点的客户业务量比较大等等。

Range 静态分片

与 Hash 分片不同，Range 分片的特点恰恰是能够加入对于业务的预估。例如，我们用“Location”作为关键字进行分片时，不是以统一的行政级别为标准。因为注册地在北京、上海的用户更多，所以这两个区域可以按照区县设置分片，而海外用户较少，可以按国家设置为分片。这样，分片间的数据更加平衡。

但是，这种方式依然是静态的，如果海外业务迅速增长，服务海外用户的分片将承担更大的压力，可能导致性能下降，用户体验不佳。

相对 Hash 分片，Range 分片的适用范围更加广泛。其中一个非常重要的原因是，Range 分片可以更高效地扫描数据记录，而 Hash 分片由于数据被打散，扫描操作的 I/O 开销更大。但是，PGXC 的 Range 分片受限于单体数据库的实现机制，很难随数据变动和负载变化而调整。

虽然有些 PGXC 同时支持两种分片方式，但 Hash 分片仍是主流，比如 GoldenDB 默认使用 Hash 分片，而 TBase 仅支持 Hash 分片。

NewSQL

总体上，NewSQL 也是支持 Hash 和 Range 两种分片方式的。具体就产品来说，CockroachDB 和 YugabyteDB 同时支持两种方式，TiDB 仅支持 Range 分片。

NewSQL 数据库的 Hash 分片也是静态的，所以与 PGXC 差别不大，这里就不再赘述了。接下来，我们重点学习下 Range 动态分片。

Range 动态分片

NewSQL 的 Range 分片，多数是用主键作为关键字来分片的，当然主键可以是系统自动生成的，也可以是用户指定的。既然提供了用户指定主键的方式，那么理论上可以通过设定主键的产生规则，控制数据流向哪个分片。但是，主键必须保证唯一性，甚至是单调递增的，导致这种控制就会比较复杂，使用成本较高。所以，我们基本可以认为，分片是一个系统自动处理的过程，用户是感知不到的。这样做的好处显然是提升了系统的易用性。

我们将 NewSQL 的 Range 分片称为动态分片，主要有两个原因：

分片可以自动完成分裂与合并

当单个分片的数据量超过设定值时，分片可以一分为二，这样就可以保证每个分片的数据量较为均衡。多个数据量较少的分片，会在一定的周期内被合并为一个分片。

还是回到我们社交网站这个例子，根据消息的数量来自动分片，我们可以得到 R1、R2、R3 三个分片。

分片也会被均衡地调度到各个节点上，节点间的数据量也保持总体平衡。

可以根据访问压力调度分片

我们看到系统之所以尽量维持分片之间，以及节点间的数据量均衡，存储的原因外，还可以更大概率地将访问压力分散到各个节点上。但是，有少量的数据可能会成为访问热点，就是上面提到的业务热点，从而打破这种均衡。比如，琦琦和静静都是娱乐明星，有很多粉丝关注她们分享的内容，其访问量远超过普通人。这时候，系统会根据负载情况，将 R2 和 R3 分别调度到不同的节点，来均衡访问压力。

存储均衡和访问压力均衡，是 NewSQL 分片调度机制普遍具备的两项能力。此外，还有两项能力在Spanner 论文中被提及，但在其他产品中没有看到工程化实现。

第一是减少分布式事务。

对分布式数据库来说，有一个不争的事实，那就是分布式事务的开销永远不会小于单节点本地事务的开销。因此，所有分布式数据库都试图通过减少分布式事务来提升性能。

Spanner 在 Tablet，也就是 Range 分片，之下增加了目录（Directory），作为数据调度的最小单位，它的调度范围是可以跨 Tablet 的。通过调度 Directory 可以将频繁参与同样事务的数据，转移到同一个 Tablet 下，从而将分布式事务转换为本地事务。

第二是缩短服务延时。

对于全球化部署的分布式数据库，数据可能存储在相距很远的多个数据中心，如果用户需要访问远端机房的数据，操作延时就比较长，这受制于数据传输速度。而 Spanner 可以将 Directory 调度到靠近用户的数据中心，缩短数据传输时间。当然，这里的调度对象都是数据的主副本，跨中心的数据副本仍然存在，负责保证系统整体的高可靠性。

Directory 虽然带来新的特性，但显然也削弱了分片的原有功能，分片内的记录不再连续，扫描要付出更大成本。而减少分布式事务和靠近客户端位置这本身就是不能兼顾的，再加上存储和访问压力，分片调度机制要在四个目标间进行更复杂的权衡。

Spanner 的这种设计能达到什么样的实际效果呢？我们现在还需要继续等待和观察。

分片与高可靠的关系

高可靠是分布式数据库的重要特性，分片是数据记录的最小组织单位，也必须是高可靠的。

NewSQL 与 PGXC 的区别在于，对于 NewSQL 来说，分片是高可靠的最小单元；而对于 PGXC，分片的高可靠要依附于节点的高可靠。

NewSQL 的实现方式是复制组（Group）。在产品层面，通常由一个主副本和若干个副本组成，通过 Raft 或 Paxos 等共识算法完成数据同步，称为 Raft Group 或 Paxos Group，所以我们简称这种方式为 Group。因为不相关的数据记录会被并发操作，所以同一时刻有多个 Group 在工作。因此，NewSQL 通常支持 Multi Raft Group 或者 Multi Paxos Group。这里，我们先忽略 Multi Paxos 的另一个意思。

每个 Group 是独立运行的，只是共享相同的网络和节点资源，所以不同复制组的主副本是可以分布在不同节点的。

PGXC 的最小高可靠单元由一个主节点和多个备节点组成，我们借用 TDSQL 中的术语，将其称为 Set。一个 PGXC 是由多个 Set 组成。Set 的主备节点间复制，多数采用半同步复制，平衡可靠性和性能。这意味着，所有分片的主副本必须运行在 Set 的主节点上。

从架构设计角度看，Group 比 Set 更具优势，原因主要有两个方面。首先，Group 的高可靠单元更小，出现故障时影响的范围就更小，系统整体的可靠性就更高。其次，在主机房范围内，Group 的主副本可以在所有节点上运行，资源可以得到最大化使用，而 Set 模式下，占大多数的备节点是不提供有效服务的，资源白白浪费掉。

小结

好吧，今天的内容就到这里了，我们一起回顾下这节课的重点。

分片是分布式数据库的关键设计，以此实现多节点的存储和访问能力。

分片机制的两个要点是分片策略和调度机制，分片策略包括 Hash 和 Range 两种，调度机制则分为静态和动态。

PGXC 使用单体数据库作为数据节点，往往只实现了静态分片。它的分片策略支持 Hash 和 Range 两种，其中 Hash 一般是指一致性 Hash，可以最大程度规避节点扩缩带来的影响。Hash 分片写性能出众，但查询性能差，Range 则相反。

NewSQL 的默认分片策略通常是 Range 分片。分片调度机制为了实现存储平衡和访问压力平衡的目标，会将分片动态调度到各个节点。Spanner 的设计又将在分片下拓展了 Directory，通过对 Directory 的调度实现减少分布式事务和缩短延时的目标，但在其他分布式数据库中尚未看到对应的实现。

NewSQL 架构下，分片采用 Paxos 或 Raft 算法可以构成复制组，这种复制机制相比 PGXC 的主备节点复制，提供了更高的可靠性，资源使用也更加高效。

到这里你应该已经大体了解了分布式数据库分片机制。我们说 Range 是更好的分片策略，就是因为 Range 分片有条件做到更好的动态调度，只有动态了，才能自适应各种业务场景下的数据变化，平衡存储、访问压力、分布式事务和访问链路延时等多方面的诉求。从我个人的观点来说，NewSQL 的 Range 分片方式更加优雅，随着单体数据库底层数据同步机制的改进，未来 PGXC 可能也会向这种方式靠拢。

如果你想更深入地了解 Range 分片机制，可以研究下BigTable 的论文。同时，因为 HBase 是业界公认的 BigTable 开源实现，所以你在它的官方文档也能找到很多有用的内容。

思考题

Range 分片的优势是动态调度，这就是说分片存储在哪个节点上是不断变化的。这时，客户端首先要知道分片的位置，就要先访问分片的元数据。你觉得这些元数据应该如何存储呢？是存储在某个中心点，还是分散在所有节点上呢？如果有多个副本，又该如何同步呢？

如果你想到了答案，又或者是触发了你对相关问题的思考，都可以在评论区和我聊聊，我会在答疑篇更系统地回复这个问题。如果你身边的朋友也对数据的分片机制，这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

David Karge et al.: Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web

Fay Chang et al.: Bigtable: A Distributed Storage System for Structured Data

HBase: Apache HBase ™ Reference Guide

James C. Corbett et al.: Spanner: Google’s Globally-Distributed Database

07 | 数据复制：为什么有时候Paxos不是最佳选择？

你好，我是王磊，你也可以叫我 Ivan。今天，我们要学习的是数据复制。

数据复制是一个老生常谈的话题了，典型的算法就是 Paxo 和 Raft。只要你接触过分布式，就不会对它们感到陌生。经过从业者这些年的探索和科普，网上关于 Paxos 和 Raft 算法的高质量文章也是一搜一大把了。

所以，今天这一讲我不打算全面展开数据复制的方方面面，而是会聚焦在与分布式数据库相关的，比较重要也比较有意思的两个知识点上，这就是分片元数据的存储和数据复制的效率。

分片元数据的存储

我们知道，在任何一个分布式存储系统中，收到客户端请求后，承担路由功能的节点首先要访问分片元数据（简称元数据），确定分片对应的节点，然后才能访问真正的数据。这里说的元数据，一般会包括分片的数据范围、数据量、读写流量和分片副本处于哪些物理节点，以及副本状态等信息。

从存储的角度看，元数据也是数据，但特别之处在于每一个请求都要访问它，所以元数据的存储很容易成为整个系统的性能瓶颈和高可靠性的短板。如果系统支持动态分片，那么分片要自动地分拆、合并，还会在节点间来回移动。这样，元数据就处在不断变化中，又带来了多副本一致性（Consensus）的问题。

下面，让我们看看，不同的产品具体是如何存储元数据的。

静态分片

最简单的情况是静态分片。我们可以忽略元数据变动的问题，只要把元数据复制多份放在对应的工作节点上就可以了，这样同时兼顾了性能和高可靠。TBase 大致就是这个思路，直接将元数据存储在协调节点上。即使协调节点是工作节点，随着集群规模扩展，会导致元数据副本过多，但由于哈希分片基本上就是静态分片，也就不用考虑多副本一致性的问题。

但如果要更新分片信息，这种方式显然不适合，因为副本数量过多，数据同步的代价太大了。所以对于动态分片，通常是不会在有工作负载的节点上存放元数据的。

那要怎么设计呢？有一个凭直觉就能想到的答案，那就是专门给元数据搞一个小规模的集群，用 Paxos 协议复制数据。这样保证了高可靠，数据同步的成本也比较低。

TiDB 大致就是这个思路，但具体的实现方式会更巧妙一些。

TiDB：无服务状态

在 TiDB 架构中，TiKV 节点是实际存储分片数据的节点，而元数据则由 Placement Driver 节点管理。Placement Driver 这个名称来自 Spanner 中对应节点角色，简称为 PD。

在 PD 与 TiKV 的通讯过程中，PD 完全是被动的一方。TiKV 节点定期主动向 PD 报送心跳，分片的元数据信息也就随着心跳一起报送，而 PD 会将分片调度指令放在心跳的返回信息中。等到 TiKV 下次报送心跳时，PD 就能了解到调度的执行情况。

由于每次 TiKV 的心跳中包含了全量的分片元数据，PD 甚至可以不落盘任何分片元数据，完全做成一个无状态服务。这样的好处是，PD 宕机后选举出的新主根本不用处理与旧主的状态衔接，在一个心跳周期后就可以工作了。当然，在具体实现上，PD 仍然会做部分信息的持久化，这可以认为是一种缓存。

我将这个通讯过程画了下来，希望帮助你理解。

三个 TiKV 节点每次上报心跳时，由主副本（Leader）提供该分片的元数据，这样 PD 可以获得全量且没有冗余的信息。

虽然无状态服务有很大的优势，但 PD 仍然是一个单点，也就是说这个方案还是一个中心化的设计思路，可能存在性能方面的问题。

有没有完全“去中心化”的设计呢？当然是有的。接下来，我们就看看 P2P 架构的 CockroachDB 是怎么解决这个问题的。

CockroachDB：去中心化

CockroachDB 的解决方案是使用 Gossip 协议。你是不是想问，为什么不用 Paxos 协议呢？

这是因为 Paxos 协议本质上是一种广播机制，也就是由一个中心节点向其他节点发送消息。当节点数量较多时，通讯成本就很高。

CockroachDB 采用了 P2P 架构，每个节点都要保存完整的元数据，这样节点规模就非常大，当然也就不适用广播机制。而 Gossip 协议的原理是谣言传播机制，每一次谣言都在几个人的小范围内传播，但最终会成为众人皆知的谣言。这种方式达成的数据一致性是 “最终一致性”，即执行数据更新操作后，经过一定的时间，集群内各个节点所存储的数据最终会达成一致。

看到这，你可能有点晕。我们在第 2 讲就说过分布式数据库是强一致性的，现在搞了个最终一致性的元数据，能行吗？

这里我先告诉你结论，CockroachDB 真的是基于“最终一致性”的元数据实现了强一致性的分布式数据库。我画了一张图，我们一起走下这个过程。

节点 A 接到客户端的 SQL 请求，要查询数据表 T1 的记录，根据主键范围确定记录可能在分片 R1 上，而本地元数据显示 R1 存储在节点 B 上。

节点 A 向节点 B 发送请求。很不幸，节点 A 的元数据已经过时，R1 已经重新分配到节点 C。

此时节点 B 会回复给节点 A 一个非常重要的信息，R1 存储在节点 C。

节点 A 得到该信息后，向节点 C 再次发起查询请求，这次运气很好 R1 确实在节点 C。

节点 A 收到节点 C 返回的 R1。

节点 A 向客户端返回 R1 上的记录，同时会更新本地元数据。

可以看到，CockroachDB 在寻址过程中会不断地更新分片元数据，促成各节点元数据达成一致。

看完 TiDB 和 CockroachDB 的设计，我们可以做个小结了。复制协议的选择和数据副本数量有很大关系：如果副本少，参与节点少，可以采用广播方式，也就是 Paxos、Raft 等协议；如果副本多，节点多，那就更适合采用 Gossip 协议。

复制效率

说完了元数据的存储，我们再看看今天的第二个知识点，也就是数据复制效率的问题，具体来说就是 Raft 与 Paxos 在效率上的差异，以及 Raft 的一些优化手段。在分布式数据库中，采用 Paxos 协议的比较少，知名产品就只有 OceanBase，所以下面的差异分析我们会基于 Raft 展开。

Raft 的性能缺陷

我们可以在网上看到很多比较 Paxos 和 Raft 的文章，它们都会提到在复制效率上 Raft 会差一些，主要原因就是 Raft 必须“顺序投票”，不允许日志中出现空洞。在我看来，顺序投票确实是影响 Raft 算法复制效率的一个关键因素。

接下来，我们就分析一下为什么“顺序投票”对性能会有这么大的影响。

我们先看一个完整的 Raft 日志复制过程：

Leader 收到客户端的请求。

Leader 将请求内容（即 Log Entry）追加（Append）到本地的 Log。

Leader 将 Log Entry 发送给其他的 Follower。

Leader 等待 Follower 的结果，如果大多数节点提交了这个 Log，那么这个 Log Entry 就是 Committed Entry，Leader 就可以将它应用（Apply）到本地的状态机。

Leader 返回客户端提交成功。

Leader 继续处理下一次请求。

以上是单个事务的运行情况。那么，当多事务并行操作时，又是什么样子的呢？我画了张图来演示这个过程。

我们设定这个 Raft 组由 5 个节点组成，T1 到 T5 是先后发生的 5 个事务操作，被发送到这个 Raft 组。

事务 T1 的操作是将 X 置为 1，5 个节点都 Append 成功，Leader 节点 Apply 到本地状态机，并返回客户端提交成功。事务 T2 执行时，虽然有一个 Follower 没有响应，但仍然得到了大多数节点的成功响应，所以也返回客户端提交成功。

现在，轮到 T3 事务执行，没有得到超过半数的响应，这时 Leader 必须等待一个明确的失败信号，比如通讯超时，才能结束这次操作。因为有顺序投票的规则，T3 会阻塞后续事务的进行。T4 事务被阻塞是合理的，因为它和 T3 操作的是同一个数据项，但是 T5 要操作的数据项与 T3 无关，也被阻塞，显然这不是最优的并发控制策略。

同样的情况也会发生在 Follower 节点上，第一个 Follower 节点可能由于网络原因没有收到 T2 事务的日志，即使它先收到 T3 的日志，也不会执行 Append 操作，因为这样会使日志出现空洞。

Raft 的顺序投票是一种设计上的权衡，虽然性能有些影响，但是节点间日志比对会非常简单。在两个节点上，只要找到一条日志是一致的，那么在这条日志之前的所有日志就都是一致的。这使得选举出的 Leader 与 Follower 同步数据非常便捷，开放 Follower 读操作也更加容易。要知道，我说的可是保证一致性的 Follower 读操作，它可以有效分流读操作的访问压力。这一点我们在 24 讲再详细介绍。

Raft 的性能优化方法（TiDB）

当然，在真正的工程实现中，Raft 主副本也不是傻傻地挨个处理请求，还是有一些优化手段的。TiDB 的官方文档对 Raft 优化说得比较完整，我们这里引用过来，着重介绍下它的四个优化点。

批操作（Batch）。Leader 缓存多个客户端请求，然后将这一批日志批量发送给 Follower。Batch 的好处是减少的通讯成本。

流水线（Pipeline）。Leader 本地增加一个变量（称为 NextIndex），每次发送一个 Batch 后，更新 NextIndex 记录下一个 Batch 的位置，然后不等待 Follower 返回，马上发送下一个 Batch。如果网络出现问题，Leader 重新调整 NextIndex，再次发送 Batch。当然，这个优化策略的前提是网络基本稳定。

并行追加日志（Append Log Parallelly）。Leader 将 Batch 发送给 Follower 的同时，并发执行本地的 Append 操作。因为 Append 是磁盘操作，开销相对较大，而标准流程中 Follower 与 Leader 的 Append 是先后执行的，当然耗时更长。改为并行就可以减少部分开销。当然，这时 Committed Entry 的判断规则也要调整。在并行操作下，即使 Leader 没有 Append 成功，只要有半数以上的 Follower 节点 Append 成功，那就依然可以视为一个 Committed Entry，Entry 可以被 Apply。

异步应用日志（Asynchronous Apply）。Apply 并不是提交成功的必要条件，任何处于 Committed 状态的 Log Entry 都确保是不会丢失的。Apply 仅仅是为了保证状态能够在下次被正确地读取到，但多数情况下，提交的数据不会马上就被读取。因此，Apply 是可以转为异步执行的，同时读操作配合改造。

其实，Raft 算法的这四项优化并不是 TiDB 独有的，CockroachDB 和一些 Raft 库也做了类似的优化。比如，SOFA-JRaft 也实现了 Batch 和 Pipeline 优化。

不知道你有没有听说过 etcd，它是最早的、生产级的 Raft 协议开源实现，TiDB 和 CockroachDB 都借鉴了它的设计。甚至可以说，它们选择 Raft 就是因为 etcd 提供了可靠的工程实现，而 Paxos 则没有同样可靠的工程实现。既然是开源，为啥不直接用呢？因为 etcd 是单 Raft 组，写入性能受限。所以，TiDB 和 CockroachDB 都改造成多个 Raft 组，这个设计被称为 Multi Raft，所有采用 Raft 协议的分布式数据库都是 Multi Raft。这种设计，可以让多组并行，一定程度上规避了 Raft 的性能缺陷。

同时，Raft 组的大小，也就是分片的大小也很重要，越小的分片，事务阻塞的概率就越低。TiDB 的默认分片大小是 96M，CockroachDB 的分片不超过 512M。那么，TiDB 的分片更小，就是更好的设计吗？也未必，因为分片过小又会增加扫描操作的成本，这又是另一个权衡点了。

小结

好了，今天的内容就到这里。我们一起回顾下这节课的重点。

分片元数据的存储是分布式数据库的关键设计，要满足性能和高可靠两方面的要求。静态分片相对简单，可以直接通过多副本分散部署的方式实现。

动态分片，满足高可靠的同时还要考虑元数据的多副本一致性，必须选择合适的复制协议。如果搭建独立的、小规模元数据集群，则可以使用 Paxos 或 Raft 等协议，传播特点是广播。如果元数据存在工作节点上，数量较多则可以考虑 Gossip 协议，传播特点是谣言传播。虽然 Gossip 是最终一致性，但通过一些寻址过程中的巧妙设计，也可以满足分布式数据的强一致性要求。

Paxos 和 Raft 是广泛使用的复制协议，也称为共识算法，都是通过投票方式动态选主，可以保证高可靠和多副本的一致性。Raft 算法有“顺序投票”的约束，可能出现不必要的阻塞，带来额外的损耗，性能略差于 Paxos。但是，etcd 提供了优秀的工程实现，促进了 Raft 更广泛的使用，而 etcd 的出现又有 Raft 算法易于理解的内因。

分布式数据库产品都对 Raft 做了一定的优化，另外采用 Multi Raft 设计实现多组并行，再通过控制分片大小，降低事务阻塞概率，提升整体性能。

讲了这么多，回到我们最开始的问题，为什么有时候 Paxos 不是最佳选择呢？一是架构设计方面的原因，看参与复制的节点规模，规模太大就不适合采用 Paxos，同样也不适用其他的共识算法。二是工程实现方面的原因，在适用共识算法的场景下，选择 Raft 还是 Paxos 呢？因为 Paxos 没有一个高质量的开源实现，而 Raft 则有 etcd 这个不错的工程实现，所以 Raft 得到了更广泛的使用。这里的深层原因还是 Paxos 算法本身过于复杂，直到现在，实现 Raft 协议的开源项目也要比 Paoxs 更多、更稳定。

有关分片元数据的存储，在我看来，TiDB 和 CockroachDB 的处理方式都很优雅，但是 TiDB 的方案仍然建立在 PD 这个中心点上，对集群的整体扩展性，对于主副本跨机房、跨地域部署，有一定的局限性。

关于 Raft 的优化方法，大的思路就是并行和异步化，其实这也是整个分布式系统中常常采用的方法，在第 10 讲原子协议的优化中我们还会看到类似的案例。

思考题

最后是今天的思考题时间。我们在第 1 讲就提到过分布式数据库具备海量存储能力，那么你猜，这个海量有上限吗？或者说，你觉得分布式数据库的存储容量会受到哪些因素的制约呢？欢迎你在评论区留言和我一起讨论，我会在答疑篇回复这个问题。

你是不是也经常听到身边的朋友讨论数据复制的相关问题呢，而且得出的结论有可能是错的？如果有的话，希望你能把今天这一讲分享给他 / 她，我们一起来正确地理解分布式数据库的数据复制是怎么一回事。

08 | 基础篇大串讲：重难点回顾+思考题答疑+知识全景图

你好，我是王磊，你也可以叫我 Ivan。

这一讲是我们课程的答疑篇，我会集中讨论前 7 讲布置的思考题，以及留言区中大家关注的一些内容。

第 1 讲：分布式数据库的定义

在第 1 讲中，我们通过层层递进式的分析，给这门课程要讨论的“分布式数据库”下了一个定义：分布式数据库是服务于写多读少、低延时、海量并发 OLTP 场景的，具有海量数据存储能力和高可靠性的关系型数据库。在“内部构成”这一节，我们还着重讨论了几种不属于分布式数据库的解决方案。

在这一讲的思考题部分，我们聊到了 Aurora，我说“Aurora 和这里说的分布式数据库还是有明显差别的”，想看看大家的理解。在留言中，我看到有些同学是持不同观点的，理由是 Aurora 也基于分布式存储的。

那么，为什么我说它不是分布式数据库呢？主要原因就是 Aurora 依然是不支持写入能力的水平扩展。

Aurora 是亚马逊推出的云原生数据库，它采用计算与存储分离的思想，计算能力垂直扩展，存储能力水平扩展。究其原因，它的存储系统是直接架设在自家的分布式存储系统（S3）之上的；而计算节点仍然是单节点，所以是垂直扩展。当然 Aurora 也像 MySQL 一样是支持一写多读的，根据亚马逊的官方说明，可以配置 15 个备节点来分流读操作的压力。由于 Aurora 的元数据会缓存在主节点上的，在发生变更时，主备同步数据有一个小的延迟（小于 100 毫秒），这就造成备节点不能承接写入功能，读也不能保证严格的数据一致性。

我们在定义中强调了海量并发和写多读少，这其实就是要求分布式数据库的写入能力必须是可水平扩展的。

“开心哥”的留言中，提到了 Aurora 是不能支持多写的，准确地抓住了它与 NewSQL 的重要差别。而“南国”同学的留言中还提到了 Aurora 的论文。这篇论文是 2017 年，亚马逊在 SIGMOD 上发表的，论文题目叫做”Amazon Aurora: Design Considerations for High Throughput Cloud-Native Relational Databases”，其中披露了系统架构的设计细节，推荐有兴趣的同学阅读。其实阅读顶会论文是非常不错的学习方法，给“南国”同学点赞，希望大家也尝试一下。

最后，“xy”同学的留言还提到了另外两款同架构的产品，阿里 polarDB，腾讯 CynosDB，说明“xy”同学很关注对系统的横向比较，这也是非常好的学习习惯。我这里再补充一点，华为的 Taurus 也采用了类似 Aurora 的架构。

第 2 讲：数据一致性

第 2 讲中，我们首先明确了强一致性包含数据一致性和事务一致性两个方面，而后展开介绍了数据一致性。我们的讲解方式是先给出一个分析框架，也就是状态和操作双视角，并从状态视角引出了最终一致性这个概念。而后，我们在最终一致性的基础上介绍了 5 种不同强度的一致性模型，其中线性一致性和因果一致性是分布式数据库中普遍应用的。

思考题部分则是“你觉得 Paxos 这个一致性协议和数据一致性又是什么关系呢？”

这个答案嘛，很显然它们是不同的概念。可为什么不同的概念，都叫做一致性呢？就像“峰”同学说的，这个问题其实是翻译造成的。数据一致性对应是 Consistency，而一致性协议对应的则是 Consensus，这个单词更多时候被翻译成共识，就是我们常说的共识算法。

我认为，Paxos 本质上是一种复制协议，约定了副本之间的同步策略，就像我们谈到的最终一致性，同样也只是描述了副本之间同步情况。再看看我们具体介绍的 5 个数据一致性模型，它们都在多副本的基础上又约定了读写策略，所以这两点都是一致性模型（Consistency Model）必不可少的内容。

我在留言中发现有的同学对 Paxos 这样的共识算法认识很深刻，谈了多副本的一致性，讲得很好，但是会忽略了读写策略的作用。“chenchukun”和“tt”同学的留言则抓住了这两个点，点赞。

第 3 讲：事务一致性

第 3 讲谈的事务一致性也是强一致性的组成部分，它具体又细化为 ACID 四个特性，其中的一致性比较宽泛，持久性的实现机制比较稳定，而原子性在分布式架构下面临挑战，最后的隔离性则非常复杂。即使在单体数据库下，工业界也没找到公认的处理隔离性问题的完美方法，很难实现最高级别的可串行化。所以，在分布式架构下，多数产品依然需要在性能与正确性之间进行权衡。

关于原子性和隔离性，我们还有比较多的篇幅展开讨论，所以课程的最后我留了一道关于持久性的思考题，就是预写日志（WAL）写成功，但是数据表写失败，要怎么处理？

在留言中，我发现很多同学都对 WAL 有深刻的认识，也都了解基于日志恢复数据的运作原理。其实，我这个问题是想让大家思考，联机写入的那一刻，除了记录 WAL，数据库还干了什么。这也是一个与 WAL 有关的设计，也很有意思。

事实上，对大多数的数据库来说，实时写入数据时，并不是真的将数据写入数据表在磁盘中的对应文件里，因为数据表的组织形式复杂，不像 WAL 那样只是在文件尾部追加，所以 I/O 操作的延迟太长。因此，写入过程往往是这样的，记录 WAL 日志，同时将数据写入内存，两者都成功就返回客户端了。这些内存中的数据，在 Oracle 和 MySQL 中都被称为脏页，达到一定比例时会批量写入磁盘。而 NewSQL 所采用的 LSM-Tree 存储模型也是大致的思路，只不过在磁盘的数据组织上不同。

写入内存和 WAL 这两个操作构成了一个事务，必须一起成功或失败。

第 4 讲：两种架构风格

第 4 讲我们谈了分布式数据库的两种架构风格 NewSQL 和 PGXC。PGXC 是从代理中间件演化而来，以单体数据库作为数据节点，它的优势是工程实现更稳定。NewSQL 则是以分布式键值系统为基础，引入了很多新技术，这些技术都会在我们的课程中逐步介绍。NewSQL 的代表系统是 Google 的 Spanner，而它的优势就是架构的先进性。

其实关于架构风格的讨论，往往是百家争鸣，各持观点，所以我们的思考题也是一个开放性话题，请大家聊聊自己熟悉的分布式数据库，或者其他分布式系统的架构。

在留言区，“xy”和“赵见跃”同学都提到了 TDSQL，它是不是也属于 PGXC 风格呢？我认为目前腾讯输出的 TDSQL 还不是典型的 PGXC，因为它没有全局时钟，也没有等效的设计去解决全局一致性问题。当然，说它不是，我也是有点纠结的，在 2019 年 TDSQL 的技术演讲中，腾讯的研发人员深入地分析了缺失全局时钟带来的一致性问题，同时也提及了正在进行的技术尝试。所以，我相信 TDSQL 很快会在新版本中增加类似的特性。

“南国”同学还提出了一个新问题：NewSQL 与 PGXC 的界限似乎很模糊，是不是差别就在存储层面，NewSQL 只能存储，而 PGXC 是完整的数据库呢？我认为这只是一个表象，最关键的差异其实是分片设计，或者说是两种架构对数据组织形式上的根本差别。PGXC 的数据是相对固定的，而 NewSQL 的数据是能够更加灵活移动的，移动意味着解锁了数据与节点的关系，有点像灵魂和躯体的关系。如果灵魂不被限制在一个躯体里，那是不是就可以实现永生。解锁了数据与节点的依赖关系，系统也更加鲁棒。总的来说，我认为能够适应变化，在各种意外情况下，都能生存下来，这是设计分布式系统的核心思想。

第 5 讲：全局时钟

第 5 讲，我们介绍了全局时钟的不同实现方式，包括物理时钟和逻辑时钟两种方式，物理时钟的难点首先是要做到足够高的精度，其次是在使用时如何处理时钟误差，学术一点的说法叫做时钟的置信区间。逻辑时钟实际上是混合逻辑时钟，还是会引入物理时钟作为参考，但主要通过逻辑控制来保证时钟的单调递增。有同学问是不是可以不用物理时钟，我要说的是，对于多时间源是不行的，因为这样会造成不相关事件的时钟偏差太大，也就是偏序拼接的全序失真太大。如果是单时间源的混合逻辑时钟，它的好处是不用处理误差，简化了其他模块的设计。而 HLC 这样多时间源的混合逻辑时钟，则依然有时钟误差的问题。

这一讲的思考题是让大家思考一下“时间对于分布式数据库的影响是什么？”我发现大家的留言对这个问题的讨论并不多。其实，时间在很多分布式系统都是存在的，比如 HBase 对于各节点的时钟偏移也是有限制，只不过它的容忍度更高，可以达到几十秒。而在分布式数据库中与时间有关的功能主要体现在事务并发控制，比如 MVCC、读写冲突。既然留言讨论不多，我这里就先不做点评，卖个关子，在第 11 讲、第 12 讲中我们再来详细聊聊。

第 6 讲：数据分片

第 6 讲，我们介绍了分布式数据库中一个非常重要的概念“分片”。分片机制的两个关键点是分片策略和分片调度机制。分片策略包括 Hash 和 Range，调度机制则包括静态和动态两种。分片机制的实现和架构有很大的关系，PGXC 架构基本上都是静态分片，是以 Hash 分片为主，有的产品也同时支持 Range 分片。关于 NewSQL 架构，我们主要介绍了最有代表性的动态 Range 分片。

这一讲的思考题，就是在问分片元数据的存储方案。

分析这个问题，首先要看元数据会不会变更，比如静态分片就不会变更，那么就可以把它复制多份部署在所有工作节点上，如果会变更，那就要考虑变更带来的多副本一致性问题，这里其实是和后面的 07 讲相呼应的。现在读完 07 讲，你自然应该知道，如果是少数节点集中存储元数据，那么可以采用 Paxos 协议保证一致性。如果是 P2P 架构，因为节点规模太大，那就适合采用 Gossip 协议。设计的权衡点主要是在于节点规模大小对传播效率的影响。

“开心哥”和“真名不叫黄金”两位同学都回答其中的一种情况，就是基于 etcd 或 PD（基于 etcd）来存储元数据，而 etcd 是 Raft 协议的开源实现。

第 7 讲：数据复制

第 7 讲，我们讨论的话题是数据复制，这和分片一样是非常基础和重要的内容。这一讲我们介绍了两个知识点，其中第一个就是分片元数据的存储方案，刚刚我们已经说过了，第二个知识点是数据复制的效率问题。Raft 由于顺序投票的限制，在复制效率上比 Paxos 稍差。但是因为 Raft 有高质量的开源实现项目 etcd，而 Paxos 因为算法复杂没有稳定的开源能实现，所有 TiDB 和 CockroachDB 还是选择了 Raft 协议。同时，TiDB 和 CockroachDB 采用了 Multi Raft 的方式，让多分片并行处理提升性能。两者在 Raft 协议实现上也进行了若干改进。这些改进思路很有普适性，一些独立的 Raft 项目也同样实现了，比如 SOFA-JRaft。

这一讲的思考题，我们讨论的是分布式数据库的存储上限。你一定有点疑惑，既然分布式数据库是一个水平扩展的系统，可以不断地增加节点。那么为什么还有存储上限呢？事实上，不仅分布式数据库，绝大多数分布式存储系统都是有上限的。因为有了这个限制，可以简化系统架构设计，而这个上限当然也是一个很大的数值，能够满足绝大多数业务场景的需求。

以 CockroachDB 为例，它的存储容量大致是 4EB，而这个限制是由元数据的存储方式决定的。

在 CockroachDB 中存储分片元数据的数据结构叫做 Meta ranges，它是一个两层索引结构，第一层 Meta1 存储了第二层 Meta2 的地址，第二层 Meta2 则指向了具体分片。每个节点会保存 Meta1 的定位，而且 Meta1 是不会分拆的，这样就更好的稳定性。Meta1 和 Meta2 的长度都是 18 位，所以 CockroachDB 中最多只能有 2^36 个分片。CockroachDB 默认分片初始大小是 64M，那么可以算出一个总存储量是 4EB，2^36*64M。从这个意义上说，CockroachDB 的最大存储容量是 4EB。当然，如果分片增大整体容量还会增加，但第 6 讲我们介绍过分片过大是有副作用的，所以不能无限制增加，系统的容量还是有上限的。

小结

最后，要特别感谢“Monday”同学，他建议我们增加一张分布式数据库的全景图，让知识的组织更加系统。我觉得这是个好主意，和编辑商量了一下，最后决定在每个答疑篇都会增量补充这个全景图，在最后的第 30 讲大家就能看到完整的全景图了。这样安排还有一个好处，就是帮助大家阶段性地复习前面课程。

分布式数据全景图 1/4

如果你对今天的内容有任何疑问，欢迎在评论区留言和我一起讨论。要是你身边的朋友也对分布式数据库这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

Alexandre Verbitski et al.: Amazon Aurora: Design Considerations for High Throughput Cloud-Native Relational Databases

09｜原子性：2PC还是原子性协议的王者吗？

你好，我是王磊，你也可以叫我 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 讲中介绍的“单元架构 + 单体数据库”吗? 这类方案需要在应用层实现事务的原子性，经常会用到 TCC 协议。

下面，我用一个转账的例子向你解释 TCC 处理流程。

小明和小红都是番茄银行的客户，现在小明打算给小红转账 2,000 元，这件事在番茄银行存款系统中是如何实现的呢？

我们先来看下系统的架构示意图：

显然，番茄银行的存款系统是单元化架构的。也就是说，系统由多个单元构成，每个单元包含了一个存款系统的部署实例和对应的数据库，专门为某一个地区的用户服务。比如，单元 A 为北京用户服务，单元 B 为上海用户服务。

单元化架构的好处是每个单元只包含了部分用户，这样运行负载比较小，而且一旦出现问题，也只影响到少部分客户，可以提升整个存款系统的可靠性。

不过这种架构也有局限性。那就是虽然单元内的客户转账非常容易，但是跨单元的转账需要引入额外的处理机制，而 TCC 就是一种常见的选择。

TCC 的整个过程由两类角色参与，一类是事务管理器，只能有一个；另一类是事务参与者，也就是具体的业务服务，可以是多个，每个服务都要提供 Try、Confirm 和 Cancel 三个操作。

下面是 TCC 的具体执行过程。

小明的银行卡在北京的网点开户，而小红的银行卡是在上海出差时办理的，所以两人的账户分别在单元 A 和单元 B 上。现在小明的账户余额是 4,900 元，要给小红转账 2,000 元，一个正常流程是这样的。

第一阶段，事务管理器会发出 Try 操作，要求进行资源的检查和预留。也就是说，单元 A 要检查小明账户余额并冻结其中的 2,000 元，而单元 B 要确保小红的账户合法，可以接收转账。在这个阶段，两者账户余额始终不会发生变化。

第二阶段，因为参与者都已经做好准备，所以事务管理器会发出 Confirm 操作，执行真正的业务，完成 2,000 元的划转。

但是很不幸，小红账户是无法接收转账的非法账户，处理过程就变成下面的样子。

第一阶段，事务管理器发出 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”，对当时数据库系统研究成果和实践进行了总结，而 2PC 在工程中的应用还要再早上几年。

2PC 的处理过程也分为准备和提交两个阶段，每个阶段都由事务管理器与资源管理器共同完成。其中，事务管理器作为事务的协调者只有一个，而资源管理器作为参与者执行具体操作允许有多个。

2PC 具体是如何运行的呢？我们还是说回小明转账的例子。

小明给小红转账没有成功，两人又到木瓜银行来尝试。

木瓜银行的存款系统采用了分库分表方案，系统架构大致是这样的：

在木瓜银行的存款系统中，所有客户的数据被分散存储在多个数据库实例中，这些数据库实例具有完全相同的表结构。业务逻辑部署在应用服务器上，通过数据库中间件访问底层的数据库实例。数据库中间件作为事务管理器，资源管理器就是指底层的数据库实例。

假设，小明和小红的数据分别被保存在数据库 D1 和 D2 上。

我们还是先讲正常的处理流程。

第一阶段是准备阶段，事务管理器首先向所有参与者发送待执行的 SQL，并询问是否做好提交事务的准备（Prepare）；参与者记录日志、分别锁定了小明和小红的账户，并做出应答，协调者接收到反馈 Yes，准备阶段结束。

第二阶段是提交阶段，如果所有数据库的反馈都是 Yes，则事务管理器会发出提交（Commit）指令。这些数据库接受指令后，会进行本地操作，正式提交更新余额，给小明的账户扣减 2,000 元，给小红的账户增加 2,000 元，然后向协调者返回 Yes，事务结束。

那如果小明的账户出了问题，导致转账失败，处理过程会是怎样呢？

第一阶段，事务管理器向所有数据库发送待执行的 SQL，并询问是否做好提交事务的准备。

由于小明之前在木瓜银行购买了基金定投产品，按照约定，每月银行会自动扣款购买基金，刚好这个自动扣款操作正在执行，先一步锁定了账户。数据库 D1 发现无法锁定小明的账户，只能向事务管理器返回失败。

第二阶段，因为事务管理器发现数据库 D1 不具备执行事务的条件，只能向所有数据库发出“回滚”（Rollback）指令。所有数据库接收到指令后撤销第一阶段的操作，释放资源，并向协调者返回 Yes，事务结束。小明和小红的账户余额均保持不变。

2PC 的三大问题

学完了 TCC 和 2PC 的流程，我们来对比下这两个协议。

相比于 TCC，2PC 的优点是借助了数据库的提交和回滚操作，不侵入业务逻辑。但是，它也存在一些明显的问题：

同步阻塞

执行过程中，数据库要锁定对应的数据行。如果其他事务刚好也要操作这些数据行，那它们就只能等待。其实同步阻塞只是设计方式，真正的问题在于这种设计会导致分布式事务出现高延迟和性能的显著下降。

单点故障

事务管理器非常重要，一旦发生故障，数据库会一直阻塞下去。尤其是在第二阶段发生故障的话，所有数据库还都处于锁定事务资源的状态中，从而无法继续完成事务操作。

数据不一致

在第二阶段，当事务管理器向参与者发送 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”，因为它是基于分布式存储系统 BigTable 建立的模型，所以可以和 NewSQL 无缝链接。

Percolator 模型同时涉及了隔离性和原子性的处理。今天，我们主要关注原子性的部分，在讲并发控制时，我再展开隔离性的部分。

使用 Percolator 模型的前提是事务的参与者，即数据库，要支持多版本并发控制（MVCC）。不过你不用担心，现在主流的单体数据库和分布式数据库都是支持的 MVCC。

在转账事务开始前，小明和小红的账户分别存储在分片 P1 和 P2 上。如果你不了解分片的含义，可以回到第 6 讲学习。当然，你也可以先用单体数据库来替换分片的概念，这并不会妨碍对流程的理解。

上图中的 Ming 代表小明，Hong 代表小红。在分片的账户表中各有两条记录，第一行记录的指针（write）指向第二行记录，实际的账户余额存储在第二行记录的 Bal. data 字段中。

Bal.data 分为两个部分，冒号前面的是时间戳，代表记录的先后次序；后面的是真正的账户余额。我们可以看到，现在小明的账户上有 4,900 元，小红的账户上有 300 元。

我们来看下 Percolator 的流程。

第一，准备阶段，事务管理器向分片发送 Prepare 请求，包含了具体的数据操作要求。

分片接到请求后要做两件事，写日志和添加私有版本。关于私有版本，你可以简单理解为，在 lock 字段上写入了标识信息的记录就是私有版本，只有当前事务能够操作，通常其他事务不能读写这条记录。

你可能注意到了，两个分片上的 lock 内容并不一样。

主锁的选择是随机的，参与事务的记录都可能拥有主锁，但一个事务只能有一条记录拥有主锁，其他参与事务的记录在 lock 字段记录了指针信息“primary@Ming.bal”，指向主锁记录。

准备阶段结束的时候，两个分片都增加了私有版本记录，余额正好是转账顺利执行后的数字。

第二，提交阶段，事务管理器只需要和拥有主锁的分片通讯，发送 Commit 指令，且不用附带其他信息。

分片 P1 增加了一条新记录时间戳为 8，指向时间戳为 7 的记录，后者在准备阶段写入的主锁也被抹去。这时候 7、8 两条记录不再是私有版本，所有事务都可以看到小明的余额变为 2,700 元，事务结束。

你或许要问，为什么在提交阶段不用更新小红的记录？

Percolator 最有趣的设计就是这里，因为分片 P2 的最后一条记录，保存了指向主锁的指针。其他事务读取到 Hong7 这条记录时，会根据指针去查找 Ming.bal，发现记录已经提交，所以小红的记录虽然是私有版本格式，但仍然可视为已经生效了。

当然，这种通过指针查找的方式，会给读操作增加额外的工作。如果每个事务都照做，性能损耗就太大了。所以，还会有其他异步线程来更新小红的余额记录，最终变成下面的样子。

现在，让我们对比 2PC 的问题，来看看 Percolator 模型有哪些改进。

数据不一致

2PC 的一致性问题主要缘自第二阶段，不能确保事务管理器与多个参与者的通讯始终正常。

但在 Percolator 的第二阶段，事务管理器只需要与一个分片通讯，这个 Commit 操作本身就是原子的。所以，事务的状态自然也是原子的，一致性问题被完美解决了。

单点故障

Percolator 通过日志和异步线程的方式弱化了这个问题。

一是，Percolator 引入的异步线程可以在事务管理器宕机后，回滚各个分片上的事务，提供了善后手段，不会让分片上被占用的资源无法释放。

二是，事务管理器可以用记录日志的方式使自身无状态化，日志通过共识算法同时保存在系统的多个节点上。这样，事务管理器宕机后，可以在其他节点启动新的事务管理器，基于日志恢复事务操作。

Percolator 模型在分布式数据库的工程实践中被广泛借鉴。比如，分布式数据库 TiDB，完全按照该模型实现了事务处理；CockroachDB 也从 Percolator 模型获得灵感，设计了自己的 2PC 协议。

CockroachDB 的变化在于没有随机选择主锁，而是引入了一张全局事务表，所有分片记录的指针指向了这个事务表中对应的事务记录。单就原子性处理来说，这种设计似乎差异不大，但在相关设计上会更有优势，具体是什么优势呢，下一讲我来揭晓答案。

PGXC 阵营：GoldenDB 的一阶段提交

那么，分布式数据库的另一大阵营，PGXC，又如何解决 2PC 的问题呢？

GoldenDB 展现了另外一种改良思路，称之为“一阶段提交”。

GoldenDB 遵循 PGXC 架构，包含了四种角色：协调节点、数据节点、全局事务器和管理节点，其中协调节点和数据节点均有多个。GoldenDB 的数据节点由 MySQL 担任，后者是独立的单体数据库。

虽然名字叫“一阶段提交”，但 GoldenDB 的流程依然可以分为两个阶段。

第一阶段，GoldenDB 的协调节点接到事务后，在全局事务管理器（GTM）的全局事务列表中将事务标记成活跃的状态。这个标记过程是 GoldenDB 的主要改进点，实质是通过全局事务列表来申请资源，规避可能存在的事务竞争。

这样的好处是避免了与所有参与者的通讯，也减少了很多无效的资源锁定动作。

第二阶段，协调节点把一个全局事务分拆成若干子事务，分配给对应的 MySQL 去执行。如果所有操作成功，协调者节点会将全局事务列表中的事务标记为结束，整个事务处理完成。如果失败，子事务在单机上自动回滚，而后反馈给协调者节点，后者向所有数据节点下发回滚指令。

由于 GoldenDB 属于商业软件，公开披露信息有限，我们也就不再深入细节了，你只要能够理解上面我讲的两个阶段就够了。

GoldenDB 的“一阶段提交”，本质上是改变了资源的申请方式，更准确的说法是，并发控制手段从锁调度变为时间戳排序（Timestamp Ordering）。这样，在正常情况下协调节点与数据节点只通讯一次，降低了网络不确定性的影响，数据库的整体性能有明显提升。因为第一阶段不涉及数据节点的操作，也就弱化了数据一致性和单点故障的问题。

小结

好了，以上就是今天的主要内容了，我希望你能记住以下几点：

事务的原子性就是让包含若干操作的事务表现得像一个最小粒度的操作，而这个操作一旦被执行只有两种结果，成功或者失败。

相比于单机事务，分布式事务原子性的复杂之处在于增加了多物理设备和网络的不确定性，需要通过一定的算法和协议来实现。这类协议也有不少，我重点介绍了 TCC 和 2PC 这两个常用协议。

TCC 提供了一个应用层的分布式事务框架，它对参与者没有特定要求，但有较强的业务侵入；2PC 是专为数据库这样的资源层设计的，不侵入业务，也是今天分布式数据库主流产品的选择。

考虑到 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 的延时，无论是理论模型还是工程实践都已经过验证。

那么，它们又有哪些精巧构思呢？我将在下一讲为你介绍这些黑科技。

思考题

最后，我给你留下一个思考题。今天内容主要围绕着 2PC 展开，而它的第一阶段“准备阶段”也被称为“投票阶段”，“投票”这个词是不是让你想到 Paxos 协议呢？

那么，你觉得 2PC 和 Paxos 协议有没有关系，如果有又是什么关系呢？

如果你想到了答案，又或者是触发了你对相关问题的思考，都可以在评论区和我聊聊，我会在下一讲和你一起探讨。最后，谢谢你的收听，希望这节课能带给你一些收获，欢迎你把它分享给周围的朋友，一起进步。

学习资料

Daniel Peng and Frank Dabek: Large-scale Incremental Processing Using Distributed Transactions and Notifications

Jim Gray: Notes on Data Base Operating Systems

10 | 原子性：如何打破事务高延迟的魔咒？

你好，我是王磊，你也可以加我 Ivan。

通过上一讲的学习，你已经知道使用两阶段提交协议（2PC）可以保证分布式事务的原子性，但是，2PC 的性能始终是一个绕不过去的坎儿。

那么，它到底有多慢呢？

我们来看一组具体数据。2013 年的 MySQL 技术大会上（Percona Live MySQL C&E 2013），Randy Wigginton 等人在一场名为“Distributed Transactions in MySQL”的演讲中公布了一组 XA 事务与单机事务的对比数据。XA 协议是 2PC 在数据库领域的具体实现，而 MySQL（InnoDB 存储引擎）正好就支持 XA 协议。我把这组数据转换为下面的折线图，这样看起来会更加直观些。

其中，横坐标是并发线程数量，纵坐标是事务延迟，以毫秒为单位；蓝色的折线表示单机事务，红色的折线式表示跨两个节点的 XA 事务。我们可以清晰地看到，无论并发数量如何，XA 事务的延迟时间总是在单机事务的 10 倍以上。

这绝对是一个巨大的性能差距，所以这个演讲最终的建议是“不要使用分布式事务”。

很明显，今天，任何计划使用分布式数据库的企业，都不可能接受 10 倍于单体数据库的事务延迟。如果仍旧存在这样大的差距，那分布式数据库也必然是无法生存的，所以它们一定是做了某些优化。

具体是什么优化呢？这就是我们今天要讨论的主题，分布式事务要怎么打破高延迟的魔咒。

先别急，揭开谜底之前，我们先来算算，2PC 协议的事务延迟大概是多少。当然，这里我们所说的 2PC 都是指基于 Percolator 优化的改进型，如果你还不了解 Percolator 可以回到第 9 讲复习一下。

事务延迟估算

整个 2PC 的事务延迟由两个阶段组成，可以用公式表达为：

Ltxn​=Lprep​+Lcommit​

其中，Lprep​ 是准备阶段的延迟，Lcommit​ 是提交阶段的延迟。

我们先说准备阶段，它是事务操作的主体，包含若干读操作和若干写操作。我们把读操作的次数记为 R，读操作的平均延迟记为 Lr​，写操作次数记为 W，写操作平均延迟记为 Lw​。那么整个准备阶段的延迟可以用公式表达为:

Lprep​=R∗Lr​+W∗Lw​

在不同的产品架构下，读操作的成本是不一样的。我们选一种最乐观的情况，CockroachDB。因为它采用 P2P 架构，每个节点既承担了客户端服务接入的工作，也有请求处理和数据存储的职能。所以，最理想的情况是，读操作的客户端接入节点，同时是当前事务所访问数据的 Leader 节点，那么所有读取就都是本地操作。

磁盘操作相对网络延迟来说是极短的，所以我们可以忽略掉读取时间。那么，准备阶段的延迟主要由写入操作决定，可以用公式表达为：

Lprep​=W∗Lw​

我们都知道，分布式数据库的写入，并不是简单的本地操作，而是使用共识算法同时在多个节点上写入数据。所以，一次写入操作延迟等于一轮共识算法开销，我们用 Lc​ 代表一轮共识算法的用时，可以得到下面的公式：

Lprep​=W∗Lc​

我们再来看第二阶段，提交阶段，第 9 讲我们介绍了 Percolator 模型，它的提交阶段只需要写入一次数据，修改整个事务的状态。对于 CockroachDB，这个事务标识可以保存在本地。那么提交操作的延迟也是一轮共识算法，也就是：

Lcommit​=Lc​

分别得到两个阶段的延迟后，带入最开始的公式，可以得到：

Ltxn​=(W+1)∗Lc​

我们把这个公式带入具体例子里来看一下。

这次还是小明给小红转账，金额是 500 元。

在这个转账事务中，包含两条写操作 SQL，分别是扣减小明账户余额和增加小红账户余额，W 等于 2。再加上提交操作，一共有 3 个 Lc​。我们可以看到，这个公式里事务的延迟是与写操作 SQL 的数量线性相关的，而真实场景中通常都会包含多个写操作，那事务延迟肯定不能让人满意。

优化方法

缓存写提交（Buffering Writes until Commit）

怎么缩短写操作的延迟呢？

第一个办法是将所有写操作缓存起来，直到 commit 语句时一起执行，这种方式称为 Buffering Writes until Commit，我把它翻译为“缓存写提交”。而 TiDB 的事务处理中就采用这种方式，我借用 TiDB 官网的一张交互图来说明执行过程。

所有从 Client 端提交的 SQL 首先会缓存在 TiDB 节点，只有当客户端发起 Commit 时，TiDB 节点才会启动两阶段提交，将 SQL 被转换为 TiKV 的操作。这样，显然可以压缩第一阶段的延迟，把多个写操作 SQL 压缩到大约一轮共识算法的时间。那么整个事务延迟就是：

Ltxn​=2∗Lc​

但缓存写提交存在两个明显的缺点。

首先是在客户端发送 Commit 前，SQL 要被缓存起来，如果某个业务场景同时存在长事务和海量并发的特点，那么这个缓存就可能被撑爆或者成为瓶颈。

其次是客户端看到的 SQL 交互过程发生了变化，在 MySQL 中如果出现事务竞争，判断优先级的规则是 First Write Win，也就是对同一条记录先执行写操作的事务获胜。而 TiDB 因为缓存了所有写 SQL，所以就变成了 First Commit Win，也就是先提交的事务获胜。我们用一个具体的例子来演示这两种情况。

在 MySQL 中同时执行 T1，T2 两个事务，T1 先执行了 update，所以获得优先权成功提交。而 T2 被阻塞，等待 T1 提交后才完成提交。

在 TiDB 中执行同样的 T1、T2，虽然 T2 晚于 T1 执行 update，但却先执行了 commit，所以 T2 获胜，T1 失败。

First Write Win 与 First Commit Win 在交互上是显然不同的，这虽然不是大问题，但对于开发者来说，还是有一定影响的。可以说，TiDB 的“缓存写提交”方式已经不是完全意义上的交互事务了。

管道（Pipeline）

有没有一种方法，既能缩短延迟，又能保持交互事务的特点呢？还真有。这就是 CockroachDB 采用的方式，称为 Pipeline。具体过程就是在准备阶段是按照顺序将 SQL 转换为 K/V 操作并执行，但是并不等待返回结果，直接执行下一个 K/V 操作。

这样，准备阶段的延迟，等于最慢的一个写操作延迟，也就是一轮共识算法的开销，所以整体延迟同样是：

Lprep​=Lc​

那么，加上提交阶段的一轮共识算法开销：

Ltxn​=2∗Lc​

我们再回到小明转账的例子来看一下。

同样的操作，按照 Pipeline 方式，增加小红账户余额时并不等待小明扣减账户的动作结束，两条 SQL 的执行时间约等于 1 个 Lc​。加上提交阶段的 1 个 Lc​，一共是 2 个 Lc​，并且延迟也不再随着 SQL 数量增加而延长。

2 个 Lc​ 是多久呢？我们带入真实场景，来计算一下 。

首先，我们评估一下期望值。对于联机交易来说，延迟通常不超过 1 秒，如果用户体验良好，则要控制在 500 毫秒以内。其中留给数据库的处理时间不会超过一半，也就是 250-500 毫秒。这样推算，Lc​ 应该控制在 125-250 毫秒之间。

再来看看真实的网络环境。我们知道人类现有的科技水平是不能超越光速的，这个光速是指光在真空中的传播速度，大约是 30 万千米每秒。而光纤由于传播介质不同和折线传播的关系，传输速度会降低 30%，大致是 20 万千米每秒。但是，这仍然是一个比较理想的速度，因为还要考虑网络上的各种设备、协议处理、丢包重传等等情况，实际的网络延迟还要长很多。

为了让你有一个更直观的感受。我这里引用了论文“Highly Available Transactions: Virtues and Limitations“中的一些数据，这篇论文发表在 VLDB2014 上，在部分章节中初步探讨了系统全球化部署面临的延迟问题。论文作者在亚马逊 EC2 上，使用 Ping 包的方式进行了实验，并统计了一周时间内 7 个不同地区机房之间的 RTT（Round-Rip Time，往返延迟）数据。

简单来说，RTT 就是数据在两个节点之间往返一次的耗时。在讨论网络延迟的时候，为了避免歧义，我们通常使用 RTT 这个概念。

实验中，地理跨度较大两个机房是巴西圣保罗和新加坡，两地之间的理论 RTT 是 106.7 毫秒（使用光速测算），而实际测试的 RTT 均值为 362.8 毫秒，P95（95%）RTT 均值为 649 毫秒。将 649 毫秒代入公式，那 Ltxn​ 就是接近 1.3 秒，这显然太长了。而考虑到共识算法的数据包更大，这个延迟还会更长。

并行提交（Parallel Commits）

但是，像 CockroachDB、YugabyteDB 这样分布式数据库，它们的目标就是全球化部署，所以还要努力去压缩事务延迟。

可是，还能怎么压缩呢？准备阶段的操作已经压缩到极限了，commit 这个动作也不能少呀，那就只有一个办法，让这两个动作并行执行。

在优化前的处理流程中，CockroachDB 会记录事务的提交状态：

TransactionRecord{
    Status: COMMITTED,
    ...
}

并行执行的过程是这样的。

准备阶段的操作，在 CockroachDB 中被称为意向写。这个并行执行就是在执行意向写的同时，就写入事务标志，当然这个时候不能确定事务是否提交成功的，所以要引入一个新的状态“Staging”，表示事务正在进行。那么这个记录事务状态的落盘操作和意向写大致是同步发生的，所以只有一轮共识算法开销。事务表中写入的内容是类似这样的：

TransactionRecord{
    Status: STAGING,
    Writes: []Key{"A", "C", ...},
    ...
}

Writes 部分是意向写的 Key。这是留给异步进程的线索，通过这些 Key 是否写成功，可以倒推出事务是否提交成功。

而客户端得到所有意向写的成功反馈后，可以直接返回调用方事务提交成功。注意！这个地方就是关键了，客户端只在当前进程内判断事务提交成功后，不维护事务状态，而直接返回调用方；事后由异步线程根据事务表中的线索，再次确认事务的状态，并落盘维护状态记录。这样事务操作中就减少了一轮共识算法开销。

你有没有发现，并行提交的优化思路其实和 Percolator 很相似，那就是不要纠结于在一次事务中搞定所有事情，可以只做最少的工作，留下必要的线索，就可以达到极致的速度。而后续的异步进程，只要根据线索，完成收尾工作就可以了。

小结

好了，这讲的内容到这里就该结束了。那么，让我们再回顾一下今日的内容吧。

高延迟一直是分布式事务的痛点。在一些测试案例中，MySQL 多节点的 XA 事务延迟甚至达到单机事务的 10 倍。按照 2PC 协议的处理过程，分布式事务延迟与事务内写操作 SQL 语句数量直接相关。延迟时间可以用公式表达为 Ltxn​=(W+1)∗Lc​ 。

使用缓存写提交方式优化，可以缩短准备阶段的延迟，Ltxn​=2∗Lc​。但这种方式与事务并发控制技术直接相关，仅在乐观锁时适用，TiDB 使用了这种方式。但是，一旦将并发控制改为悲观协议，事务延迟又会上升。

通过管道方式优化，整体事务延迟可以降到两轮共识算法开销，并且在悲观协议下也适用。

使用并行提交，可以进一步将整体延迟压缩到一轮共识算法开销。CockroachDB 使用了管道和并行提交这两种优化手段。

今天我们分析了分布式事务高延迟的原因和一些优化的手段，理想的情况下，事务延迟可以缩小到一轮共识算法开销。你看，是不是对分布式数据库更有信心了。当然，在测算事务延迟时我们还是预设了一些前提，比如读操作成本趋近于零，这仅在特定情况下对 CockroachDB 适用，很多时候是不能忽略的，其他产品则更是不能无视这个成本。那么，在全球化部署下，执行读操作时，如何获得满意延迟呢？或者还有什么其他难题，我们在第 24 讲中会继续探讨。

思考题

最后，我们的思考题还是关于 2PC 的。第 9 讲和第 10 讲中，我们介绍了 2PC 的各种优化手段，今天最后介绍的“并行提交”方式将延迟压缩达到的一轮共识算法开销，应该是现阶段比较极致的方法了。不过，在工程实现中其实还有一些其他的方法，也很有趣，我想请你也介绍下自己了解的 2PC 优化方法。

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对如何优化分布式事务性能这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

Peter Bailis et al.: Highly Available Transactions: Virtues and Limitations

Randy Wigginton et al.: Distributed Transactions in MySQL

11｜隔离性：读写冲突时，快照是最好的办法吗？

你好，我是王磊，你也可以叫我 Ivan。我们今天的话题要从多版本并发控制开始。

多版本并发控制（Multi-Version Concurrency Control，MVCC）就是通过记录数据项历史版本的方式，来提升系统应对多事务访问的并发处理能力。今天，几乎所有主流的单体数据库都实现了 MVCC，它已经成为一项非常重要也非常普及的技术。

MVCC 为什么这么重要呢？我们通过下面例子来回顾一下 MVCC 出现前的读写冲突场景。

图中事务 T1、T2 先后启动，分别对数据库执行写操作和读操作。写操作是一个过程，在过程中任意一点，数据的变更都是不完整的，所以 T2 必须在数据写入完成后才能读取，也就形成了读写阻塞。反之，如果 T2 先启动，T1 也要等待 T2 将数据完全读取后，才能执行写入。

早期数据库的设计和上面的例子一样，读写操作之间是互斥的，具体是通过锁机制来实现的。

你可能会觉得这个阻塞也没那么严重，磁盘操作应该很快吧?

别着急下结论，让我们来分析下。如果先执行的是 T1 写事务，除了磁盘写入数据的时间，由于要保证数据库的高可靠，至少还有一个备库同步复制主库的变更内容。这样，阻塞时间就要再加上一次网络通讯的开销。

如果先执行的是 T2 只读事务，情况也好不到哪去，虽然不用考虑复制问题，但是读操作通常会涉及更大范围的数据，这样一来加锁的记录会更多，被阻塞的写操作也就更多。而且，只读事务往往要执行更加复杂的计算，阻塞的时间也就更长。

所以说，用锁解决读写冲突问题，带来的事务阻塞开销还是不小的。相比之下，用 MVCC 来解决读写冲突，就不存在阻塞问题，要优雅得多了。

第 4 讲中我们介绍了 PGXC 和 NewSQL 两种架构风格，而且还说到分布式数据库的很多关键设计是和整体架构风格有关的。MVCC 的设计就是这样，随架构风格不同而不同。在 PGXC 架构中，因为数据节点就是单体数据库，所以 PGXC 的 MVCC 实现方式其实就是单体数据库的实现方式。

单体数据库的 MVCC

那么，就让我们先看下单体数据库的 MVCC 是怎么设计的。开头我们说了实现 MVCC 要记录数据的历史版本，这就涉及到存储的问题。

MVCC 的存储方式

MVCC 有三类存储方式，一类是将历史版本直接存在数据表中的，称为 Appane-Only，典型代表是 PostgreSQL。另外两类都是在独立的表空间存储历史版本，它们区别在于存储的方式是全量还是增量。增量存储就是只存储与版本间变更的部分，这种方式称为 Delta，也就是数学中常作为增量符号的那个 Delta，典型代表是 MySQL 和 Oracle。全量存储则是将每个版本的数据全部存储下来，这种方式称为 Time-Travle，典型代表是 HANA。我把这三种方式整理到了下面的表格中，你看起来会更直观些。

下面，我们来看看每种方式的优缺点。

Append-Only 方式

优点

在事务包含大量更新操作时也能保持较高效率。因为更新操作被转换为 Delete + Insert，数据并未被迁移，只是有当前版本被标记为历史版本，磁盘操作的开销较小。

可以追溯更多的历史版本，不必担心回滚段被用完。

因为执行更新操作时，历史版本仍然留在数据表中，所以如果出现问题，事务能够快速完成回滚操作。

缺点

新老数据放在一起，会增加磁盘寻址的开销，随着历史版本增多，会导致查询速度变慢。

Delta 方式

优点

因为历史版本独立存储，所以不会影响当前读的执行效率。

因为存储的只是变化的增量部分，所以占用存储空间较小。

缺点

历史版本存储在回滚段中，而回滚段由所有事务共享，并且还是循环使用的。如果一个事务执行持续的时间较长，历史版本可能会被其他数据覆盖，无法查询。

这个模式下读取的历史版本，实际上是基于当前版本和多个增量版本计算追溯回来的，那么计算开销自然就比较大。

Oracle 早期版本中经常会出现的 ORA-01555 “快照过旧”（Snapshot Too Old），就是回滚段中的历史版本被覆盖造成的。通常，设置更大的回滚段和缩短事务执行时间可以解决这个问题。随着 Oracle 后续版本采用自动管理回滚段的设计，这个问题也得到了缓解。

Time-Travel 方式

优点

同样是将历史版本独立存储，所以不会影响当前读的执行效率。

相对 Delta 方式，历史版本是全量独立存储的，直接访问即可，计算开销小。

缺点

相对 Delta 方式，需要占用更大的存储空间。

当然，无论采用三种存储方式中的哪一种，都需要进行历史版本清理。

好了，以上就是单体数据库 MVCC 的三种存储方式，同时也是 PGXC 的实现方式。而 NewSQL 底层使用分布式键值系统来存储数据，MVCC 的存储方式与 PostgreSQL 类似，采用 Append 方式追加新版本。我觉得你应该比较容易理解，就不再啰嗦了。

为了便于你记忆，我把三种存储方式的优缺点提炼了一下放到下面表格中，其实说到底这些特点就是由“是否独立存储”和“存储全量还是存储增量变更”这两个因素决定的。

MVCC 的工作过程

历史版本存储下来后又是如何发挥作用的呢？这个，我们开头时也说过了，是要解决多事务的并发控制问题，也就是保证事务的隔离性。在第 3 讲，我们介绍了隔离性的多个级别，其中可接受的最低隔离级别就是“已提交读”（Read Committed，RC）。

那么，我们先来看 RC 隔离级别下 MVCC 的工作过程。

按照 RC 隔离级别的要求，事务只能看到的两类数据：

当前事务的更新所产生的数据。

当前事务启动前，已经提交事务更新的数据。

我们用一个例子来说明。

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）呢？我们继续往下看。

还是继续刚才的例子，当 T6 执行到下一个时间点（T6-2），T1 到 T4 等 4 个事务都已经提交，此时 T6 再次向系统申请活动事务列表，列表包含 T5 和 T7。遵循同样的规则，这次 T6 可以看到 R1 到 R4 等四条记录的最新版本，同时看到 R5 的上一版本。

很明显，T6 刚才和现在这两次查询得到了不同的结果集，这是不符合 RR 要求的。

实现 RR 的办法也很简单，我们只需要记录下 T6-1 时刻的活动事务列表，在 T6-2 时再次使用就行了。那么，这个反复使用的活动事务列表就被称为“快照”（Snapshot）。

快照是基于 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，我们看图说话。

TiDB 底层是分布式键值系统，我们假设两个事务操作同一个数据项。其中，事务 T1 执行写操作，由 Prewrite 和 Commit 两个阶段构成，对应了我们之前介绍的两阶段提交协议（2PC），如果你还不熟悉可以重新阅读第 9 讲复习一下。这里你也可以简单理解为 T1 的写操作分成了两个阶段，T2 在这两个阶段之间试图执行读操作，但是 T2 会被阻塞，直到 T1 完成后，T2 才能继续执行。

你肯定会非常惊讶，这不是 MVCC 出现前的读写阻塞吗？

TiDB 为什么没有使用快照读取历史版本呢？ TiDB 官方文档并没有说明背后的思路，我猜问题出在全局事务列表上，因为 TiDB 根本没有设计全局事务列表。当然这应该不是设计上的疏忽，我更愿意把它理解为一种权衡，是在读写效率和全局事务列表的维护代价之间的选择。

事实上，PGXC 中的全局事务管理器就是一个单点，很容易成为性能的瓶颈，而分布式系统一个普遍的设计思想就是要避免对单点的依赖。当然，TiDB 的这个设计付出的代价也是巨大的。虽然，TiDB 在 3.0 版本后增加了悲观锁，设计稍有变化，但大体仍是这样。

CockroachDB

那么如果有全局事务列表，又会怎么操作呢？说来也巧，CockroachDB 真的就设计了这么一张全局事务列表。它是否照搬了单体数据库的“快照”呢？答案也是否定的。

我们来看看它的处理过程。

依旧是 T1 事务先执行写操作，中途 T2 事务启动，执行读操作，此时 T2 会被优先执行。待 T2 完成后，T1 事务被重启。重启的意思是 T1 获得一个新的时间戳（等同于事务 ID）并重新执行。

又是一个不可思议的过程，还是会产生读写阻塞，这又怎么解释呢？

CockroachDB 没有使用快照，不是因为没有全局事务列表，而是因为它的隔离级别目标不是 RR，而是 SSI，也就是可串行化。

你可以回想一下第 3 讲中黑白球的例子。对于串行化操作来说，没有与读写并行操作等价的处理方式，因为先读后写和先写后读，读操作必然得到两个不同结果。更加学术的解释是，先读后写操作会产生一个读写反向依赖，可能影响串行化事务调度。这个概念有些不好理解，你也不用着急，在 14 讲中我会有更详细的介绍。总之，CockroachDB 对于读写冲突、写写冲突采用了几乎同样的处理方式。

在上面的例子中，为了方便描述，我简化了读写冲突的处理过程。事实上，被重启的事务并不一定是执行写操作的事务。CockroachDB 的每个事务都有一个优先级，出现事务冲突时会比较两个事务的优先级，高优先级的事务继续执行，低优先级的事务则被重启。而被重启事务的优先级也会提升，避免总是在竞争中失败，最终被“饿死”。

TiDB 和 CockroachDB 的实现方式已经讲完了，现在你该明白它们典型在哪里了吧？对，那就是全局事务列表是否存在和实现哪种隔离级别，这两个因素都会影响最终的设计。

小结

好了，今天的话题就谈到这了，让我们一起回顾下这一讲的内容。

用锁机制来处理读写冲突会影响并发处理能力，而 MVCC 的出现很好地解决了这个问题，几乎所有的单体数据库都实现了 MVCC。MVCC 技术包括数据的历史版本存储、清理机制，存储方式具体包括 Append-Only、Delta、Time-Travel 三种方式。通过 MVCC 和快照（基于 MVCC），单体数据库可以完美地解决 RC 和 RR 级别下的读写冲突问题，不会造成事务阻塞。

PGXC 风格的分布式数据库，用单体数据库作为数据节点存储数据，所以自然延续了其 MVCC 的实现方式。但 PGXC 的改变在于增加了全局事务管理器统一管理事务 ID 的生成和活动事务列表的维护。

NewSQL 风格的分布式数据库，没有普遍采用快照解决读写冲突问题，其中 TiDB 是由于权衡全局事务列表的代价，CockroachDB 则是因为要实现更高的隔离级别。但无论哪种原因，都造成了读写并行能力的下降。

要特别说明的是，虽然 NewSQL 架构的分布式数据库没有普遍使用快照处理读写事务，但它们仍然实现了 MVCC，在数据存储层保留了历史版本。所以，NewSQL 产品往往也会提供一些低数据一致性的只读事务接口，提升读取操作的性能。

思考题

最后，又到了我们的思考题时间。今天我介绍了两种风格的分布式数据库如何解决读写冲突问题。其实，无论是否使用 MVCC 实现快照隔离，时间都是一个重要的因素，每个事务都要获得一个事务 ID 或者时间戳，这直接决定了它能够读取什么版本的数据或者是否被阻塞。

但是你有没有想过时间误差的问题。我在第 2 讲中曾经提到 Spanner 的全局时钟存在 7 毫秒的误差，在第 5 讲又深入探讨了物理时钟和逻辑时钟如何控制时间误差。那么，你觉得时间误差会影响读写冲突的处理吗？

如果你想到了答案，又或者是触发了你对相关问题的思考，都可以在评论区和我聊聊，我会在下一讲和你一起探讨。最后，希望这节课能带给你一些收获，也欢迎你把它分享给周围的朋友，一起进步。

12 | 隔离性：看不见的读写冲突，要怎么处理？

你好，我是王磊，你也可以叫我 Ivan。

我们今天继续聊读写冲突。上一讲我们谈的都是显式的读写冲突，也就是写操作和读操作都在同一时间发生。但其实，还有一种看不见的读写冲突，它是由于时间的不确定性造成的，更加隐蔽，处理起来也更复杂。

关于时间，我们在第 5 讲中已经做了深入讨论，最后我们接受了一个事实，那就是无法在工程层面得到绝对准确的时间。其实，任何度量标准都没有绝对意义上的准确，这是因为量具本身就是有误差的，时间、长度、重量都是这样的。

不确定时间窗口

那么，时间误差会带来什么问题呢？我们用下面这张图来说明。

我们这里还是沿用上一讲的例子，图中共有 7 个数据库事务，T1 到 T7，其中 T6 是读事务，其他都是写事务。事务 T2 结束的时间点（记为 T2-C）早于事务 T6 启动的时间点（记为 T6-S），这是基于数据记录上的时间戳得出的判断，但实际上这个判断很可能是错的。

为什么这么说呢？这是因为时间误差的存在，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 + ɛ时刻才可以，这个ɛ就是我们前面说的不确定时间窗口的宽度。我画了张图来帮你理解这个概念。

从直觉上说，标识数据版本的“提交时间戳”和事务的真实提交时间应该是一个时间，那么我们推演一下这个过程。有当前事务 Ta，已经获得了一个绝对时间 S 作为“提交时间戳”。Ta 在 S 时刻写盘，保存的时间戳也是 S。事务 Tb 在 Ta 结束后的 S+X 时刻启动，获得时间区间的最小值是 TT1.earliest。如果 X 小于时间区间ɛ，则 TT1.earliest 就会小于 S，那么 Tb 就无法读取到 Ta 写入的数据。

你看，Tb 在 Ta 提交后启动却读取不到 Ta 写入的数据，这显然不符合线性一致性的要求。

写等待的处理方式是这样的。事务 Ta 在获得“提交时间戳”S 后，再等待ɛ时间后才写盘并提交事务。真正的提交时间是晚于“提交时间戳”的，中间这段时间就是等待。这样 Tb 事务启动后，能够得到的最早时间 TT2.earliet 肯定不会早于 S 时刻，所以 Tb 就一定能够读取到 Ta。这样就符合线性一致性的要求了。

综上，事务获得“提交时间戳”后必须等待ɛ时间才能写入磁盘，即 commit-wait。

到这里，写等待算是说清楚了。但是，你仔细想想，有什么不对劲的地方吗？

对，就是那个绝对时间 S。都说了半天时间有误差，那又怎么可能拿到一个绝对时间呢？这不是自相矛盾吗？

Spanner 确实拿不到绝对时间，为了说清楚这个事情，我们稍微延伸一下话题。

实际等待时间

Spanner 将含有写操作的事务定义为读写事务。读写事务的写操作会以两阶段提交（2PC）的方式执行。有关 2PC 的内容在第 9 讲中已经介绍过，如果你已经记不清了，可以去复习一下。

2PC 的第一阶段是预备阶段，每个参与者都会获取一个“预备时间戳”，与数据一起写入日志。第二阶段，协调节点写入日志时需要一个“提交时间戳”，而它必须要大于任何参与者的“预备时间戳”。所以，协调节点调用 TT.now() 函数后，要取该时间区间的 lastest 值（记为 s），而且 s 必须大于所有参与者的“预备时间戳”，作为“提交时间戳”。

这样，事务从拿到提交时间戳到 TT.after(s) 为 true，实际等待了两个单位的时间误差。我们还是画图来解释一下。

针对同一个数据项，事务 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 毫秒。

写等待模式下，所有包含写操作的事务都受到影响，要延后提交；而读等待只在特殊条件下才被触发，影响的范围要小得多。

那到底是什么特殊条件呢？我们还是使用开篇的那个例子来说明。

事务 T6 启动获得了一个时间戳 T6-S1，此时虽然事务 T2 已经在 T2-C 提交，但 T2-C 与 T6-S1 的间隔小于集群的时间偏移量，所以无法判断 T6 的提交是否真的早于 T2。

这时，CockroachDB 的办法是重启（Restart）读操作的事务，就是让 T6 获得一个更晚的时间戳 T6-S2，使得 T6-S2 与 T2-C 的间隔大于 offset，那么就能读取 T2 的写入了。

不过，接下来又出现更复杂的情况， T6-S2 与 T3 的提交时间戳 T3-C 间隔太近，又落入了 T3 的不确定时间窗口，所以 T6 事务还需要再次重启。而 T3 之后，T6 还要重启越过 T4 的不确定时间窗口。

最后，当 T6 拿到时间戳 T6-S4 后，终于跳过了所有不确定时间窗口，读等待过程到此结束，T6 可以正式开始它的工作了。

在这个过程中，可以看到读等待的两个特点：一是偶发，只有当读操作与已提交事务间隔小于设置的时间误差时才会发生；二是等待时间的更长，因为事务在重启后可能落入下一个不确定时间窗口，所以也许需要经过多次重启。

小结

到这里，今天的内容就告一段落了，时间误差的问题比较抽象，你可能会学得比较辛苦，让我帮你整理一下今天内容。

时间误差是客观存在的，任何系统都不能获得准确的绝对时间，只能得到一个时间区间，差别仅在于有些系统承认这点，而有些系统不承认。

有两种方式消除时间误差的影响，分别是写等待和读等待。写等待影响范围大，所有包含写操作的事务都要至少等待一个误差周期。读等待的影响范围小，只有当读操作时间戳与访问数据项的提交时间戳落入不确定时间窗口后才会触发，但读等待的周期可能更长，可能是数个误差周期。

写等待适用于误差很小的系统，Spanner 能够将时间误差控制在 7 毫秒以内，所以有条件使用该方式。读等待适用于误差更大的系统，CockroachDB 对误差的预期达到 250 毫秒。

总之，处理时间误差的方式就是等待，“waiting out the uncertainty”，等待不确定性过去。你可能觉得写等待和读等待都不完美，但这就是全球化部署的代价。我想你肯定会追问，那为什么要实现全球化部署呢？简单地说，全球化部署最突出的优势就是可以让所有节点都处于工作状态，就近服务客户；而缺失这种能力就只能把所有主副本限制在同机房或者同城机房的范围内，异地机房不具备真正的服务能力，这会带来资源浪费、用户体验下降、切换演练等一系列问题。我会在第 24 讲专门讨论全球化部署的问题。

思考题

最后，我要留给你一道思考题。

今天，我们继续探讨了读写冲突的话题，在引入了时间误差后，整个处理过程变得更复杂了，而无论是“读等待”还是“写等待”都会让系统的性能明显下降。说到底是由多个独立时间源造成的，而多个时间源是为了支持全球化部署。那么，今天的问题就是，你觉得在什么情况下，不用“等待”也能达到线性一致性或因果一致性呢？

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对时间误差下的读写冲突这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

13 | 隔离性：为什么使用乐观协议的分布式数据库越来越少?

你好，我是王磊，你也可以叫我 Ivan。

我们在 11、12 两讲已经深入分析了读写冲突时的控制技术，这项技术的核心是在实现目标隔离级别的基础上，最大程度地提升读写并发能力。但是，读写冲突只是事务冲突的部分情况，更多时候我们要面对的是写写冲突，甚至后者还要更重要些。

你可能经常会听到这类说法，“某某网站的架构非常牛，能抗住海量并发”“某某网站很弱，搞个促销，让大家去抢红包，结果活动刚开始 2 分钟系统就挂了”。其实，要想让系统支持海量并发，很重要的基础就是数据库的并发处理能力，而这里面最重要的就是对写写冲突的并发控制。因为并发控制如此重要，所以很多经典教材都会花费大量篇幅来探讨这个问题，进而系统性地介绍并发控制技术。在这个技术体系中，虽然有多种不同的划分方式，但最为大家熟知就是悲观协议和乐观协议两大类。

TiDB 和 CockroachDB 的流行一度让大家对于乐观协议这个概念印象深刻。但是，经过几年的实践，两款产品都将默认的并发控制机制改回了悲观协议。他们为什么做了这个改变呢？我们这一讲，专门来探讨什么是乐观控制协议，以及为什么 TiDB 和 CockroachDB 不再把它作为默认选项。

并发控制技术的分类

首先，无论是学术界还是工业界，都倾向于将并发控制分为是悲观协议和乐观协议两大类。但是，这个界限在哪，其实各有各的解释。

我先给一个朴素版的定义。所谓悲观与乐观，它和我们自然语言的含义大致是一样的，悲观就是对未来的一种负面预测，具体来说，就是认为会出现比较多的事务竞争，不容易获得充足的资源完成事务操作。而乐观，则完全相反，认为不会有太多的事务竞争，所以资源是足够的。这个定义虽然不那么精准，但大体表示了两种机制的倾向。

再进一步，落到实现机制上，有一个广泛被提到的定义版本。乐观协议就是直接提交，遇到冲突就回滚；悲观协议就是在真正提交事务前，先尝试对需要修改的资源上锁，只有在确保事务一定能够执行成功后，才开始提交。

总之，这个版本的核心就是，悲观协议是使用锁的，而乐观协议是不使用锁的。这就非常容易把握了。

但是，这个解释和真正分布式数据库产品的实现还是有些差距的，比如 TiDB 就宣称自己使用了乐观锁。对，你没听错，是乐观锁。怎么有锁还能乐观呢，是不是有点蒙了？

为了让你在学习过程中不背负着这个巨大的问号，我们就先放下对定义的探讨，先来看看 TiDB 乐观锁的实现方式。

乐观锁：TiDB

TiDB 的乐观锁基本上就是 Percolator 模型，这个模型我们在第 9 讲时曾经介绍过，它的运行过程可以分为三个阶段。

选择 Primary Row

收集所有参与修改的行，从中随机选择一行，作为这个事务的 Primary Row，这一行是拥有锁的，称为 Primary Lock，而且这个锁会负责标记整个事务的完成状态。所有其他修改行也有锁，称为 Secondary Lock，都会保留指向 Primary Row 的指针。

写入阶段

按照两阶段提交的顺序，执行第一阶段。每个修改行都会执行上锁并执行“prewrite”，prewrite 就是将数据写入私有版本，其他事务不可见。注意这时候，每个修改行都可能碰到锁冲突的情况，如果冲突了，就终止事务，返回给 TiDB，那么整个事务也就终止了。如果所有修改行都顺利上锁，完成 prewrite，第一阶段结束。

提交阶段

这是两阶段提交的第二阶段，提交 Primary Row，也就是写入新版本的提交记录并清除 Primary Lock，如果顺利完成，那么这个事务整体也就完成了，反之就是失败。而 Secondary Rows 上的锁，则会交给异步线程根据 Primary Lock 的状态去清理。

你看这个过程中不仅有锁，而且锁的数量还不少。那么，为什么又说它是乐观协议呢？

想回答这个问题，我们就需要一些理论知识了。

并发控制的三个阶段

在经典理论教材“Principles of Distributed Database Systems”中，作者将乐观协议和悲观协议的操作，都统一成四个阶段，分别是有效性验证（V）、读（R）、计算（C）和写（W）。两者的区别就是这四个阶段的顺序不同：悲观协议的操作顺序是 VRCW，而乐观协议的操作顺序则是 RCVW。因为在比较两种协议时，计算（C）这个阶段没有实质影响，可以忽略掉。那么简化后，悲观协议的顺序是 VRW，而乐观协议的顺序就是 RVW。

RVW 的三阶段划分，也见于研究乐观协议的经典论文“On Optimistic Methods for Concurrency Control”。

关于三个阶段的定义在不同文献中稍有区别，其中“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”给出了一个专用于 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）发出，这样事务就可以在第一时间发现是否有其他事务与自己冲突。

另外，悲观锁还触发了一个变化。TiDB 原有的事务模型并不是一个交互事务，它会把所有的写 SQL 都攒在一起，在 commit 阶段一起提交，所以有很大的并行度，锁的时间较短，死锁的概率也就较低。因为增加了悲观锁的加锁动作，变回了一个可交互事务，TiDB 还要增加一个死锁检测机制。

小结

到这里，今天的内容就告一段落了，让我们一起梳理下今天内容。

并发控制分为乐观和悲观两种，它们的语义和自然语言都是对未来正面或负面的预期。乐观协议预期事务冲突很少，所以不会提前做什么，悲观协议认为事务冲突比较多，所以要有所准备。

按照经典理论，并发控制都是由三个阶段组成，分别是有效性确认（V）、读（R）和写（W）。悲观协议的执行顺序是 VRW，乐观协议的执行顺序是 RVW。所以，又可以得到两个乐观协议的定义，狭义上必须满足 RVW 才是乐观并发控制，而且三阶段有更具体的要求，这个就是学术论文上的 OCC。另外广义的定义，只要不是严格 VRW 的并发控制，都是相对乐观的，都是乐观并发控制，TiDB 就是相对乐观。生产级的分布式数据库很少有使用狭义的 OCC 协议，FoundationDB 是一个例外。

乐观协议的挑战来自两个方面。第一点，乐观协议在事务冲突较少时，因为避免了锁的管理开销，能提供更好的性能；但在事务冲突较多时会出现大量的回滚，效率低下。总的来说，乐观协议的通用性并不好。第二点，传统单体数据库几乎都是基于锁的悲观协议，乐观协议在语义层面没有对等的 SQL，例如 select for update。因此，TiDB 和 CockroachDB 都由乐观协议转变为悲观协议，并且是基于锁的悲观协议。

TiDB 的悲观协议符合严格的 VRW 顺序，在原有的两阶段提交前，增加了一轮悲观锁占位操作，实现全局有效性确认。但是随着悲观锁的引入，TiDB 转变为一个可交互事务模式，出现死锁的概率大幅提升，对应增加死锁检测功能。

今天的课程中，我们澄清了对乐观协议的常见误解，也解释了为什么 TiDB 要把默认选项从乐观锁改为悲观锁。当然你一定注意到了，除了基于锁的悲观协议外，还有一些其他技术，比如基于时间戳排序（TO）和串行化图检测（SGT）等，我们将在下一讲中继续探讨这个话题。

思考题

课程的最后，我们来看看今天的思考题。

在这一讲开头，我们说 TiDB 和 CockroachDB 都经历了从乐观协议到悲观协议的转变，但在文中只展开了 TiDB 变化前后的设计细节。其实，对于 CockroachDB 你应该也不陌生了，我们在最近几讲中都有提及到它的一些特性。所以，我今天的问题是，请你推测下 CockraochDB 的悲观协议大概会采用什么方式？而这种方式又有什么优势？当然，CockroachDB 的实现方式很容易查到，所以我更关心你的思考的过程。

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对乐观协议或者并发控制技术这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

Gerhard Weikum and Gottfried Vossen: Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery

H. T. Kung and John T. Robinson: On Optimistic Methods for Concurrency Control

M. Tamer Özsu and Patrick Valduriez: Principles of Distributed Database Systems

14 | 隔离性：实现悲观协议，除了锁还有别的办法吗？

你好，我是王磊，你也可以叫我 Ivan。

我们今天的主题是悲观协议，我会结合第 13 讲的内容将并发控制技术和你说清楚。在第 13 讲我们以并发控制的三阶段作为工具，区分了广义上的乐观协议和悲观协议。因为狭义乐观很少使用，所以我们将重点放在了相对乐观上。

其实，相对乐观和局部悲观是一体两面的关系，识别它的要点就在于是否有全局有效性验证，这也和分布式数据库的架构特点息息相关。但是关于悲观协议，还有很多内容没有提及，下面我们就来填补这一大块空白。

悲观协议的分类

要搞清楚悲观协议的分类，其实是要先跳出来，从并发控制技术整体的分类体系来看。

事实上，并发控制的分类体系，连学术界的标准也不统一。比如，在第 13 讲提到的两本经典教材中，“Principles of Distributed Database Systems”的分类是按照比较宽泛的乐观协议和悲观协议进行分类，子类之间又有很多重叠的概念，理解起来有点复杂。

而“Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery”采用的划分方式，是狭义乐观协议和其他悲观协议。这里狭义乐观协议，就是指我们在第 13 讲提到过的，基于有效性验证的并发控制，也是学术上定义的 OCC。

我个人认为，狭义乐观协议和其他悲观协议这种分类方式更清晰些，所以就选择了“ Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery”中的划分体系。下面我摘录了书中的一幅图，用来梳理不同的并发控制协议。

这个体系首先分为悲观和乐观两个大类。因为这里的乐观协议是指狭义乐观并发控制，所以包含内容就比较少，只有前向乐观并发控制和后向乐观并发控制；而悲观协议又分为基于锁和非锁两大类，其中基于锁的协议是数量最多的。

两阶段封锁（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 理解。

在 t1 时刻之前是加锁阶段，在 t1 之后则是释放锁阶段，我们可以从时间上明确地把事务执行过程划分为两个阶段。2PL 的关键点就是释放锁之后不能再加锁。而根据加锁和释放锁时机的不同，2PL 又有一些变体。

保守两阶段封锁协议（Conservative 2PL，C2PL），事务在开始时设置它需要的所有锁。

严格两阶段封锁协议（Strict 2PL，S2PL），事务一直持有已经获得的所有写锁，直到事务终止。

强两阶段封锁协议（Strong Strict 2PL，SS2PL），事务一直持有已经获得的所有锁，包括写锁和读锁，直到事务终止。SS2PL 与 S2PL 差别只在于一直持有的锁的类型，所以它们的图形是相同的。

理解了这几种 2PL 的变体后，我们再回想一下第 13 讲中的 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”中最早提出了 SSI 的工程实现方案，这篇论文也被 VLDB2012 收录。

为了更清楚地描述 SSI 方案，我们先要了解一点理论知识。

串行化理论的核心是串行化图（Serializable Graph，SG）。这个图用来分析数据库事务操作的冲突情况。每个事务是一个节点，事务之间的关系则表示为一条有向边。那么，什么样的关系可以表示为边呢？

串行化图的构建规则是这样的，事务作为节点，当一个操作与另一个操作冲突时，在两个事务节点之间就可以画上一条有向边。

具体来说，事务之间的边又分为三类情况：

写读依赖（WR-Dependencies），第二个操作读取了第一个操作写入的值。

写写依赖（WW-Dependencies），第二个操作覆盖了第一个操作写入的值。

读写反依赖（RW-Antidependencies），第二个操作覆盖了第一个操作读取的值，可能导致读取值过期。

我们通过一个例子，看看如何用这几条规则来构建一个简单的串行化图。

图中一共有三个事务先后执行，事务 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。

最终，我们看到产生了一个有向无环图（Directed Acyclic Graph，DAG）。能够构建出 DAG，就说明相关事务是可串行化执行的，不需要中断任何事务。

我们可以使用 SGT，验证一下典型的死锁情况。我们知道，事务 T1 和 T2 分别以不同的顺序写两个数据项，那么就会形成死锁。

用串行化图来体现就是这个样子，显然构成了环。

在 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 之前执行，这样才能出现在当天的报表中。三者顺序执行可以正常工作，否则就会出现异常，比如下面这样的：

T2 先拿到一个批次号 x，随后 T3 执行，批次号关闭后，x 这个批次号其实已经过期，但是 T2 还继续使用 x，记录当前的这笔收入。T1 正常在 T3 后执行，此时 T2 尚未提交，所以 T1 的报告中漏掉了 T2 的那笔收入。因为 T2 使用时过期的批次号 x，第二天的报告中也不会统计到这笔收入，最终这笔收入就神奇地消失了。

在理解了这个例子的异常现象后，我们用串行化图方法来验证一下。我们是把事务中的 SQL 抽象为对数据项的操作，可以得到下面这张图。

图中 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 依赖。这样就构成了下面的串行化图。

显然这三个事务之间是存在环的，那么这三个事务就是不能串行化的。

这个异常现象中很有意思的一点是，虽然 T1 是一个只读事务，但如果没有 T1 的话，T2 与 T3 不会形成环，依然是可串行化执行的。这里就为我们澄清了一点：我们直觉上认为的只读事务不会影响事务并发机制，其实是不对的。

工程实现：CockroachDB

RW 反向依赖是一个非常特别的存在，而特别之处就在于传统的锁机制无法记录这种情况。因此在论文“Serializable Snapshot Isolation in PostgreSQL”中提出，增加一种锁 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 讲提到的问题，所以 CockroachDB 也在原有基础上进行了改良，通过增加全局的锁表（Lock Table），使用加锁的方式，先进行一轮全局有效性验证，确定无冲突的情况下，再使用单个节点的 SGT。

小结

有关悲观协议的内容就聊到这里了，我们一起梳理下今天课程的重点。

并发控制机制的划分方法很多，没有统一标准，我们使用了Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery提出的划分标准，分为悲观协议与乐观协议两种。这里的乐观协议是上一讲提到的狭义乐观协议，悲观协议又分为锁和非锁两大类，我们简单介绍了 2PL 这一个分支。

我们回顾了 Percolator 模型，按照 S2PL 的定义，Percoloatro 本质就是 S2PL，因此 TiDB 的乐观锁也属于 S2PL。

S2PL 是数据库并发控制的主流技术，但是锁管理复杂，在实现串行化隔离级别时开销太大。而后，我们讨论了非锁协议中的串行化图检测（SGT）。PostgreSQL 最早提出了 SGT 的工程实现方式 SSI。CockroachDB 在此基础上又进行了优化，降低了 SIREAD 的开销，是生产级的可串行化隔离。

CockroachDB 最初和 TiDB 一样都是局部采用悲观协议，而不做全局有效性验证，是广义的乐观协议。后来，CockroachDB 同样也将乐观协议改为悲观协议，采用的方式是增加全局的锁表，进行全局有效性验证，而后再转入单个的 SGT 处理。

今天的课程中，我们提到了串行化理论，只有当相关事务形成 DAG 图时，这些事务才是可串行化的。这个理论不仅适用于 SGT，2PL 的最终调度结果也同样是 DAG 图。在更大范围内，批量任务调度时 DAG 也同样被作为衡量标准，例如 Spark。

思考题

课程的最后，我们来看看今天的思考题。

在第 11 讲中我们提到了 MVCC。有的数据库教材中将 MVCC 作为一种重要的并发控制技术，与乐观协议、悲观协议并列，但我们今天并没有单独提到它。所以，我的问题是，你觉得该如何理解 MVCC 与乐观协议、悲观协议的关系呢？

欢迎你在评论区留言和我一起讨论，我会在答疑篇回复这个问题。如果你身边的朋友也对悲观协议或者并发控制技术这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

最早的 SSI 工程实现方案：Serializable Snapshot Isolation in PostgreSQL

按照狭义乐观协议和其他悲观协议划分并发控制协议：Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery

15 | 分布式事务串讲：重难点回顾+思考题答疑+知识全景图

你好，我是王磊，你也可以叫我 Ivan。

今天这一讲是我们这门课的第二个答疑篇，我会带你回顾第 9 讲到第 14 讲的主要内容，集中解答留给你的思考题，同时回复一些留言区的热点问题。这几讲涉及的也是数据库的核心话题“事务”，咱们一定得学扎实了。

第 9 讲：原子性提交协议

在第 9 讲，我们首先讨论了两种不同的原子性协议，分别是面向应用层的协议 TCC 和面向资源层的协议 2PC。

使用 TCC 协议时，由应用系统负责协议的实现，数据库没有额外工作。所以，TCC 更加灵活，但对业务的侵入性更高。反之，使用 2PC 协议时，主要靠数据库来实现，应用层工作很少，所以业务侵入少，但是存在同步阻塞、单点故障和数据不一致这三个问题。

针对 2PC 协议的这些问题，我又介绍了两种改进型协议。一种是 Percolator 模型，在 TiDB 和 CockroachDB 中有具体落地。另一种是 GoldenDB 中实现的“一阶段提交”协议。它们都较好地解决了单点故障和数据不一致的问题。而有关同步阻塞带来的高延迟问题，我们没有展开，而是留到了第 10 讲。

这一讲的是思考题是：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 讲提出的问题是：如果客户端没有在一定的时间内得到所有意向写的反馈（不知道反馈是成功还是失败），要如何处理？

这两位同学的问题虽然不一样但有一个共同点，就是事务协调者收不到事务参与者的反馈怎么办。

为什么会收不到呢？多数情况是和网络故障有关。那么，Paxos 协议这个针对网络分区而设计的共识算法，能不能帮助解决这个问题呢？

Paxos 确实有帮助，但真正能解决问题的是在其之上衍生的 Paxos Commit 协议，这是一个融合了 Paxos 算法的原子提交协议，也是我们前面所说的 2PC 和 Paxos 的联系所在。

Paxos Commit 协议

Paxos Commit 协议是 2006 年在论文“Consensus on Transaction Commit”中首次提出的。值得一提的是，这篇论文的两位作者，正是我们课程中多次提到的 Jim Gray 和 Leslie Lamport。他们分别是数据库和分布式领域的图灵奖获得者，也分别是 2PC 和 Paxos 的提出者。

我们结合论文中的配图，简单学习一下 Paxos Commit 的思路。

Paxos Commit 协议中有四个角色，有两个与 2PC 对应，分别是 TM（Transaction Manager，事务管理者）也就是事务协调者，RM（Resource Manager，资源管理者）也就是事务参与者；另外两个角色与 Paxos 对应，一个是 Leader，一个是 Acceptor。其中，TM 和 Leader 在逻辑上是不可能分的，所以在图中隐去了。因为 Leader 是选举出来的，所以第一个 Leader 标识为 Initial Leader。

下面，我们来描述下这个处理过程。

首先由 RM1，就是某个 RM，向 Leader 发送 Begin Commit 指令。这个操作和第 9 讲介绍的 2PC 稍有不同，但和客户端向 Leader 发送指令在效果上是大致相同的。同时，RM1 要向所有 Acceptor 发送 Prepared 指令。因为把事务触发也算进去了，所以整个协议有三个阶段构成，Prepare 是其中的第二阶段，而 RM 对 Prepare 指令的响应过程又拆分成了 a 和 b 两个子阶段。所以，这里的 Prepared 指令用 2a Prepared 表示，要注意这是一个完成时，表示已经准备完毕。

Leader 向除 RM1 外的所有 RM，发送 Prepare 指令。RM 执行指令后，向所有 Acceptor 发送消息 2a Prepared。这里的关键点是，一个 2a Prepared 消息里只包含一个 RM 的执行情况。而每个 Acceptor 都会收到所有 RM 发送的消息，从而得到全局 RM 的执行情况。

每个 Acceptor 向 Leader 汇报自己掌握的全局状态，载体是消息 2b Prepared。2b Prepared 是对 2a Prepared 的合并，每个消息都记录了所有 RM 的执行情况。最后，Leader 基于多数派得出了最终的全局状态。这一点和 2PC 完全不同，事务的状态完全由投票决定，Leader 也就是事务协调者，是没有独立判断逻辑的。

Leader 基于已知的全局状态，向所有 RM 发送 Commit 指令。

这个过程中，如果 Acceptor 总数是 2F+1，那么每个 RM 就有 2F+1 条路径与 Leader 通讯。只要保证其中 F+1 条路径是畅通的，整个协议就可以正常运行。因此，Paxos Commit 协议的优势之一，就是在很大程度上避免了网络故障对系统的影响。但是，相比于 2PC 来说，它的消息数量大幅增加，而且多了一次消息延迟。目前，实际产品中还很少有使用 Paxos Commit 协议的。

第 10 讲：2PC 的延迟优化

第 10 讲的核心内容是 2PC 的低延迟技术，我们先是分析了延迟的主要构成，发现延迟时间与事务中的写入操作数量线性相关，然后又将延迟时间的计量单位统一为共识算法延迟 Lc​，最后得到了下面的延迟计算公式：

Ltxn​=(W+1)∗Lc​

随后，我为你讲解了三种优化技术，都是基于 Percolator 模型的，分别是缓存提交写、管道和并行提交。TiDB 采用“缓存提交写”达到了 2 倍共识算法延迟，但这个方案的缺点是缓存 SQL 的节点会出现瓶颈，而且不再是交互事务。CockroachDB 采用了管道和并行提交技术，整体延迟缩短到了 1 倍共识算法延迟，可能是目前最极致的优化方法了。

这一讲的是思考题是：虽然 CockroachDB 的优化已经比较极致了，但还有些优化方法也很有趣，请你介绍下自己了解的 2PC 优化方法。

关于这个问题，我们刚刚讲的 Paxos Commit 其实已经是一种 2PC 的优化方法了。另外，在 Spanner 论文“Spanner: Google’s Globally-Distributed Database”中也介绍了它的 2PC 优化方式。Spanner 的 2PC 优化特点在于由客户端负责第一段协调，发送 prepare 指令，减少了节点间的通讯。具体的内容，你可以参考下这篇论文。

在留言区，我看到很多同学对并行提交有不同的理解。我要再提示一下，并行提交中的异步写事务日志只是根据每个数据项的写入情况，追溯出事务的状态，然后落盘保存，整个过程并没有任何重试或者回滚的操作。这是因为，在之前的同步操作过程中，负责管道写入的同步线程，已经明确知道了每个数据项的写入情况，也就是确定了事务的状态，不同步落盘只是为了避免由此带来的共识算法延迟。

第 11 讲：读写冲突、MVCC 与快照

在第 11 讲中，我们介绍如何避免读写冲突的解决方案，其中很重要的概念就是 MVCC 和快照。MVCC 是单体数据库普遍使用的一种技术，通过记录数据项历史版本的方式，提升系统应对多事务访问的并发处理能力。

在 MVCC 出现前读写操作是相互阻塞的，并行能力受到很大影响。而使用 MVCC 可以实现读写无阻塞，并能够达到 RC（读已提交）隔离级别。基于 MVCC 还可以构建快照，使用快照则能够更容易地实现 RR（可重复读）和 SI（快照隔离）两个隔离级别。

首先，我们学习了 PGXC 风格分布式数据库的读写冲突处理方案。PGXC 因为使用单体数据库作为数据节点，所以沿用了 MVCC 来实现 RC。但如果要实现 RR 级别，则需要全局事务管理器（GTM）承担产生事务 ID 和记录事务状态的职责。

然后，我们介绍了 TiDB 和 CockroachDB 这两种 NewSQL 风格分布式数据库的读写冲突处理方案。TiDB 没有设置全局事务列表，所以读写是相互阻塞的。CockroachDB 虽然有全局事务列表，但由于它的目标隔离级别是可串行化，所以也没有采用快照方式，读写也是相互阻塞的。

这一讲的思考题是：在介绍的几种读写冲突的处理方案中，时间都是非常重要的因素，但时间是有误差的，那么你觉得时间误差会影响读写冲突的处理吗？

其实，这个问题就是引导你思考，以便更好地理解第 12 讲的内容。MVCC 机制是用时间戳作为重要依据来判别哪个数据版本是可读取的。但是，如果这个时间戳本身有误差，就需要特定的机制来管理这个误差，从而读取到正确的数据版本。更详细的内容，你可以去学习下第 12 讲。

“真名不叫黄金”同学的答案非常准确，抓住了时钟置信区间这个关键点，分析思路也很清晰，点赞。

第 12 讲：读写操作与时间误差

在第 12 讲中，我们给出了时间误差的具体控制手段，也就是写等待和读等待。

Spanner 采用了写等待方案，也就是 Commit Wait，理论上每个写事务都要等待一个时间置信区间。对 Spanner 来说这个区间最大是 7 毫秒，均值是 4 毫秒。但是，由于 Spanner 的 2PC 设计，需要再增加一个时间置信区间，来确保提交时间戳晚于预备时间戳。所以，实际上 Spanner 的写等待时间就是两倍时间置信区间，均值达到了 8 毫秒。传说中，Spanner 的 TPS 是 125 就是用这个均值计算的（1 秒 /8 毫秒），但如果事务之间操作的数据不重叠，其实是不受这个限制的。

CockroachDB 采用了读等待方式，就是在所有的读操作执行前处理时间置信区间。读等待的优点是偶发，只有读操作落入写操作的置信区间才需要重启，进行等待。但是，重启后的读操作可能继续落入其他写操作的置信区间，引发多次重启。所以，读等待的缺点是等待时间可能比较长。

这一讲的思考题是：读等待和写等待都是通过等待的方式，度过不确定的时间误差，从而给出确定性的读写顺序，但性能会明显下降。那么在什么情况下，不用“等待”也能达到线性一致性或因果一致性呢？”

我为这个问题准备了两个答案。

第一个答案是要复习第 5 讲的相关知识。如果分布式数据库使用了 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”。

所以，假设两个事件发生地的距离除以光速得到一个时间 X，两个事件的时间戳间隔是 Y，时钟误差是 Z。如果 X>Y+Z，那么可以确定两个事件是并行发生的，事件 2 就不用读等待了。这是因为既然事件是并行的，事件 2 看不到事件 1 的结果也就是正常的了。

第 13 讲：广义乐观和狭义乐观

在第 13 讲中，我们开始探讨“写写冲突”的控制技术，这也是并发控制最核心的内容。大型系统之所以能够承载海量并发，就在于底层数据库有强大的并发处理能力。

并发控制分为乐观协议和悲观协议两大类，单体数据库大多使用悲观协议。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”定义狭义乐观和其他悲观协议这种的组织形式，而后对 2PL 的定义和各种变体进行了说明。我们根据 S2PL 的定义，可以推导出 Percolator 模型属于 S2PL 的结论。S2PL 虽然使用广泛，但不能在生产级支持可串行化隔离。

PostgreSQL 的 SSI 设计给出了另一种实现，它的理论基础是 SGT。CockroachDB 在此基础设计了读时间戳缓存（RTC），降低了原有 SIREAD 的开销，达到了生产级性能要求。最后，我还和你一起学习了 CockroachDB 采用全局锁表实现悲观协议的原理。

这一讲的思考题是：我们之前已经介绍过 MVCC，它是一项重要的并发控制技术，你觉得该如何理解它和乐观协议、悲观协议的关系？

就像我们在第 11 讲中所说的，MVCC 已经是数据库的底层技术，与乐观协议、悲观协议下的各项技术是两个不同的维度，最后形成了 MVTO、MV2PL、MVSGT 等技术。这些技术考虑了多版本情况下的处理，但遵循的基本原理还是一样的。

小结

正如我在这一讲开头提到的，第 9 到第 14 这 6 讲的内容，都是围绕着分布式数据库的事务展开的，重点就是原子性和隔离性的协议、算法和工程实现。

对于原子性，我们主要关注非功能性指标背后的架构优化和理论创新，尤其是 NewSQL 风格分布式数据库在 2PC 的三个传统难题，也就是同步阻塞、单点故障、数据一致性上都取得的突破。

隔离性则不同，早在单体数据库时代，架构设计就在正确性，也就是隔离级别上作了妥协，换取性能。而分布式数据库在重新挑战了隔离性这个难题，CockroachDB 在这方面的探索更是意义重大，它实践了一种兼顾正确性和性能的技术方案。

如果你对今天的内容有任何疑问，欢迎在评论区留言和我一起讨论。要是你身边的朋友也对分布式数据库的事务处理这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

分布式数据全景图 2/4

学习资料

James C. Corbett et al.: Spanner: Google’s Globally-Distributed Database

Jim Gray and Leslie Lamport: Consensus on Transaction Commit

Leslie Lamport: Time, Clocks, and the Ordering of Events in a Distributed System

Gerhard Weikum and Gottfried Vossen: Transactional Information Systems

16 | 为什么不建议你使用存储过程？

你好，我是王磊，你也可以叫我 Ivan。

今天，我们一起来到了这门课的第 16 讲。如果你学习并理解了前面的所有课程，那么我要恭喜你，这不仅是因为你学完了一半的课程，还意味着你已经征服了“数据库事务”这座高峰。当然，如果你有困惑的地方，也不必沮丧，因为接下来会是一小段平缓地带，我们会探讨一些相对独立的问题。比如我们今天的话题，为什么不建议你使用存储过程？

有些资深的数据库开发同学可能不同意这个观点，我猜他们大概会说：“存储过程很好呀，那些用不好的人就是自己水平烂，不接受反驳！”其实，我就有过这样的念头，但是现在的我面对分布式数据库，会更倾向于少用或者不用存储过程。下面，我就来和你分享下这个心路历程吧。

我从 C/S 时代走来

当我刚成为一个名程序员时，正好是 C/S 架构时代的末期。那时最流行的开发套件是 PowerBuilder 和 Sybase 数据库。PowerBuilder 是一款可视化开发工具，有点像 VB，开发好的程序运行在用户的 PC 终端上，通过驱动程序连接远端的数据库。而 Sybase 当时正与 Oracle 争夺数据库的头把交椅，它和 SQL Server 有很深的渊源，两者在架构和语言上都很像。

C/S架构

在这个 C/S 架构中，数据库不仅承担了数据存储、计算功能，还要运行很重的业务逻辑，相当于数据库同时承担了应用服务器（Application Server）的大多数功能。而这些业务逻辑的技术载体就是存储过程。所以，不管是 Sybase 还是 Oracle，它们存储过程的功能都非常强大。

触发器被抛弃

进入 B/S 时代，大家对数据库的理解发生了变化，应用服务器承载了服务器端的主要业务逻辑，那还要不要使用存储过程呢？你看，这和我们今天的问题是一样的。当时的主流观点认为存储过程还有存在价值的，但是它的同胞兄弟触发器则被彻底抛弃了。

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”。论文中提出，通过独立的 UDF Server 支持自定义函数，也就是存储过程。这个架构中，因为 F1 是完全独立于数据存储的，所以 UDF Server 自然也就被抽了出来。从论文提供的测试数据看，这个设计保持了比较高的性能，但我觉得这和 Google 强大的网络设施有很大关系，在普通企业网络条件下能否适用，这还很难说。

关于 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 意义已经是截然不同了。

小结

好了，有关存储过程的话题就到这里了，让我们一起梳理下今天的重点内容。

我用自己的一段亲身经历，说明了存储过程的移植差。究其原因，在于存储过程高度依赖于数据库环境，而数据库环境不像操作系统或虚拟机那样遵循统一的标准。因为同样的原因，存储过程调试也很复杂，也没有跟上敏捷开发的步伐，与今天工程化的要求不匹配。正是因为这两个工程化方面的原因，我建议你不用或者少用存储过程。

从分布式数据库看，多数 NewSQL 还不支持存储过程，OceanBase 作为唯一的例外，已经支持 Oracle 存储过程，但仍然没有达到生产级。

F1 的论文提出了独立 UDF Server 的思路，是分布式架构下存储过程的一种实现方案，但能不能适合普通的企业网络环境，尚待观察。但这个方案中，存储过程的实现语言不局限于 SQL 方言，而是放宽到多种主流语言，向标准兼容，具备更好的开放性。这提升了存储过程技术与 DevOps 融合的可能性。

VoltDB 作为一款内存型分布式数据库，以存储过程作为主要的操作定义方式，支持使用 Java 语言开发。甚至可以说，VoltDB 的基础就是存储过程这种预定义事务方式。存储过程、内存存储、单线程三者互相影响，使得 VoltDB 具备出色的性能表现。

对于任何一个程序员来说，放弃一种已经熟练掌握而且执行高效的技术，必然是一个艰难的决定。但是今天，对于大型软件系统而言，工程化要求远比某项技术本身更加重要。不能与整个技术生态协作的技术，最终将无法避免被边缘化的命运。当你学习一门新技术前，无论是分布式数据库还是微服务，我都建议你要关注它与周边生态是否能够适配，因为符合潮流的技术有机会变得更好，而太过小众的技术则蕴藏了更大的不确定性。

思考题

课程的最后，我们来看看今天的思考题。我们说 VoltDB 的设计思路很特别，除了单线程、大量使用内存、存储过程支持 Java 语言外，它在数据的复制上的设计也是别出心裁，既不是 NewSQL 的 Paxos 协议也不是 PGXC 的主从复制，你能想到是如何设计的吗？提示一下，复制机制和存储过程是有一定关系的。

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对存储过程这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

Bart Samwel: F1 Query: Declarative Querying at Scale

17 | 为什么不建议你使用自增主键？

你好，我是王磊，你也可以叫我 Ivan。

有经验的数据库开发人员一定知道，数据库除了事务处理、查询引擎这些核心功能外，还会提供一些小特性。它们看上去不起眼，却对简化开发工作很有帮助。

不过，这些特性的设计往往是以单体数据库架构和适度的并发压力为前提的。随着业务规模扩大，在真正的海量并发下，这些特性就可能被削弱或者失效。在分布式架构下，是否要延续这些特性也存在不确定性，我们今天要聊的自增主键就是这样的小特性。

虽然，我对自增主键的态度和第 16 讲提到的存储过程一样，都不推荐你使用，但是原因各有不同。存储过程主要是工程方面的原因，而自增主键则是架构上的因素。好了，让我们进入正题吧。

自增主键的特性

自增主键在不同的数据库中的存在形式稍有差异。在 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 的处理过程。

两个事务 T1 和 T2 都要在同一张表中插入记录，T1 先执行，得到的主键是 25，而 T2 后执行，得到是 26。

但是，T1 事务还要操作其他数据库表，结果不走运，出现了异常，T1 必须回滚。T2 事务则正常执行成功，完成了事务提交。

这样，在数据表中就缺少主键为 25 的记录，而当下一个事务 T3 再次申请主键时，得到的就是 27，那么 25 就成了永远的空洞。

为什么不支持连续递增呢？这是因为自增字段所依赖的计数器并不是和事务绑定的。如果要做到连续递增，就要保证计数器提供的每个主键都被使用。

怎么确保每个主键都被使用呢？那就要等待使用主键的事务都提交成功。这意味着，必须前一个事务提交后，计数器才能为后一个事务提供新的主键，这个计数器就变成了一个表级锁。

显然，如果存在这么大粒度的锁，性能肯定会很差，所以 MySQL 优先选择了性能，放弃了连续递增。至于那些因为事务冲突被跳过的数字呢，系统也不会再回收重用了，这是因为要保证自增主键的单调递增。

看到这里你可能会想， 虽然实现不了连续递增，但至少能保证单调递增，也不错。那么，我要再给你泼一盆冷水了，这个单调递增有时也是不能保证的。

无法单调递增

对于单体数据库自身来说，自增主键确实是单调递增的。但使用自增主键也是有前提的，那就是主键生成的速度要能够满足应用系统的并发需求。而在高并发量场景下，每个事务都要去申请主键，数据库如果无法及时处理，自增主键就会成为瓶颈。那么，这时只用自增主键已经不能解决问题了，往往还要在应用系统上做些优化。

比如，对于 Oracle 数据库，常见的优化方式就是由 Sequence 负责生成主键的高位，由应用服务器负责生成低位数字，拼接起来形成完整的主键。

图中展示这样的例子，数据库的 Sequence 是一个 5 位的整型数字，范围从 10001 到 99999。每个应用系统实例先拿到一个号，比如 10001，应用系统在使用这 5 位为作为高位，自己再去拼接 5 位的低位，这样得到一个 10 位长度的主键。这样，每个节点访问一次 Sequence 就可以处理 99999 次请求，处理过程是基于应用系统内存中的数据计算主键，没有磁盘 I/O 开销，而相对的 Sequence 递增时是要记录日志的，所以方案改进后性能有大幅度提升。

这个方案虽然使用了 Sequence，但也只能保证全局唯一，数据表中最终保存的主键不再是单调递增的了。

因为，几乎所有数据库中的自增字段或者自增序列都是要记录日志的，也就都会产生磁盘 I/O，也就都会面临这个性能瓶颈的问题。所以，我们可以得出一个结论：在一个海量并发场景下，即使借助单体数据库的自增主键特性，也不能实现单调递增的主键。

自增主键的问题

对于分布式数据库，自增主键带来的麻烦就更大了。具体来说是两个问题，一是在自增主键的产生环节，二是在自增主键的使用环节。

首先，产生自增主键难点就在单调递增。如果你已经学习过第 5 讲就会发现，单调递增这个要求和全局时钟中的 TSO 是很相似的。你现在已经知道，TSO 实现起来比较复杂，也容易成为系统的瓶颈，如果再用作主键的发生器，显然不大合适。

其次，使用单调递增的主键，也会给分布式数据库的写入带来问题。这个问题是在 Range 分片下发生的，我们通常将这个问题称为 “尾部热点”。

尾部热点

我们先通过一组性能测试数据来看看尾部热点问题的现象，这些数据和图表来自CockroachDB 官网。

这本身是一个 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 讲我们已经介绍过了 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 填充指定列。

这个随机 ID 是一个 64 位整型，分为三个部分。

第一部分的符号位没有实际作用。

第二部分是事务开始时间，默认为 5 位，可以理解为事务时间戳的一种映射。

第三部分则是自增的序列号, 使用其余位。

AutoRandom 可以保证表内主键唯一，用户也不需要关注分片情况。

外置 Snowflake

雪花算法（Snowflake）是 Twitter 公司分布式项目采用的 ID 生成算法。

这个算法生成的 ID 是一个 64 位的长整型，由四个部分构成：

第一部分是 1 位的符号位，并没有实际用处，主要为了兼容长整型的格式。

第二部分是 41 位的时间戳用来记录本地的毫秒时间。

第三部分是机器 ID，这里说的机器就是生成 ID 的节点，用 10 位长度给机器做编码，那意味着最大规模可以达到 1024 个节点（2^10）。

最后是 12 位序列，序列的长度直接决定了一个节点 1 毫秒能够产生的 ID 数量，12 位就是 4096（2^12）。

这样，根据数据结构推算，雪花算法支持的 TPS 可以达到 419 万左右（2^22*1000），我相信对于绝大多数系统来说是足够了。

但实现雪花算法时，有个小问题往往被忽略，那就是要注意时间回拨带来的影响。机器时钟如果出现回拨，产生的 ID 就有可能重复，这需要在算法中特殊处理一下。

小结

那么，今天的课程就到这里了，让我们梳理一下这一讲的要点。

单体数据库普遍提供了自增主键或序列等方式，自动产生主键。单体数据库的自增主键保证主键唯一、单调递增，但在发生事务冲突时，并不能做到连续递增。在海量并发场景下，通常不能直接使用数据库的自增主键，因为它的性能不能满足要求。解决方式是应用系统进行优化，有数据库控制高位，应用系统控制低位，提升性能。但使用这种方案，主键不再是单调递增的。

分布式数据库在产生自增主键和使用自增主键两方面都有问题。生成自增主键时，要做到绝对的单调递增，其复杂度等同于 TSO 全局时钟，而且存在性能上限。使用自增主键时，会导致写入数据集中在单个节点，出现“尾部热点”问题。

由于自增主键的问题，有的分布式数据库，如 CockroachDB 更推荐使用随机主键的方式。随机主键的产生机制可以分为数据库内置和应用系统外置两种思路。内置的技术方案，我们介绍了 CockraochDB 的 UUID 和 TiDB 的 RadomID。外置技术方案，我们介绍了 Snowflake。

思考题

课程的最后，我们来看看今天的思考题。我们说如果分布式数据库使用 Range 分片的情况下，单调递增的主键会造成写入压力集中在单个节点上，出现“尾部热点”问题。因此，很多产品都用随机主键替换自增主键，分散写入热点。我的问题就是，你觉得使用随机主键是不是一定能避免出现“热点”问题呢？

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对分布式架构下如何设计主键这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

CockroachDB: Yugabyte vs CockroachDB: Unpacking Competitive Benchmark Claims

18 | HTAP是不是赢者通吃的游戏？

你好，我是王磊，你也可以叫我 Ivan。

这一讲的关键词是 HTAP，在解释这个概念前，我们先要搞清楚它到底能解决什么问题。

有关 OLTP 和 OLAP 的概念，我们在第 1 讲就已经介绍过了。OLTP 是面向交易的处理过程，单笔交易的数据量很小，但是要在很短的时间内给出结果；而 OLAP 场景通常是基于大数据集的运算。

OLAP 和 OLTP 通过 ETL 进行衔接。为了提升 OLAP 的性能，需要在 ETL 过程中进行大量的预计算，包括数据结构的调整和业务逻辑处理。这样的好处是可以控制 OLAP 的访问延迟，提升用户体验。但是，因为要避免抽取数据对 OLTP 系统造成影响，所以必须在日终的交易低谷期才能启动 ETL 过程。这样一来， OLAP 与 OLTP 的数据延迟通常就在一天左右，习惯上大家把这种时效性表述为 T+1。其中，T 日就是指 OLTP 系统产生数据的日期，T+1 日是 OLAP 中数据可用的日期，两者间隔为 1 天。

你可能已经发现了，这个体系的主要问题就是 OLAP 系统的数据时效性，T+1 太慢了。是的，进入大数据时代后，商业决策更加注重数据的支撑，而且数据分析也不断向一线操作渗透，这都要求 OLAP 系统更快速地反映业务的变化。

两种解决思路

说到这，你应该猜到了，HTAP 要解决的就是 OLAP 的时效性问题，不过它也不是唯一的选择，这个问题有两种解决思路：

用准实时数据计算替代原有批量 ETL 过程，重建 OLAP 体系；

弱化甚至是干脆拿掉 OLAP，直接在 OLTP 系统内扩展，也就是 HTAP。

重建 OLAP 体系

我们先来看第一种思路。重建 OLAP 体系，重视数据加工的时效性，正是近年来大数据技术的主要发展方向。Kappa 架构就是新体系的代表，它最早由 LinkedIn 的 Jay Kreps 在 2014 年的一篇文章中提出。

在 Kappa 架构中，原来的批量文件传输方式完全被 Kafka 替代，通过流计算系统完成数据的快速加工，数据最终落地到 Serving DB 中提供查询服务。这里的 Serving DB 泛指各种类型的存储，可以是 HBase、Redis 或者 MySQL。

要注意的是，Kappa 架构还没有完全实现，因为在实践中流计算仍然无法替代批量计算，Serving DB 也无法满足各种类型的分析查询需求。未来，Kappa 架构需要在两方面继续完善：

流计算能力的增强，这需要用到 Kafka 和 Flink 等软件；

Serving DB 即时计算能力的增强，这就寄希望于 OLAP 数据库的突破，就像 ClickHouse 已经做的那样。

总的来说，新的 OLAP 体系试图提升即时运算能力，去除批量 ETL，降低数据延迟。这个新体系是流计算的机遇，也是 OLAP 数据库的自我救赎。

新建 HTAP 系统

第二种思路是 HTAP。HTAP（Hybrid Transaction/Analytical Processing）就是混合事务分析处理，它最早出现在 2014 年Gartner 的一份报告中，很巧和 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 概念落地为可运行系统，根本性的挑战就是存储。面对这个挑战，业界有两个不同的解决思路：

Spanner 使用的融合性存储 PAX（Partition Attributes Across），试图同时兼容两类场景。

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” 中就被提出了。论文从 CPU 缓存友好性的角度，对不同的存储方式进行了探讨，涉及 NSM、DSM、PAX 三种存储格式。

NSM （行式存储）

NSM（N-ary Storage Model）就是行式存储，也是 OLTP 数据库默认的存储方式，始终伴随着关系型数据库的发展。我们常用的 OLTP 数据库，比如 MySQL（InnoDB）、PostgreSQL、Oracle 和 SQL Server 等等都使用了行式存储。

顾名思义，行式存储的特点是将一条数据记录集中存在一起，这种方式更加贴近于关系模型。写入的效率较高，在读取时也可以快速获得一个完整数据记录，这种特点称为记录内的局部性（Intra-Record Spatial Locality）。

但是，行式存储对于 OLAP 分析查询并不友好。OLAP 系统的数据往往是从多个 OLTP 系统中汇合而来，单表可能就有上百个字段。而用户一次查询通常只访问其中的少量字段，如果以行为单位读取数据，查询出的多数字段其实是无用的，也就是说大量 I/O 操作都是无效的。同时，大量无效数据的读取，又会造成 CPU 缓存的失效，进一步降低了系统的性能。

图中显示 CPU 缓存的处理情况，我们可以看到很多无效数据被填充到缓存中，挤掉了那些原本有机会复用的数据。

DSM（列式存储）

DSM（Decomposition Storage Model）就是列式存储，它的出现要晚于行式存储。典型代表系统是 C-Store，它是迈克尔 · 斯通布雷克（Micheal Stonebraker）主导的开源项目，后来的商业化产品就是 Vertica。

列式存储就是将所有列集中存储，不仅更加适应 OLAP 的访问特点，对 CACHE 也更友好。这种特点称为记录间的局部性（Inter-Record Spatial Locality）。列式存储能够大幅提升查询性能，以速度快著称的 ClickHouse 就采用了列式存储。

列式存储的问题是写入开销更大，这是因为根据关系模型，在逻辑上数据的组织单元仍然是行，改为列式存储后，同样的数据量会被写入到更多的数据页（page）中，而数据页直接对应着物理扇区，那么磁盘 I/O 的开销自然增大了。

列式存储的第二个问题，就是很难将不同列高效地关联起来。毕竟在多数应用场景中，不只是使用单列或单表数据，数据分散后，关联的成本会更高。

PAX

PAX 增加了 minipage 这个概念，是原有的数据页下的二级单位，这样一行数据记录在数据页上的基本分布不会被破坏，而相同列的数据又被集中地存储在一起。PAX 本质上还是更接近于行式存储，但它也在努力平衡记录内局部性和记录间局部性，提升了 OLAP 的性能。

理论上，PAX 提供了一种兼容性更好的存储方式，可让人有些信心不足的是其早在 2002 年提出，但在 Spanner 之前却少有落地实现。

与这个思路类似的设计还有 HyPer 的DataBlock(SIGMOD2016)，DataBlock 构造了一种独有的数据结构，同时面向 OLTP 和 OLAP 场景。

TiFlash：存储分离

如果底层存储是一份数据，那么天然就可以保证 OLTP 和 OLAP 的数据一致性，这是 PAX 的最大优势，但是由于访问模式不同，性能的相互影响似乎也是无法避免，只能尽力选择一个平衡点。TiDB 展现了一种不同的思路，介于 PAX 和传统 OLAP 体系之间，那就是 OLTP 和 OLAP 采用不同的存储方式，物理上是分离的，然后通过创新性的复制策略，保证两者的数据一致性。

TiDB 是在较早的版本中就提出了 HTAP 这个目标，并增加了 TiSpark 作为 OLAP 的计算引擎，但仍然共享 OLTP 的数据存储 TiKV，所以两种任务之间的资源竞争依旧不可避免。直到近期的 4.0 版本中，TiDB 正式推出了 TiFlash 作为 OLAP 的专用存储。

我们的关注点集中在 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 系统，首要目标是保证读性能，因此写入无论多么重要，都要让位于读优化。作为分布式系统，还有最后一招可用，那就是通过扩容降低单点写入的压力。

小结

好了，今天的内容我们就聊到这了，最后让我们梳理一下这一讲的要点。

OLTP 通过 ETL 与 OLAP 衔接，所以 OLAP 的数据时效性通常是 T+1，不能及时反映业务的变化。这个问题有两种解决思路，一种是重建 OLAP 体系，通过流计算方式替代批量数据处理，缩短 OLAP 的数据延迟，典型代表是 Kappa 架构。第二种思路是 Gartner 提出的 HTAP。

HTAP 的设计要点在计算引擎和存储引擎，其中存储引擎是基础。对于存储引擎也两种不同的方案，一种是以 PAX 为代表，用一份物理存储融合行式和列式的特点，Spanner 采用了这种方式。另一种是 TiDB 的 TiFlash，为 OLTP 和 OLAP 分别设置行式存储和列式存储，通过创新性的同步机制保证数据一致。

TiDB 的同步机制仍然是基于 Raft 协议的，通过增加 Learner 角色实现异步复制。异步复制必然带来数据的延迟，Learner 在响应请求前，通过与 Leader 同步增量日志的方式，满足数据一致性，但这会带来通讯上的额外开销。

TiFlash 作为列存，首先要保证读取性能，但因为要保证数据一致性，所以也要求具备较高的写入性能，TiFlash 通过 Delta Tree 的设计来平衡读写性能。这个问题我们没有展开，将在 22 讲继续讨论。

总的来说，HTAP 是解决传统 OLAP 的一种思路，但是推动者只是少数 OLTP 数据库厂商。再看另一边，以流计算为基础的新 OLAP 体系，这些技术本身就是大数据技术生态的一部分，有更多的参与者，不断有新的成果落地。至于 HTAP 具有绝对优势的数据一致性，其实在商业场景中也未必有足够多的刚性需求。所以，从实践效果出发，我个人更加看好后者，也就是新 OLAP 体系。

当然 HTAP 也具有相对优势，那就是通过全家桶方案避免了用户集成多个技术产品，整体技术复杂度有所降低。最后，TiDB 给出的解决方案很有新意，但是能否覆盖足够大的 OLAP 场景，仍有待观察。

思考题

课程的最后，我们来看看今天的思考题。今天我们介绍了 TiDB 的 OLAP 组件 TiFlash，它保持数据一致性的方法是，每次 TiFlash 接到请求后，都会向 TiKV Leader 请求最新的日志增量，本地 replay 日志后再继续处理请求。这种模式虽然能够保证数据足够新，但比起 TiFlash 独立服务多了一次网络通讯，在延迟上有较大的影响。我的问题就是，你觉得这个模式还能优化吗？在什么情况下不需要与 Leader 通讯？

欢迎你在评论区留言和我一起讨论，我会在答疑篇回复这个问题。如果你身边的朋友也对 HTAP 这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

Anastassia Ailamaki et al.: Data Page Layouts for Relational Databases on Deep Memory Hierarchies

Harald Lang et al: Data Blocks: Hybrid OLTP and OLAP on Compressed Storage using both Vectorization and Compilation

Jay Kreps: Questioning the Lambda Architecture

Nigel Rayner et al.: Hybrid Transaction/Analytical Processing Will Foster Opportunities for Dramatic Business Innovation

19 | 查询性能优化：计算与存储分离架构下有哪些优化思路？

你好，我是王磊，你也可以叫我 Ivan。

我在第 4 讲介绍架构风格时曾经提到过，分布式数据库的主体架构是朝着计算和存储分离的方向发展的，这一点在 NewSQL 架构中体现得尤其明显。但是计算和存储是一个完整的过程，架构上的分离会带来一个问题：是应该将数据传输到计算节点 (Data Shipping)，还是应该将计算逻辑传输到数据节点 (Code Shipping)？

从直觉上说，肯定要选择 Code Shipping，因为 Code 的体量远小于 Data，因此它能传输得更快，让系统的整体性能表现更好。

这个将 code 推送到存储节点的策略被称为“计算下推”，是计算存储分离架构下普遍采用的优化方案。

计算下推

将计算节点的逻辑推送到存储节点执行，避免了大量的数据传输，也达到了计算并行执行的效果。这个思路还是很好理解的，我们用一个例子来具体说明下。

假如有一张数据库表 test，目前有四条记录。

我们在客户端执行下面这条查询 SQL。

select value from test where cond=’C1’；

计算节点接到这条 SQL 后，会将过滤条件“cond=‘C1’“下推给所有存储节点。

存储节点 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 讲曾经介绍过 TiDB 采用了“缓存写提交”技术，就是将所有的写 SQL 缓存起来，直到事务 commit 时，再一起发送给存储节点。这意味着执行事务中的 select 语句时，insert 的数据还没有写入存储节点，而是缓存在计算节点上的，那么 select 语句下推后，查询结果将只有 ID 为 4 的记录，没有 ID 等于 5 的记录。

这个结果显然是错误的。为了解决这个问题，TiDB 开始的设计策略是，当计算节点没有缓存数据时，就执行下推，否则就不执行下推。

这种策略限制了下推的使用，对性能的影响很大。所以，之后 TiDB 又做了改进，将缓存数据也按照存储节点的方式组织成 Row 格式，再将缓存和存储节点返回结果进行 Merge，就得到了最后的结果。这样，缓存数据就不会阻碍读请求的下推了。

分区键与 Join 下推

除了谓词下推，还有一个对下推来讲很重要的关联设计，那就是分区键。分区键是沿用单体数据库的说法，这里的分区实质是指分片，也就是在定义建表语句时，显式指定的分片对应的键值。

如果你已经学习过第 6 讲，一定记得不同的分片机制与架构风格直接相关。通常，在 PGXC 架构中是显式指定分片的，所以会出现分区键；而 NewSQL 主要采用 Range 分片，用户感受不到分片的存在，所以往往无法利用这个特性。

只要 SQL 的谓词条件中包含分区键，那么很多时候是可以下推到各个存储节点执行的。就算是面对多表关联的情况，只要这些表使用了相同的分区键，也是可以下推的，类似的方式在 PolarDB 中被称为“Join 下推”，在 Greenplum 中被称为本地连接（Local Joins）。Join 下推可以保证数据移动最少，减少网络开销。

但是，多表使用相同的分区键并不是一个通用的方法，很多时候会在性能的均衡上面临挑战。例如，我们对用户表和交易表同样使用“机构”来做分区键，这时在每个分片内用户数量和交易数量往往不成正比。这是因为少量用户贡献了多数的交易，同时这些少量用户可能又会集中在几个节点上，就会出现局部资源紧张。

最后，也不是所有计算都能下推的。比如，排序操作，业务需求往往不只是在一个分区内进行排序；还有关联查询（Join），即使关联的多张表都使用了分区键，但如果查询条件中没有包含分区键，也是很难处理。关联查询的有关内容，我在第 20 讲还会详细介绍。

索引分布

分布式数据库执行计算下推的目的就是为了加速查询。我想问问你，单体数据库的查询优化手段是什么呢？嗯，你一定会告诉我，索引优化。

的确，索引是数据库加速查询的重要手段。索引优化的基本逻辑是：索引实质是数据库表的子表，它的数据量更少，所以查询索引比查询数据表更高效。那么，先通过索引确定记录的主键后再“回表”查询，也就比直接查询数据表的速度更快。当然，在有些情况下，索引包含的数据项已经能够满足查询的需要，可以免去“回表”这个步骤，性能表现会更好。

索引优化对于分布式数据库来说仍然是重要的优化手段，并且和前面介绍的计算下推有密切的关系。

对于单体数据库，索引和数据表必然在同一节点上；而在分布式架构下，索引和数据既可能是同节点的，也可能是跨节点的，而这对于读写性能有很大影响。我们按照索引的分布情况和作用范围，可以分为全局索引和分区索引两种类型。在很多分布式数据库中都有对应实现，支持情况稍有差异。

分区索引

分区索引就是索引与数据在同一分区，这个分区实际就是我们之前说的分片。因为分片是最小调度单位，那就意味着在分区索引下，索引和数据是确保存储在同一物理节点。我们把索引和数据在同一个物理节点的情况称为同分布（co_located）。

分区索引的优点很明显，那就是性能好，因为所有走索引的查询都可以下推到每个存储节点，每个节点只把有效查询结果返回给计算节点，避免了大量无效的数据传输。分区索引的实现难点在于如何保证索引与数据始终同分布。

索引与数据同分布又和分片的基本策略有关。我们在第 6 讲介绍了动态分片的分拆和调度，都会影响同分布。Spanner2017 论文中简短地介绍了父子表模式（parent-child）的同分布策略，原理就是利用键值存储系统左前缀匹配 Key 区间的特性，通过设置子表记录与父表记录保持相同的前缀，来实现两者的同分布。索引作为数据的子表，也采用了类似的设计理念。

NewSQL 分布式数据库的底层就是分布式键值存储系统，所以我们下面用 BigTable 的开源实现 HBase 来介绍具体实现原理。

在 HBase 下，每个分片都有一个不重叠的 Key 区间，这个区间左闭右开。当新增一个键值对（Key/Value）时，系统会先判断这个 Key 与哪个分片的区间匹配，而后就分配到那个匹配的分片中保存，匹配算法一般采用左前缀匹配方式。

这个场景中，我们要操作的是一张用户信息表 T_USER，它有四个字段，分别是主键 PID、客户名称（Name）、城市（City）和年龄（Age）。T_USER 映射到 HBase 这样的键值系统后，主键 PID 作为 Key，其他数据项构成 Value。事实上，HBase 的存储格式还要更复杂些，这里为了便于你理解，做了简化。

我们在“Ctiy”字段上建立索引，索引与数据行是一对一的关系。索引存储也是 KV 形式，Key 是索引自身的主键 ID，Value 是反序列化信息用于解析主键内容。索引主键由三部分构成，分别是分片区间起始值、索引值和所指向数据行的主键（PID）。因为 PID 是唯一的，索引主键在它的基础上增加了前缀，所以也必然是唯一的。

整个查询的流程是这样的：

客户端发起查询 SQL。

计算节点将 SQL 下推到各个存储节点。

存储节点在每个 Region 上执行下推计算，取 Region 的起始值加上查询条件中的索引值，拼接在一起作为左前缀，扫描索引数据行。

根据索引扫描结果中的 PID，回表查询。

存储节点将 Region 查询结果，反馈给计算节点。

计算节点汇总结果，反馈给客户端。

实现分区索引的难点在于如何始终保持索引与数据的同分布，尤其是发生分片分裂时，这是很多索引方案没有完美解决的问题。有些方案是在分裂后重建索引，这样开销太大，而且有短暂的不一致。我在自研软件 Phaors 时，专门对这个处理做了设计优化，这里和你分享一下。其实，设计思想并不复杂，那就是把同分布的索引和数据装入一个更小的组织单元 (Bucket)，而在分片分裂时要保持 Bucket 的完整性。这样一来，因为 Bucket 的粒度足够小，就不会影响分片分裂本身的目标，也就是平衡分片的数据量和访问压力，又能维持索引数据同分布。

全局索引

当然，分区索引也是有缺陷的。如果你期望的是一个唯一索引，那么分区索引就无法实现。因为唯一值的判定显然是一个全局性的约束，而所有全局性的约束，都无法在一个分片内完成。

唯一索引对应的方案就是全局索引。全局索引并不保持索引与数据同分布，于是就带来两个问题：

读操作的通讯成本更高，计算节点要与存储节点做两轮通讯，第一次查询索引信息，第二次回表查询数据。

写操作的延迟更长，因为任何情况下索引应该与数据保持一致，如果同分布，那么数据变更时可以通过本地事务保证，但在全局索引下就变成了一个分布式事务，代价当然更高了。

所以，在使用分布式数据库时，是否有必要建立全局索引，是一个非常谨慎的决定。

回到产品层面，并不是所有分布式数据库都支持了分区索引和全局索引供用户选择，比如 TiDB 的二级索引只支持全局索引。

小结

那么，今天的课程就到这里了，让我们梳理一下这一讲的要点。

计算与存储分离是分布式数据库的指导思想，但容易造成数据的无效传输，降低整体性能。所以将计算逻辑推送到存储节点，是一个主要的优化方向，这个策略被称为“计算下推”。

计算下推的逻辑非常简单，但并不是每个产品都能轻松实现的。比如，在 TiDB 中因为缓存了写操作，需要使用更复杂的机制来对冲。

索引是数据优化的重要手段，在分布式数据库下具体实现包括分区索引和全局索引。分区索引可以兼容计算下推的要求，将负载分散到各个存储节点。

分区索引的难点在于分片分裂时如何保持索引与数据的同分布，可以通过引入更小的数据组织单元解决这个问题。

分区索引无法实现唯一索引，只能用全局索引支持。但是全局索引会带来两个问题，一是查询索引与数据是两轮通讯，延迟更长，二是索引必须与数据同步更新，这就增加了一个分布式事务，造成数据更新的代价更大。

其实，计算与存储分离架构自诞生起，就伴随着对其性能的质疑，这也推动了各种分布式数据库进行计算下推的优化，在存储节点支持更多的计算函数。但是计算下推终归要受到运算逻辑的限制，并不是所有计算都可以无冗余地下推。分区索引是计算下推的一种特殊形式，但很多分布式数据库并没支持这个特性，而是用实现起来更简单的全局索引代替，也因此增加了读、写两个方面的性能开销。

思考题

课程的最后，我们来看看今天的思考题。我们今天的关键词是计算下推，在课程的最后我提到一个“无冗余下推”的概念。其实，我想表达的意思是，有时候虽然可以下推但是计算量会被增大，这是因为运算逻辑无法等价地拆分成若干个子任务分散到存储节点上，那么增加出来的计算量就是一种冗余。我的问题就是，如果将“单表排序”操作进行下推，该如何设计一种有冗余的下推算法呢？

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对计算下推这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

David F. Bacon et al.: Spanner: Becoming a SQL System

20 | 关联查询：如何提升多表Join能力？

你好，我是王磊，你也可以叫我 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。

SNLJ 的执行过程是这样的：

遍历 Outer 表，取一条记录 r1；

遍历 Inner 表，对于 Inner 表中的每条记录，与 r1 做 join 操作并输出结果；

重复步骤 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 的执行效率。

与 SNLJ 相比，BNJ 虽然在时间复杂度都是 O(m*n)（m 和 n 分别是 Outer 表和 Inner 表的记录行数），但磁盘 I/O 的开销却明显降低了，所以效果优于 SNLJ。

Index Lookup Join

你应该也注意到了，SNLJ 和 BNJ 都是直接在数据行上扫描，并没有使用索引。所以，这两种算法的磁盘 I/O 开销还是比较大的。

Index Lookup Join（ILJ）就是在 BNJ 的基础上使用了索引，算法执行过程是这样的：

遍历 Outer 表，取一个批次的记录 ri；

通过连接键（Join Key）和 ri 可以确定对 Inner 表索引的扫描范围，再通过索引得到对应的若干条数据记录，记为 sj；

将 ri 的每一条记录与 sj 的每一条记录做 Join 操作并输出结果；

重复前三步，直到遍历完 Outer 表中的所有数据，就得到了最后结果集。

看到这里，ILJ 的主要优化点也很明显了，就是对 Inner 表进行索引扫描。那么，你可能会问了为什么不让 Outer 表也做索引扫描呢？

我认为，Outer 表当然也可以走索引。但是，BNJ 在 Inner 表上要做多次全表扫描成本最高，所以 Inner 表上使用索引的效果最显著，也就成为了算法的重点。而对 Outer 表来说，因为扫描结果集要放入内存中暂存，这意味着它的记录数是比较有限的，索引带来的效果也就没有 Inner 表那么显著，所以在定义中没有强调这部分。

关联算法的定义是为了让我们专注其中的重点，而不是僵化地去理解它。比如，我们会在有些教材上看到，对 ILJ 的定义就是直接在 SNLJ 增加索引，并不是在 BNJ 上拓展。而要真正在工程中应用关联算法，都要结合具体场景进一步优化。

排序归并连接算法

排序归并算法就是 Sort-Merge Join（SMJ），也被称为 Merge Join。SMJ 可以分为排序和归并两个阶段：

第一阶段是排序，就是对 Outer 表和 Inner 表进行排序，排序的依据就是每条记录在连接键上的数值。

第二阶段就是归并，因为两张表已经按照同样的顺序排列，所以 Outer 表和 Inner 表各一次循环遍历就能完成比对工作了。

简单来说，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）。

建立阶段

选择一张表作为 Inner 表，对其中每条记录上的连接属性（Join Attribute）使用哈希函数得到哈希值，从而建立一个哈希表。在计算逻辑允许的情况下，建立阶段选择数据量较小的表作为 Inner 表，以减少生成哈希表的时间和空间开销。

探测阶段

另一个表作为 Outer 表，扫描它的每一行并计算连接属性的哈希值，与建立阶段生成的哈希表进行对比。当然，哈希值相等不代表连接属性相等，还要再做一次判断，返回最终满足条件的记录。

通过 Simple Hash Join 这个命名，我们就能知道它也是一个简单的算法。这里的简单是说，它做了非常理想化的假设，也就是 Inner 表形成的哈希表小到能够放入内存中。可实际上，即使对于单体数据库来说，这个哈希表也是有可能超过内存容量的。

哈希表无法全部放入内存怎么办呢？这时就要使用 Grace HashJoin 算法了。

Grace Hash Join

GHJ 算法与 SHJ 的不同之处在于，GHJ 正视了哈希表大于内存这个问题，将哈希表分块缓存在磁盘上。GHJ 中的 Grace 并不是指某项技术，而是首个采用该算法的数据库的名字。

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 讲中我们提到了计算下推，换个角度看，其实它就是一种并行框架，不过是最简单的并行框架。因为在很多情况下，计算任务的执行节点和对应数据的存储节点并不是完全对应的，也就没办法只依据数据分布就拆分出子任务。

那么，要想在数据交错分布的情况下，合理地划分和调度子任务就需要引入更复杂的计算引擎。这种并行执行引擎在 OLAP 数据库中比较常见，通常称为 MPP（Massively Parallel Processing）。很明显，MPP 已经超出了 OLTP 计算引擎的范畴，并不是所有分布式数据库都支持的。

比如，我们前面介绍过的 TiDB，在最初的 TiDB + TiKV 的体系中，就没有 MPP 引擎。TiDB 的存储节点之间是不能通讯的（除了 Raft 协议），这就意味着如果子任务之间有数据传输就必须以计算节点为通道。这样，计算节点很容易成为瓶颈，同时增加了网络传输负载。由此可见，必须经过计算节点这个约束，是生成高效并行计划的一个障碍。后来，TiDB 也没有打破这个约束，而是通过引入 Spark 来处理复杂的 OLAP 计算任务，这就是 TiSpark 组件。

但并不是所有分布式数据库都采用引入外部组件的方式，比如 OceanBase 就在原有设计中拓展了并行执行框架，实现了更复杂的任务调度，在存储节点间也可以直接进行数据交换。

OceanBase 大致也是 P2P 架构，每个 Observer 部署了相同的服务，在运行过程中，动态的承担不同角色。图中一个 Observer 节点承担了入口处的查询协调器，其他节点作为子查询协调器，上面的工作线程是真正的任务执行者。

理解了并行框架的必要性，我们再回到多表关联这个具体场景。多表关联的复杂度，主要看参与表的数据量。其中，小表之间的关联都比较简单，所以我们接下来主要关注小表与大表关联和大表之间的关联。

大小表关联（复制表）

大小表关联时，可以把小表复制到相关存储节点，这样全局关联就被转换为一系列的本地关联，再汇总起来就得到了最终结果。这种算法的具体实现方式有两种。

静态的方式

静态的方式，其实就是在创建表的时候，直接使用关键字将表声明为复制表，这样每个节点上都会保留一份数据副本。当它与大表关联时，计算节点就可以将关联操作下推到每个存储节点进行。很多分布式数据库，比如 TBase、TDSQL 等，都支持定义复制表。

动态方式

动态方式也称为“小表广播”，这种方式不需要人工预先定义，而是在关联发生时，系统自行处理。这就是说，当关联的某张表足够小时，在整个集群中分发不会带来太大的网络开销，系统就将其即时地复制到相关的数据节点上，实现本地关联。

下面这张图体现了小表广播的过程。

动态方式和并行执行引擎有直接的联系，例如 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 来对比学习一下。

shuffle 阶段：分别将两个表按照连接键进行分区，将相同连接键的记录重分布到同一节点，数据就会被分配到尽量多的节点上，增大并行度。

hash join 阶段：每个分区节点上的数据单独执行单机 hash join 算法。

小结

那么，今天的课程就到这里了，让我们梳理一下这一讲的要点。

关联是数据库中比较复杂的操作，相关算法主要分为三类，分别是嵌套循环、排序归并和哈希。嵌套循环是比较基础的排序算法，大多数数据库都会支持，又细分为 SNLJ、BNLJ、ILJ 三种。排序归并算法，仅适用于关联数据有序的情况，比如连接键是关联表的索引列时，在这个前提下排序归并算法的成本低于嵌套循环。

哈希算法适用于大小表关联和大表关联的场景，并不是 OLTP 数据库的标配。在海量数据下，哈希算法比嵌套循环和排序归并这两种算法的效果更好，所以在 OLAP 数据库和大数据技术产品中比较常见。常用的哈希算法包括 SHJ、GHJ 和 HHJ。

分布式数据库下关联算法的优化依赖于并行框架（MPP），而并行框架更多地出现在 OLAP 数据库中，不是分布式数据库的标配。

大小表关联的方法是复制表，有静态和动态两种实现方式。静态方式是预先将小表存储在所有节点，动态方式是在关联发生时决定是否广播小表。大表间关联的方法是重分布。当 A 和 B 两张表关联时，如果 A 表的分区键与连接键相同，只需要对 B 表做单表重分布，否则两表都需要重分布，代价更大。

关联计算是查询场景中比较复杂的操作，即使面向 OLTP 场景的传统单体数据库也没有完善的处理，比如 MySQL 直到 8.0 版本才支持 Hash Join。而分布式数据库也由于自身定位不同，对关联算法支持程度存在差异。总的来说，越倾向于支持 OLAP 场景，对关联算法的支持度也就越高。

思考题

课程的最后，我们来看看今天的思考题。我在介绍哈希算法时，说“在计算逻辑允许的情况下，建立阶段选择数据量较小的表作为 Inner 表”，我的问题就是在什么情况下，系统无法根据数据量决定 Inner 表呢？

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对关联查询这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

21 | 查询执行引擎：如何让聚合计算加速？

你好，我是王磊。

在 19、20 两讲中，我已经介绍了计算引擎在海量数据查询下的一些优化策略，包括计算下推和更复杂的并行执行框架。这些策略对应了从查询请求输入到查询计划这个阶段的工作。那么，整体查询任务的下一个阶段就是查询计划的执行，承担这部分工作的组件一般称为查询执行引擎。

单从架构层面看，查询执行引擎与分布式架构无关，但是由于分布式数据库要面对海量数据，所以对提升查询性能相比单体数据库有更强烈的诉求，更关注这部分的优化。

你是不是碰到过这样的情况，对宽口径数据做聚合计算时，系统要等待很长时间才能给出结果。那是因为这种情况涉及大量数据参与，常常会碰到查询执行引擎的短板。你肯定想知道，有优化办法吗？

当然是有的。查询执行引擎是否高效与其采用的模型有直接关系，模型主要有三种：火山模型、向量化模型和代码生成。你碰到的情况很可能是没用对模型。

火山模型

火山模型（Volcano Model）也称为迭代模型（Iterator Model），是最著名的查询执行模型，早在 1990 年就在论文“Volcano, an Extensible and Parallel Query Evaluation System”中被提出。主流的 OLTP 数据库 Oracle、MySQL 都采用了这种模型。

在火山模型中，一个查询计划会被分解为多个代数运算符（Operator）。每个 Operator 就是一个迭代器，都要实现一个 next() 接口，通常包括三个步骤：

调用子节点 Operator 的 next() 接口，获取一个元组（Tuple）；

对元组执行 Operator 特定的处理；

返回处理后的元组。

通过火山模型，查询执行引擎可以优雅地将任意 Operator 组装在一起，而不需要考虑每个 Operator 的具体处理逻辑。查询执行时会由查询树自顶向下嵌套调用 next() 接口，数据则自底向上地被拉取处理。所以，这种处理方式也称为拉取执行模型（Pull Based）。

为了更好地理解火山模型的拉取执行过程，让我们来看一个聚合计算的例子，它来自 Databricks 的一篇文章（Sameer Agarwal et al. (2016)）。

select count(*) from store_sales where ss_item_sk = 1000;

开始从扫描运算符 TableScan 获取数据，通过过滤运算符 Filter 开始推动元组的处理。然后，过滤运算符传递符合条件的元组到聚合运算符 Aggregate。

你可能对“元组”这个词有点陌生，其实它大致就是指数据记录（Record），因为讨论算法时学术文献中普遍会使用元组这个词，为了让你更好地与其他资料衔接起来，我们这里就沿用“元组”这个词。

火山模型的优点是处理逻辑清晰，每个 Operator 只要关心自己的处理逻辑即可，耦合性低。但是它的缺点也非常明显，主要是两点：

虚函数调用次数过多，造成 CPU 资源的浪费。

数据以行为单位进行处理，不利于发挥现代 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 中。这样做有两个好处：

降低了整个查询计划中 Operator 的数量，也就简化了 Operator 间的嵌套调用关系，最终减少了虚函数调用次数。

单个 Operator 的处理逻辑更集中，增强了代码局部性能力，更容易发挥 CPU 的分支预测能力。

分支预测能力

你可能还不了解什么是分支预测能力，我这里简单解释一下。

分支预测是指 CPU 执行跳转指令时的一种优化技术。当出现程序分支时 CPU 需要执行跳转指令，在跳转的目的地址之前无法确定下一条指令，就只能让流水线等待，这就降低了 CPU 效率。为了提高效率，设计者在 CPU 中引入了一组寄存器，用来专门记录最近几次某个地址的跳转指令。

这样，当下次执行到这个跳转指令时，就可以直接取出上次保存的指令，放入流水线。等到真正获取到指令时，如果证明取错了则推翻当前流水线中的指令，执行真正的指令。

这样即使出现分支也能保持较好的处理效率，但是寄存器的大小总是有限的，所以总的来说还是要控制程序分支，分支越少流水线效率就越高。

刚刚说的运算符融合是一种针对性的优化方法，优点是实现简便而且快速见效，但进一步的提升空间很有限。

因此，学术界还有一些更积极的改进思路，主要是两种。一种是优化现有的迭代模型，每次返回一批数据而不是一个元组，这就是向量化模型（Vectorization）；另一种是从根本上消除迭代计算的性能损耗，这就是代码生成（Code Generation）。

我们先来看看向量化模型。

向量化：TiDB&CockroachDB

向量化模型最早提出是在MonerDB-X100（Vectorwise）系统，已成为现代硬件条件下广泛使用的两种高效查询引擎之一。

向量化模型与火山模型的最大差异就是，其中的 Operator 是向量化运算符，是基于列来重写查询处理算法的。所以简单来说，向量化模型是由一系列支持向量化运算的 Operator 组成的执行模型。

我们来看一下向量化模型怎么处理聚合计算。

通过这个执行过程可以发现，向量化模型依然采用了拉取式模型。它和火山模型的唯一区别就是 Operator 的 next() 函数每次返回的是一个向量块，而不是一个元组。向量块是访问数据的基本单元，由固定的一组向量组成，这些向量和列 / 字段有一一对应的关系。

向量处理背后的主要思想是，按列组织数据和计算，充分利用 CPU，把从多列到元组的转化推迟到较晚的时候执行。这种方法在不同的操作符间平摊了函数调用的开销。

向量化模型首先在 OLAP 数据库中采用，与列式存储搭配使用可以获得更好的效果，例如 ClickHouse。

我们课程里定义的分布式数据库都是面向 OLTP 场景的，所以不能直接使用列式存储，但是可以采用折中的方式来实现向量化模型，也就是在底层的 Operator 中完成多行到向量块的转化，上层的 Operator 都是以向量块作为输入。这样改造后，即使是与行式存储结合，仍然能够显著提升性能。在 TiDB 和 CockroachDB 的实践中，性能提升可以达到数倍，甚至数十倍。

向量化运算符示例

我们以 Hash Join 为例，来看下向量化模型的执行情况。

在第 20 讲我们已经介绍过 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）。

总的来说，向量化执行模型对火山模型做了针对性优化，在以下几方面有明显改善：

减少虚函数调用数量，提高了分支预测准确性；

以向量块为单位处理数据，利用 CPU 的数据预取特性，提高了 CPU 缓存命中率；

多行并发处理，发挥了 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 的工程师对比了两者的执行效率，测试显示手工代码的吞吐能力要明显优于火山模型。

引自Sameer Agarwal et al. (2016)

手工编写代码的执行效率之所以高，就是因为它的循环次数要远远小于火山模型。而代码生成就是按照一定的策略，通过即时编译（JIT）生成代码可以达到类似手写代码的效果。

此外，代码生成是一个推送执行模型（Push Based），这也有助于解决火山模型嵌套调用虚函数过多的问题。与拉取模型相反，推送模型自底向上地执行，执行逻辑的起点直接就在最底层 Operator，其处理完一个元组之后，再传给上层 Operator 继续处理。

Hyper 是一个深入使用代码生成技术的数据库，Hyper 实现的论文（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 对应的是三个分支。

引自Thomas Neumann (2011)

要获得最优的 CPU 执行效率，就要使数据尽量不离开 CPU 的寄存器，这样就可以在一个 CPU 流水线（Pipeline）上完成数据的处理。但是，查询计划中的 Join 操作要生成 Hash 表加载到内存中，这个动作使数据必须离开寄存器，称为物化（Materilaize）。所以整个执行过程会被物化操作分隔为 4 个 Pipeline。而像 Join 这种会导致物化操作的 Operator，在论文称为 Pipeline-breaker。

通过即时编译生成代码得到对应 Piepline 的四个代码段，可以表示为下面的伪码：

引自Thomas Neumann (2011)

代码生成消除了火山模型中的大量虚函数调用，让大部分指令可以直接从寄存器取数，极大地提高了 CPU 的执行效率。

代码生成的基本逻辑清楚了，但它的工程实现还是挺复杂的，所以会有不同粒度的划分。比如，如果是整个查询计划的粒度，就会称为整体代码生成（Whole-Stage Code Generation），这个难度最大；相对容易些的是代码生成应用于表达式求值（Expression Evaluation），也称为表达式代码生成。在 OceanBase 2.0 版本中就实现了表达式代码生成。

如果你想再深入了解代码生成的相关技术，就需要有更全面的编译器方面的知识做基础，比如你可以学习宫文学老师的编译原理课程。

小结

那么，今天的课程就到这里了，让我们梳理一下这一讲的要点。

火山模型自 1990 年提出后，是长期流行的查询执行模型，至今仍在 Oracle、MySQL 中使用。但面对海量数据时，火山模型有 CPU 使用率低的问题，性能有待提升。

火山模型仍有一些优化空间，比如运算符融合，可以适度减少虚函数调用，但提升空间有限。学术界提出的两种优化方案是向量化和代码生成。

简单来说，向量化模型就是一系列向量化运算符组成的执行模型。向量化模型首先在 OLAP 数据库和大数据领域广泛使用，配合列式存储取得很好的效果。虽然 OLTP 数据库的场景不适于列式存储，但将其与行式存储结合也取得了明显的性能提升。

代码生成是现代编译器与 CPU 结合的产物，也可以大幅提升查询执行效率。代码生成的基础逻辑是，针对性的代码在执行效率上必然优于通用运算符嵌套。代码生成根据算法会被划分成多个在 Pipeline 执行的单元，提升 CPU 效率。代码生成有不同的粒度，包括整体代码生成和表达式代码生成，粒度越大实现难度越大。

向量化和代码生成是两种高效查询模型，并没有最先出现在分布式数据库领域，反而是在 OLAP 数据库和大数据计算领域得到了更广泛的实践。ClickHouse 和 Spark 都同时混用了代码生成和向量化模型这两项技术。目前 TiDB 和 CockroachDB 都应用向量化模型，查询性能得到了一个数量级的提升。OceanBase 中则应用了代码生成技术优化了表达式运算。

思考题

课程的最后，我们来看看今天的思考题。这一讲，我们主要讨论了查询执行引擎的优化，核心是如何最大程度发挥现代 CPU 的特性。其实，这也是基础软件演进中一个普遍规律，每当硬件技术取得突破后就会引发软件的革新。那么，我的问题就是你了解的基础软件中，哪些产品分享了硬件技术变革的红利呢？

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对查询执行引擎这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

Goetz Graefe: Volcano, an Extensible and Parallel Query Evaluation System

Peter Boncz et al.: MonetDB/X100: Hyper-Pipelining Query Execution

Sameer Agarwal et al.: Apache Spark as a Compiler: Joining a Billion Rows per Second on a Laptop

Thomas Neumann: Efficiently Compiling Efficient Query Plans for Modern Hardware

22｜RUM猜想：想要读写快还是存储省？又是三选二

你好，我是王磊。

从第 18 讲，我们开始介绍查询过程中全部重要节点的相关技术，从并行框架到查询执行引擎，再从关联运算符到行式和列式存储。今天这一讲我们面临最后的一个步骤，直接和磁盘打交道，实现最终的数据存储，这就是存储引擎。

RUM 猜想

说到数据存储，我相信很多人都有一种直觉，那就是读写两种操作的优化在很多时候是互斥的。

我们可以想一下，数据以什么形式存储，可以实现最快的写入速度？答案肯定是按照顺序写入，每次新增数据都追加在文件尾部，因为这样物理磁盘的磁头移动距离最小。

但这种方式对于读取显然是不友好的，因为没有任何有意义的数据结构做辅助，读操作必须从头到尾扫描文件。反过来，如果要实现更高效的读取，就要设计更复杂的数据结构，那么写入的速度当然就降低了，同时在存储空间上也会有额外的要求。

2016 年的一篇论文将我们这种朴素的理解提升到理论层面，这就是 RUM 猜想。RUM 猜想来自论文“Designing Access Methods: The RUM Conjecture”（Manos Athanassoulis et al.(2016)），同时被 SIGMOD 和 EDBT 收录。它说的是，对任何数据结构来说，在 Read Overhead（读）、Update Overhead（写） 和 Memory or Storage Overhead（存储） 中，同时优化两项时，需要以另一项劣化作为代价。论文用一幅图展示了常见数据结构在这三个优化方向中的位置，这里我摘录下来便于你理解。

引自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 条记录。为了方便你理解，我略去了叶子节点指向数据的指针以及叶子节点之间的顺序指针。

B+Tree 由内节点（InterNode）和叶节点（LeafNode）两类节点构成，前者仅包含索引信息，后者则携带了指向数据的指针。当插入一个索引值为 70 的记录，由于对应页表的记录已满，需要对 B+Tree 重新排列，变更其父节点所在页表的记录，并调整相邻页表的记录。完成重新分布后的效果如下：

在这个写入过程中存在两个问题：

写放大

本来我们仅需要一条写入记录（黄色标注），实际上更新了 3 个页表中的 7 条索引记录，额外的 6 条记录（绿色标注）是为了维护 B+Tree 结构产生的写放大。

为了度量写放大的程度，相关研究中引入了写放大系数（Write Amplification Factor，WAF）这个指标，就是指实际写入磁盘的数据量和应用程序要求写入数据量之比。对于空间放大有类似的度量单位，也就是空间放大系数（Space Amplification Factor, SAF）。

我们这个例子中的 WAF 是 7。

存储不连续

虽然新增叶节点会加入到原有叶节点构成的有序链表中，整体在逻辑上是连续的，但是在磁盘存储上，新增页表申请的存储空间与原有页表很可能是不相邻的。这样，在后续包含新增叶节点的查询中，将会出现多段连续读取，磁盘寻址的时间将会增加。

也就是说，虽然 B+Tree 结构是为读取做了优化，但如果进行大量随机写还是会造成存储的碎片化，从而导致写放大和读放大。

填充因子

填充因子（Factor Fill）是一种常见的优化方法，它的原理就是在页表中预留一些空间，这样不会因为少量的数据写入造成树结构的大幅变动。但填充因子的设置也很难拿捏，过大则无法解决写放大问题；过小会造成页表数量膨胀，增大对磁盘的扫描范围，降低查询性能。

相对于 PGXC，NewSQL 风格分布式数据库的底层存储引擎则主要采用 LSM-Tree。

LSM-Tree

LSM-Tree（Log Structured-Merge Tree）由 Patrick O’Neil 在 1996 年的同名论文中首先提出。而后 Google 在Bigtable（Fay Chang et al.(2008)）中使用了这个模型，它的大致处理过程如下图所示：

引自Fay Chang et al.(2008)

系统接收到写操作后会记录日志（Tablet Log）并将数据写入内存（Memtable），这时写操作就可以返回成功了。而在系统接收到读操作时，会在内存和磁盘文件中查找对应的数据。

你肯定会说不对呀，如果写入的数据都在内存中，磁盘文件又是哪来的呢？

的确，这中间还少了重要的一步，就是数据落盘的过程，这也是 LSM-Tree 最有特点的设计。其实，LSM 是分成三步完成了数据的落盘。

第一步已经说过了，就是写入 Memtable，同时记录 Tablet Log；

当 Memtable 的数据达到一定阈值后，系统会把其冻结并将其中的数据顺序写入磁盘上的有序文件（Sorted String Table，SSTable）中，这个操作被称为 Flush；当然，执行这个动作的同时，系统会同步创建一个新的 Memtable，处理写入请求。

根据第二步的处理逻辑，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 的三个角度来分析下：

读放大

执行读操作时，由于单个 SSTable 内部是按照 Key 顺序排列的，那么查找方法的时间复杂度就是 O(logN)。因为 SSTable 文件是按照时间间隔产生的，在 Key 的范围上就会存在交叉，所以每次读操作都要遍历所有 SSTable。如果有 M 个 SSTable，整体时间复杂度就是 O(MlogN)。执行 Compact 后，时间复杂度降低为 O(log(MN))。在只有一个 SSTable 时，读操作没有放大。

写放大

Compact 会降低读放大，但却带来更多的写放大和空间放大。其实 LSM 只是推迟了写放大，短时间内，可以承载大量并发写入，但如果是持续写入，则需要一部分 I/O 开销用于处理 Compact。

你可能听说到过关于 B+Tree 和 LSM 的一个争论，就是到底谁的写放大更严重。如果是采用 Tiered 策略，LSM 的写放大比 B+Tree 还严重。此外，Compact 是一个很重的操作，占用大量的磁盘 I/O，会影响同时进行的读操作。

空间放大

从空间放大的角度看，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 也采用这个策略。

接下来，我们详细说明一下这个处理过程。

第一步处理 Leveled 和 Tiered 是一样的。当内存的数据较多时，就会 Flush 到 SSTable 文件。对应内存的这一层 SSTable 定义为 L0，其他层的文件我们同样采用 Ln 的形式表示，n 为对应的层数。因为 L0 的文件仍然是按照时间顺序生成的，所以文件之间就可能有重叠的 Key。L0 不做整理的原因是为了保证写盘速度。

通常系统会通过指定 SSTable 数量和大小的方式控制每一个层的数据总量。当 L0 超过预定文件数量，就会触发 L0 向 L1 的 Compact。因为在 L0 的 SSTable 中 Key 是交叉的，所以要读取 L0 的所有 SSTable，写入 L1，完成后再删除 L0 文件。从 L1 开始，SSTable 都是保证 Key 不重叠的。

随着 L1 层数据量的增多，SSTable 可能会重新划分边界，目的是保证数据相对均衡的存储。

由于 L1 的文件大小和数量也是受限的，所以随着数据量的增加又会触发 L1 向 L2 的 Compact。因为 L1 的文件是不重叠的，所以不用所有 L1 的文件都参与 Compact，这就延缓了磁盘 I/O 的开销。而 L2 的单个文件容量会更大，通常从 L1 开始每层的存储数据量以 10 倍的速度增长。这样，每次 Ln 到 L(n+1) 的 compact 只会涉及少数的 SSTable，间隔的时间也会越来越长。

说完处理流程，我们再从 RUM 三个角度来分析下。

读放大

因为存在多个 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 是层数。

写放大

Leveled 策略对写放大是有明显改善的，除了 L0 以外，每次更新仅涉及少量 SSTable。但是 L0 的 Compact 比较频繁，所以仍然是读写操作的瓶颈。

空间放大

数据在同层的 SSTable 不重叠，这就保证了同层不会存在重复记录。而由于每层存储的数据量是按照比例递增的，所以大部分数据会存储在底层。因此，大部分数据是没有重复记录的，所以数据的空间放大也得到了有效控制。

分布式数据库的实现

到这里我们已经介绍了存储结构的实现原理和它们在单体数据库中的运转过程。在分布式数据库中，数据存储是在每个数据节点上各自进行的，所以原理和过程是完全一样的。因此，TiDB 和 CockroachDB 干脆直接使用 RocksDB 作为单机存储引擎。在这两个分布式数据库的架构中，RocksDB 都位于 Raft 协议之下，承担具体的数据落地工作。

他们为什么选择 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”（Lanyue Lu et al.(2016)）阐述了这种模型的设计思想。WiscKey 提出的改进是通过将 value 分离出 LSM-Tree 的方法来降低写放大。

WiscKey 的主要设计思想是，在 SSTable 的重写过程中，核心工作是对 Key 进行整理，保证多个 SSTable 的 Key 范围不重叠，且内部有序。而这个过程中 Value 的重写是没有太大价值的，而从实践看，Value 占用的存储空间远远大于 Key。这意味着大量的写操作和空间成本被浪费了。所以 WiscKey 提出将 Value 从 SSTable 中分离出来单独存储，这样就降低了写放大系数。

引自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 讲提到过的 OLAP 存储引擎 TiFlash 吗？由于实时分析的要求，它必须及时落盘来自 TiKV 的数据，同样需要很高的写入速度。

在 18 讲中并没对 TiFlash 展开说明，我想，现在你应该能够猜出 TiFlash 的关键设计了吧。是的，高效写入的秘密就在于它的存储模型 Delta Tree 采用了类似 LSM 的结构。其中的 Delta Layer 和 Stable Layer，分别对应 LSM Tree 的 L0 和 L1，Delta Layer 可以顺序写入。

小结

那么，今天的课程就到这里了，让我们梳理一下这一讲的要点。

RUM 猜想提出读负载、写负载、空间负载这三者之间只能优化两个目标，另外一个目标只能被弱化。在典型数据结构中，B+Tree 是读取优化，LSM 是写入优化，这两种存储结构正好对应了两种风格分布式数据库的存储引擎。

PGXC 和单体数据库采用了 B+Tree，随机写会带来页表分裂和存储不连续，导致写放大和读放大。常用的优化方式是通过填充因子预留页空间。LSM 将随机写转换为顺序写提升了写入速度，但只是延缓了写放大并没有真正减少写放大。如果采用 Tiered 策略，LSM 的写放大和空间放大都高于 B+Tree。不过，LSM 可以很好地控制读放大，读操作的时间复杂度是 O(logN)。

Level 策略，降低了写放大和空间放大，同时读操作的成本也没有大幅增加，是一个比较广泛使用的存储模型。RocksDB 采用了这种存储模型，加上优秀的工程实现，被很多软件引用。TiDB 和 CockroachDB 也使用 RocksDB 作为单机存储引擎。OceanBase 在工程层面对 LSM 进行了优化。

WiscKey 是对 LSM 的理论模型优化，通过 Key/Value 分离存储的方式降低写放大，但这种设计会增加随机读写，要以使用 SSD 为前提。TiDB 受到 WiscKey 的启发，自研了 TiTan。

CockroachDB 也推出了自己的存储引擎 Pebble，主要目的是解决工程问题，包括解决 CGo barrier 问题和避免 Rust 功能膨胀带来的变更风险。

这一讲，我们简单介绍了近年来的主流存储模型，不难发现 LSM 占据了主导位置。但这并不代表 B+Tree 是一种失败的设计，两者只是在 RUM 之间选择了不同的优化方向。同时，是否有可靠的开源工程实现也直接影响到了对应存储模型的流行度，这一点和 Raft 协议的大规模使用很相似。

我的一点建议是，记下这些存储模型虽然重要，但更加关键的是对 RUM 猜想的深入思考，因为架构设计的精髓就在于做出适当的取舍。

思考题

课程的最后，我们来看看今天的思考题。今天的课程中，我们介绍了不同的存储模型，其中也提到了 Bloom Filter 的使用。它是一种神奇的数据结构，在 RUM 中是空间优化的典型，用很少的空间就可以记录很多数值是否存在。这种数据结构提供了两种操作方式，用于数值的写入和数值的检核，但数据库查询更多是范围查询，在 K/V 中体现为 Scan 操作，显然不能直接对应。所以我的问题是，Scan 操作是否可以使用 Bloom Filter 来加速，如果可以又该如何设计呢？

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对存储引擎这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

Fay Chang et al.: Bigtable: A Distributed Storage System for Structured Data

Lanyue Lu et al.: WiscKey: Separating Keys from Values in SSD-conscious Storage

Manos Athanassoulis et al: Designing Access Methods: The RUM Conjecture

Patrick O’Neil et al.: The Log-Structured Merge-Tree (LSM-Tree)

23 | 数据库查询串讲：重难点回顾+思考题答疑+知识全景图

你好，我是王磊。

这一讲是我们课程的第三个答疑篇，我会和你一起回顾第 16 讲到 22 讲的主要内容，这部分内容是围绕着数据库的“查询”展开的。同时，我也会集中解答留给大家思考题，并回复一些大家关注的热点内容。

第 16 讲：慎用存储过程

第 16 讲，我首先分享了自己职业生涯中的一个小故事，说的是如何用 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 讲的核心内容是自增主键的使用，它是一个具体的特性，因为要依赖全局性约束，所以很有代表性。我们首先介绍了 MySQL 的自增主键，很多同学会认为它能够保证单调递增，但如果发生事务冲突时，自增主键是会留下空洞的。

而且，当并发很大时，也不能直接使用 MySQL 的自增列，因为会成为性能瓶颈。然后，我介绍了如何使用 Oracle 的 Sequence 配合应用系统共同支持海量并发。

在分布式数据库下，自增主键与 Range 分片共用会引发“尾部热点”问题，我们用 CockroachDB 与 YugabyteDB 的性能测试数据来验证这个判断。因为 Range 分片是一种普遍的选择，所以通常是舍弃自增主键转而用随机主键替换，仅保证 ID 的唯一性。具体技术方案分为数据库内置和应用系统外置两种，内置方案包括 UUID 和 Random，外置方案包括 Snowflake 算法。

这一讲的是思考题是“使用 Range 分片加单调递增主键会引发‘尾部热点’问题，但是使用随机主键是不是一定能避免出现‘热点’问题？”

答案是，随机主键可能会出现热点问题。因为按照 Range 分片原理，一张数据表初始仅有一个分片，它的 Key 范围是从无穷小到无穷大。随着数据量的增加，这个分片会发生分裂（Split），数据存储才逐渐散开。这意味着，在一段时间内，分片数量会远小于集群节点数量时，所以仍然会出现热点。

解决的方法就是采用预分片机制（Presplit），在没有任何数据的情况下，先初始化若干分片并分配不同的节点。这样在初始阶段，写入负载就可以被分散开，避免了热点问题。目前 Presplit 在分布式键值系统中比较常见，例如 HBase，但不是所有的分布式数据库都支持。

第 18 讲：HTAP VS Kappa

在第 18 讲中，我们讨论了 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 讲中，我们谈的核心内容是计算下推，这是计算存储分离架构下主要的优化方法。

计算下推又可以细分为很多场景，比较简单的处理是谓词下推，就是将查询条件推送到数据节点执行。但在不同的架构下实现难度也有差异，比如 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 讲，我们讨论了关联查询（Join）的实现方案。关联查询是数据库中比较复杂的计算，经典算法分为三类，嵌套循环、排序归并和哈希。这三类算法又有一些具体的实现，我们依次做了介绍，其中哈希算法下的 Grace 哈希已经有了分布式执行的特点。

有了算法的基础，我又从分布式架构的角度讨论了关联查询的实现。首先涉及到并行执行框架的问题，多数产品的执行框架比较简单，只能做到计算下推这种比较简单的并行。因为数据节点之间是不通讯的，所以计算节点容易成为瓶颈。另外一些产品，比如 OceanBase 和 CockroachDB 引入了类似 MPP 的机制，允许数据节点之间交换数据。

我们把关联查询这个问题聚焦到大小表关联和大表关联两个场景上。大小表关联的解决方案是复制表方式，具体又包括静态和动态两种模式。大表关联则主要通过数据重分布来实现，这个过程需要数据节点之间交换数据，和上一段的并行执行框架有很密切的关系。

这一讲的思考题是“当执行 Hash Join 时，在计算逻辑允许的情况下，建立阶段会优先选择数据量较小的表作为 Inner 表，我的问题就是在什么情况下，系统无法根据数据量决定 Inner 表呢？”

选择数据量较小的作为 Inner 表，这是典型的基于代价的优化，也就是 CBO（Cost Based Optimizer），属于物理优化阶段的工作。在这之前还有一个逻辑优化阶段，进行基于关系代数运算的等价转化，有时就是计算逻辑限制了系统不能按照数据量来选择 Inner 表。比如执行左外连接（Left Outer Join），它的语义是包含左表的全部行（不管右表中是否存在与它们匹配的行），以及右表中全部匹配的行。这样就只能使用右表充当 Inner 表并在之上建哈希表，使用左表来当 Outer 表，也就是我们的驱动表。

第 21 讲：查询执行引擎的三个模型

第 21 讲，我们的关键词是查询执行引擎，它的责任是确保查询计划能被快速执行，而执行速度则取决于引擎采用的执行模型。执行模型分为三种，火山模型、向量化模型和代码生成。

火山模型是多数数据库使用的模型，有 20 年的历史，运行非常稳定。火山模型由一组运算符嵌套组成，运算符之间低耦合，通用性高，但它的缺点是无法使用现代 CPU 的特性，尤其是虚函数过多。

向量化模型将运算符的输入从行集合变成向量块，减少了调用虚函数的次数，也提高了 CPU 使用效率。

代码生成的逻辑则是通过编译器生成针对性的代码，从根本上解决虚函数过多的问题。

向量化模型和代码生成是现代高效查询模型的代表，已经获得越来越多认可，在很多数据库中被使用。TiDB 和 CockroachDB 都进行了向量化改造，而 OceanBase 也实现了表达式级别的代码生成。

这一讲的思考题是“基础软件演进中一个普遍规律，每当硬件技术取得突破后就会引发软件的革新。那么，我的问题就是你了解的基础软件中，哪些产品分享了硬件技术变革的红利呢？”

就像问题中所说的，每次硬件技术的突破都会引发软件的革新，比如 VoltDB 出现的背景就是内存技术成熟，价格日益降低，即使使用内存作为主存储设备也有足够的商业竞争力。

通过这一讲，你应该已经了解到，查询引擎的优化就是围绕着现代 CPU 特性展开的。而在第 22 讲存储引擎部分，我介绍了 WiscKey 模型，它是伴随着硬盘技术的发展而提出的，具体来说就是 SSD 的技术。

SSD 的物理结构与传统的 HDD 非常不同，没有物理磁头，所以寻址成本更低对于随机写支持等更好，但是反复擦写却更影响 SSD 的使用寿命。WiscKey 模型就是基于适合这两个特性提出的。随着 SSD 价格逐步降低，未来很可能成为服务器的标准配置。

第 22 讲：存储引擎的优化选择

第 22 讲，我们主要谈的是存储引擎，也就是数据落盘的最后一步。

在开始的部分，我们先引入 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

24 | 全球化部署：如何打造近在咫尺且永不宕机的数据库？

你好，我是王磊，你也可以叫我 Ivan。

这一讲我们要聊的是“全球化部署”，其实这个词在我们的课程中已经出现很多次了。我猜说不定你心里一直有个问号：“全球化啊，这么高大上的东西和我有关系吗？”耐心看完这一讲，我相信你会有新的理解。

我们不妨给全球化部署起一个更接地气的小名，“异地多活”。怎么样，感觉亲切多了吧？全球化部署本质就是全球范围下的异地多活。总体上看，异地多活的直接目标是要预防区域级的灾难事件，比如城市级的断电，或是地震、洪水等自然灾害。也就是说，在这些灾难发生时，要让系统还能保障关键业务的持续开展。

因此，这里的“异地”通常是指除同城机房外，在距离较远的城市配备的独立机房，在物理距离上跳出区域级灾难的覆盖范围。这个区域有多大呢？从银行业的实践来看，两地机房的布局通常会部署在南北或者东西两个大区，比如深圳到上海，或者北京到武汉，又或者北京到西安，距离一般会超过 1000 公里。

对于银行业的异地机房建设，监管机构是有具体要求的，也就是大中型银行的“两地三中心”布局。而对于互联网行业来说，虽然没有政策性要求，但业务本身的高可用需求，也推动了头部公司进行相应的布局。

说完了“异地”这个概念，我们再来看异地多活是怎么回事。首先，异地多活是高可用架构的一种实现方式，它是以整个应用系统为单位，一般来说会分为应用和数据库两部分。

应用部分通常是无状态的，这个无状态就是说应用处理每个请求时是不需要从本地加载上下文数据的。这样启动多个应用服务器就没有什么额外的成本，应用之间也没有上下文依赖，所以就很容易做到多活。

数据库节点要最终持久化数据，所有的服务都要基于已有的数据，并且这些数据内容还在不断地变化。任何新的服务节点在接入这个体系后，相互之间还会存在影响，所以数据库服务有逻辑很重的上下文。因此数据库的多活的难度就大多了，也就产生了不同版本的解读。

单体数据库

数据库层面的异地多活，本质上是要实现数据库的高可用和低延迟，也就是我们在标题中说“永不宕机”和“近在咫尺”。即便是单体数据库时代的技术方案，也是朝着这个方向努力的，我们不妨先来看看。

异地容灾

异地容灾是异地多活的低配版，它往往是这样的架构。

整个方案涉及同城和异地两个机房，都部署了同样的应用服务器和数据库，其中应用服务器都处于运行状态可以处理请求，也就是应用多活。只有同城机房的数据库处于运行状态，异地机房数据库并不启动，不过会通过底层存储设备向异地机房同步数据。然后，所有应用都链接到同城机房的数据库。

这样当同城机房整体不可用时，异地机房的数据库会被拉起并加载数据，同时异地机房的应用切换到异地机房的数据库。

显然，这个多活只是应用服务器的多活，两地的数据库并不同时提供服务。这种模式下，异地应用虽然靠近用户，但仍然要访问远端的数据库，对延迟的改善并不大。在系统正常运行情况下，异地数据库并没有实际产出，造成了资源的浪费。

按照正常的商业逻辑，当然不能容忍这种资源浪费，所以有了异地读写分离模式。

异地读写分离

在异地读写分离模式下，异地数据库和主机房数据库同时对外提供服务，但服务类型限制为只读服务，但只读服务的数据一致性是不保证的。

当主机房完全不可用时，异地机房的运作方式和异地容灾模式大体是一样的。

读写分离模式下，异地数据库也投入了使用，不再是闲置的资源。但是很多场景下，只读服务在业务服务中的占比还是比较低的，再加上不能保证数据的强一致性，适用范围又被进一步缩小。所以，对于部分业务场景，异地数据库节点可能还是运行在低负载水平下。

于是，我们又有了进一步的双向同步模式。

双向同步

双向同步模式下，同城和异地的数据库同时提供服务，并且是读写服务均开放。但有一个重要的约束，就是两地读写的对象必须是不同的数据，区分方式可以是不同的表或者表内的不同记录，这个约束是为了保证两地数据操作不会冲突。因为不需要处理跨区域的事务冲突，所以两地数据库之间就可以采用异步同步的方式。

这个模式下，两地处理的数据是泾渭分明的，所以实质上是两个独立的应用实例，或者可以说是两个独立的单元，也就是我们第 1 讲说的单元化架构。而两个单元之间又相互备份数据，有了这些数据可以容灾，也可以开放只读服务。当然，这个只读服务同样是不保证数据一致性的。

可以说，双向同步是单元化架构和异地读写分离的混合，异地机房的资源被充分使用了。但双向同步没有解决一个根本问题，就是两地仍然不能处理同样的数据，对于一个完整的系统来说，这还是有很大局限性的。

分布式数据库

分布式数据库的数据组织单位是更细粒度的分片，又有了 Raft 协议的加持，所以就有了更加灵活的模式。

机房级容灾（两地三中心五副本）

比较典型的分布式数据库部署模式是两地三中心五副本。这种模式下，每个分片都有 5 个副本，在同城的双机房各部署两个副本，异地机房部署一个副本。

这个模式有三个特点：

异地备份

保留了“异地容灾”模式下的数据同步功能，但因为同样要保证低延迟，所以也做不到 RPO（Recovery Point Objective， 恢复点目标）为零。

容灾能力

如果同城机房有一个不可用或者是同城机房间的网络出现故障，异地机房节点的投票就会发挥作用，依然可以和同城可用的那个机房共同达成多数投票，那么数据库的服务就仍然可以正常运行，当然这时提交过程必须要异地通讯，所以延迟会受到一定程度影响。

同城访问低延迟

由于 Raft 或 Paxos 都是多数派协议，那么任何写操作时，同城的四个副本就能够超过半数完成提交，这样就不会因为与异地机房通讯时间长而推高数据库的操作延迟。

两地三中心虽然可以容灾，但对于异地机房来说 RPO 不为零，在更加苛刻的场景下，仍然受到挑战。这也就催生了三地五副本模式，来实现 RPO 为零的城市级容灾。

城市级容灾（三地五副本）

三地五副本模式是两地三中心模式的升级版。两个同城机房变成两个相临城市的机房，这样总共是在三个城市部署。

这种模式在容灾能力和延迟之间做了权衡，牺牲一点延迟，换来城市级别的容灾。比如，在北京和天津建立两座机房，两个城市距离不算太远，延迟在 10 毫秒以内，还是可以接受的。不过，距离较近的城市对区域性灾难的抵御能力不强，还不是真正意义上的异地。

顺着这思路，还有更大规模的三地五中心七副本。但无论如何，只要不放弃低延迟，真正异地机房就无法做到 RPO 为零。

无论是两地三中心五副本还是三地五副本，它们更像是单体数据库异地容灾的加强版。因为，其中的异地机房始终是一个替补的角色，而那些异地的应用服务器也依然花费很多时间访问远端的数据库。

这个并不满足我们“近在咫尺”的愿望。

架构问题

为什么会这样呢？因为有些分布式数据库会有一个限制条件，就是所有的 Leader 节点必须固定在同城主机房，而这就导致了资源使用率大幅下降。TiDB 和 OceanBase 都是这种情况。

Raft 协议下，所有读写都是发送到 Leader 节点，Follower 节点是没有太大负载的。Raft 协议的复制单位是分片级的，所以理论上一个节点可以既是一些分片的 Leader，又是另一些分片的 Follower。也就是说，通过 Leader 和 Follower 混合部署可以充分利用硬件资源。

但是如果主副本只能存在同一个机房，那就意味着另外三个机房的节点，也就是有整个集群五分之三的资源，在绝大多数时候都处于低负载状态。这显然是不经济的。

那你肯定会问，这个限制条件是怎么来的，一定要有吗？

其实，这个限制条件就是我们在第 5 讲说过的全局时钟导致的。具体来说，就是单时间源的授时服务器不能距离 Leader 太远，否则会增加通讯延迟，性能就受到很大影响，极端情况下还会出现异常。

增加延迟比较好理解，因为距离远了嘛。那异常是怎么回事呢？下面我们就来解释下。

我们把这种异常称为“远端写入时间戳异常”，它的发生过程是这样的：

C2 节点与机房 A 的全局时钟服务器通讯，获取时间。此时绝对时间（At）是 500，而全局时钟（Ct）也是 500。

A3 节点也与全局时钟通讯，获取时间。A3 的请求晚于 C2，拿到的全局时钟是 510，此时的绝对时钟也是 510。

A3 节点要向 R2 写入数据，这个动作肯定是晚于取全局时钟的操作，所以绝对时间来到了 512，但是 A3 使用的时间戳仍然是 510。写入成功。

轮到 C2 节点向 R2 写入数据，由于 C2 在异地，通讯的时间更长，所以虽然 C2 先开始写入动作的流程，但却落后于 A3 将写入命令发送给 R2，此时绝对时间来到了 550，而 C2 使用的时间戳是 500。A3 与 C2 都要向 R2 写入数据，并且是相同的 Key，数据要相互覆盖的。这时候问题来了，R2 中已经有了一条记录时间戳是 510，已经提交成功，稍后又收到了一条时间戳是 500 的记录，这是 R2 只能拒绝 500 的这条记录。因为后写入的数据使用更早的时间戳，整个时间线就会乱掉，否则读取的进程会先看到 510 的数据，再看到 500 的数据，数据一致性显然有问题。

这个例子说明，如果远端计算节点距离时钟节点过远，那么当并发较大且事务冲突较多时，异地机房就会出现频繁的写入失败。这种业务场景并不罕见，当我们网购付款时就会出现多个事务在短时间内竞争修改商户的账户余额的情况。

全球化部署

全球化部署的前提是多时间源、多点授时，这样不同分片的主副本就可以分散在多个机房。那么数据库服务可以尽量靠近用户，而应用系统也可以访问本地的分片主副本，整体效果达到等同于单元化部署的效果。

当出现跨多地分片参与同一个分布式事务的情况，全球化部署模式也可以很好地支持。由于参与分片跨越更大的物理范围，所以延迟就受到影响，这一点是无法避免的。还有刚刚提到的“远端写入时间戳异常”，因为每个机房都可以就近获得时钟，那么发生异常的概率也会大幅下降。

全球化部署模式下，异地机房所有的节点都是处于运行状态的，异地机房不再是替补角色，和同城机房一样提供对等的支持能力，所有机房同等重要。

在任何机房发生灾难时，主副本会漂移到其他机房，整个系统处于较为稳定的高可用状态。而应用系统通过访问本地机房的数据库分片主副本就可以完成多数操作，这些发生在距离用户最近的机房，所以延迟可以控制到很低。这样就做到了我们说的永不宕机和近在咫尺。

同城双机房

接下来，我们再谈一些例外情况，比如下面这种架构。

有了前面的铺垫，再看这种方式你可能觉得有点奇怪。主机房保留了过半的副本，这意味着即使是同城备用机房，也不能实现 RPO 为零。那么主备机房之间就退化成了异步复制，这不更像一个单体数据库的主备模式吗？这样部署的意图是什么呢？

有的运维同学给我解释了这个部署架构，这样可以保证其他机房不存在时，主机房能够单独工作。后来我发现持这种观点的并不是极少数。还有同学会提出来，当主机房只有少数副本时，是不是可以继续工作呢？如果对 Raft 协议有所了解，你会觉得这个要求不可思议，少数副本还继续工作就意味着可能出现脑裂，这怎么可以呢？这里的脑裂是指发生网络分区时，两个少数节点群仍可以保持内部的通讯，然后各自选出的 Leader，分别对外提供服务。

但是换个角度去想，你就会发现这些同学也有他的理由。既然我的数据还是完整的，为什么我不能提供服务呢？虽然，我损失掉了备份机房，但这不影响主机房的工作呀。

如果这个理由也成立，那么哪种选择是更优呢？别急，做判断前，我想和你分享一下对恶意攻击的理解。

恶意攻击

通常来说，架构设计的高可用都是面对正常情况的，机器、网络的不可用都是源于设备自身故障不是外力损坏。但是，有没有可能发生恶意攻击呢？

回到两地三中心的模式，如果三个机房之间的光纤网络被挖断，整个数据库就处于不可用状态，因为这时已经不可能有过半数的节点参与投票了。自然状态下，这个事情发生的概率太低了，三中心之间同时有三条路线，甚至有些机构为了提高安全性，会设置并行的多条线路。但是，如果真的是恶意攻击，多搞几台挖掘机就能让银行的系统瘫痪掉，这比黑客攻击容易多了，而且成本也很低的。

同城双机房的设计，其实一种比较保守的方案，它力图规避了主机房之外因素的干扰因素。为了系统平稳运行，甚至可以放弃 RPO 为零这个重要的目标。虽然我并不认为这是最优的方案，但也确实可以引发一些思考。

RPO 为零是一种保障手段，而持续服务才是目标。那么，也就不应该为了追求 RPO 为零这个手段，而让原本还能正常运行的服务终止掉。这是为了手段而放弃了目标，不就成了舍本求末吗？在必要的时候，还是要保证主要目标舍弃次要的东西。

所以，Raft 协议还需要一个降级机制，也就是说不一定要过半投票，仍然维持服务。类似这样的设计在有些分布式数据库中已经可以看到了。因此，我觉得三地五副本模式加上 Raft 降级，应该算是目前比较完善的方案了。

Follower Read

全球化部署还有一个远端读的性能问题。如果分片的主副本在主机房，而异地机房要读取这些数据，如何高效实现呢？读写分离当然可以，但这损失了数据一致性，在有的场景下是不能接受的。CockroachDB 目前支持的 Follower Read 虽然提升了性能，但也是不保证数据一致性的；而 TiDB 的 Follower 目前还不支持跨机房部署。

但是，这并不妨碍我们探讨未来的可能性，在第 22 讲我提出了一个思路就是利用 Raft 协议无“日志空洞”的特点，等到日志时间戳超过查询时间戳，数据就足够新了。但这仅限于写入操作密集的分片，如果分片上的数据比较冷，根本等不到时间戳增长，又该怎么办呢？

其实还有可优化的地方，那就是利用 Raft 协议的合并发送机制。事实上，在真正实现 Raft 协议时，因为每个 Raft 组单独通讯的成本太高，通常会将同一节点的多个 Raft 协议打包后对外发送，这样可以考虑增加其他分片的最后更新时间戳，再通过协议包的发送时间戳来判断包内分片的最新状态。由于节点级别的 Raft 协议是源源不断发送的，这样只要冷分片和热分片在同一个包内，就可以及时得到它的状态。

全服务的意义

异地多活的终极目标应该是让异地机房提供全服务，也就是读写服务。这样的意义在于让备用机房的设备处于全面运行的状态，这不仅提升了资源利用率，也时刻确保了各种设备处于可运行的状态，它们的健康状态是实时可知的。

而在异地容灾模式下，备机房必须通过定期演练来确认是可用的，这耗费了人力物力，但并没有转化为真正的生产力，而且仍然存在风险。演练的业务场景足够多吗？出现问题时，这个定期演练的系统真的能够顶上去吗？我想很多人心中或许也是一个问号。显然，一个时刻运行着的系统比三个月才演练一次的系统更让人放心。

所以，我认为一个具有全球化部署能力，或者说是能真正做到异地多活的分布式数据库，是有非常重要的意义的。

小结

那么，今天的课程就到这里了，让我们梳理一下这一讲的要点。

全球化部署就是全球范围下的异地多活。异地多活通常是指系统级别，包括了应用和数据库，难点在于数据库的多活。

单体数据库的异地多活主要有三个版本，异地容灾、异地读写分离和双向同步。但是都无法让应用在同城和异地同时操作相同的数据，没有解决数据库大范围部署的问题。

分布式数据库常采用的部署方式是两地三中心五副本，可以实现机房级别的容灾和异地备份数据但 RPO 不为零。在此基础上，还可以升级到三地三中心五副本，提供城市级别容灾，在邻近城市实现 RPO 为零。使用单点授时的分布式数据库，必须将所有分片的主副本集中在主机房，这一方面是由于访问全局时钟的通讯成本高，另外是为了避免异常现象。

如果从恶意攻击的角度看，基于 Raft 协议的多中心部署反而会带来数据库的脆弱性，因为机房间的通讯链路将成为致命的弱点。所以，应在一定条件下允许对 Raft 协议做降级处理，保证少数副本也可以对外提供服务。

缩短从节点读操作延迟对于异地多活也有重要的意义，目前尚没有完善的解决方案，我们探讨了一些优化的可能性。真正的异地多活必须是异地机房提供全服务，这样才能在本质上提升系统可用性，比定期演练更加可靠。

思考题

课程的最后，我们来看看今天的思考题。今天的课程中，我们提到了 Raft 协议降级处理，它允许数据库在仅保留少数副本的情况下，仍然可以继续对外提供服务。这和标准 Raft 显然是不同的，你觉得应该如何设计这种降级机制呢？

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对全球化部署或者异地多活这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

25 | 容灾与备份：如何设计逃生通道保证业务连续性？

你好，我是王磊，你也可以叫我 Ivan。

我们今天的关键词是“逃生通道”。在生活中，我们去任何一个公共场所，比如火车站、商场、写字楼，都能看安全出口或者紧急疏散通道的提示，这就是逃生通道。逃生通道的作用就是人们能够快速脱离危险的地方。而在系统领域，逃生通道是指让业务能够脱离已经不可用的原有系统，在一个安全的备用系统中继续运转。

可以说逃生通道就是系统高可用的一种特殊形式。它的特别之处在于，备用系统要提供差异化的、更高级别的可靠性。为什么备用系统能够提供更高级别的可靠性呢？这是主要由于它采用了异构方案。

对于分布式数据库来说，逃生通道存在的意义应该更容易理解。作为一种新兴的技术，分布式数据库还没有足够多的实践案例来证明自身的稳定性，即使是它本身的高可用架构也不足以打消用户的顾虑。这时候就需要设计一种异构的高可用方案，采用更加稳定、可靠的数据库作为备用系统。我们通常把这个备用数据库称为“逃生库”。

在分布式数据库的实践没有成为绝对主流前，逃生通道都是一个不容忽视的用户需求。它可以降低实施风险，打消用户的顾虑，减少新技术应用中遇到的各种阻力。

CDC

作为一个数据库的高可用方案，首先要解决的是数据恢复的完整性，也就是我们提过多次的 RPO。这就需要及时将数据复制到逃生库。通常异构数据库间的数据复制有三种可选方式：

数据文件

ETL（Extract-Transform-Load）

CDC（Change Data Capture）

数据文件是指，在数据库之间通过文件导入导出的方式同步数据。这是一种针对全量数据的批量操作，如果要实现增量加载，则需要在数据库表的结构上做特殊设计。显然，数据文件的方式速度比较慢，而且对表结构设计有侵入性，所以不是最优选择。

ETL 是指包含了抽取（Extract）、转换（Transform）和加载（Load）三个阶段的数据加工过程，可以直连两端的数据库，也可以配合数据文件一起用，同样必须依赖表结构的特殊设计才能实现增量抽取，而且在两个数据库之间的耦合更加紧密。

最后，CDC 其实是一个更合适的选择。CDC 的功能可以直接按照字面意思理解，就是用来捕获变更数据的组件，它的工作原理是通过读取上游的 redo log 来生成 SQL 语句发送给下游。它能够捕捉所有 DML 的变化，比如 delete 和 update，而且可以配合 redo log 的设置记录前值和后值。

相比之下，CDC 的业务侵入性非常小，不改动表结构的情况下，下游系统就可以准确同步数据的增量变化。另外，CDC 的时效性较好，对源端数据库的资源占用也不大，通常在 5%-10% 之间。

CDC 是常规的配套工具，技术也比较成熟，在第 18 讲的 Kappa 架构中很多流式数据的源头就是 CDC。CDC 工具的提供者通常是源端的数据库厂商，传统数据库中知名度较高的有 Oracle 的 OGG（Gold Gate）和 DB2 的 Inforsphere CDC。

确定了数据同步工具后，我们再来看看如何选择逃生库以及如何搭建整体逃生方案。

逃生方案

对于分布式数据库来说，要选择一个更加成熟、稳定的逃生库，答案显然就是单体数据库了。那具体要选择哪种产品呢？从合理性角度来说，肯定就要选用户最熟悉、最信任、运维能力最强的单体数据库，也就是能够“兜底”的数据库。

按照这个思路，这个异构高可用方案就由分布式数据库和单体数据库共同组成，分布式数据库向单体数据库异步复制数据。使用异步复制的原因是不让单体数据库的性能拖后腿。

这样，当分布式数据库出现问题时，应用就可以切换到原来的单体数据库，继续提供服务。

这个方案看似简单，但其实还有一些具体问题要探讨。

1. 日志格式适配

首先是单体数据库的日志适配问题。

逃生方案的关键设计就是数据异步复制，而载体就是日志文件。既然是异构数据库，那日志文件就有差别吧，要怎么处理呢？

还记得第 4 讲我介绍的两种分布式数据库风格吗？其中 PGXC 风格分布式数据库，它的数据节点是直接复用了 MySQL 和 PostgreSQL 单体数据库。这就意味着，如果本来你熟悉的单体数据库是 MySQL，现在又恰好采用了 MySQL 为内核的分布式数据库，比如 GoldenDB、TDSQL，那么这个方案处理起来就容易些，因为两者都是基于 Binlog 完成数据复制的。

而如果你选择了 NewSQL 分布式数据库也没关系。虽然 NewSQL 没有复用单体数据库，但为了与技术生态更好的融合，它们通常都会兼容某类单体数据库，大多数是在 MySQL 和 PostgreSQL 中选择一个，比如 TiDB 兼容 MySQL，而 CockroachDB 兼容 PostgreSQL。TiDB 还扩展了日志功能，通过 Binlog 组件直接输出 SQL，可以加载到下游的 MySQL。

如果很不幸，你选择的分布式数据库并没有兼容原有的单体数据库规范，也没有提供开放性接口，那么就需要做额外的转换工作。

2. 处理性能适配

第二个问题是性能匹配问题。用单体数据库来做逃生库，这里其实有一个悖论。那就是，我们选择分布式的多数原因就是单体不能满足性能需求，那么这个高可用方案要再切换回单体，那单体能够扛得住这个性能压力呢？

比如，我们用 MySQL+x86 来做 TiDB+x86 的逃生库，这个方案就显得有点奇怪。事实上，在分布式数据库出现前，性能拓展方式是垂直扩展，导致相当一部分对性能和稳定性要求更高的数据库已经切换到 Oracle、DB2 等商用数据库加小型机的模式上。所以，对于金融行业来说，往往需要用 Oracle 或 DB2+ 小型机来做逃生库。

当然，性能问题还有缓释手段，就是增加一个分布式消息队列来缓存数据，降低逃生库的性能压力。这样就形成下面的架构。

另外，有了分布式消息队列的缓冲，我们就可以更加方便地完成异构数据的格式转换。当然，日志格式的处理是没有统一解决方案的，如果你没有能力做二次开发，就只能寄希望于产品厂商提供相应的工具。

这样，完成了日志格式的适配，增加了消息队列做性能适配，逃生方案是不是就搞定了呢？

别急，还差了那么一点点。

3. 事务一致性

我们知道数据库事务中操作顺序是非常重要的，而日志中记录顺序必须与事务实际操作顺序严格一致，这样才能确保通过重放日志恢复出相同的数据库。可以说，数据库的备份恢复完全建立在日志的有序性基础上。逃生库也是一样的，要想准确的体现数据，必须得到顺序严格一致的日志，只不过这个日志不再是文件的形式，而是实时动态推送的变更消息流（Change Feed）。

如果一个单体数据库向外复制数据，这是很容易实现的，因为只要将 WAL 的内容对外发布就能形成了顺序严格一致的变更消息流。但对于分布式数据库来说，就是一个设计上的挑战了。

为什么这么说呢？

如果事务都在分片内完成，那么每个分片的处理逻辑和单体数据库就是完全一样的，将各自的日志信息发送给 Kafka 即可。

对于 NewSQL 来说，因为每个分片就是一个独立的 Raft Group，有对应的 WAL 日志，所以要按分片向 Kafka 发送增量变化。比如，CockroachDB 就是直接将每个分片 Leader 的 Raft 日志发送出去，这样处理起来最简单。

但是，分布式数据库的复杂性就在于跨分片操作，尤其是跨分片事务，也就是分布式事务。一个分布式事务往往涉及很多数据项，这些数据都可能被记录在不同的分片中，理论上可以包含集群内的所有分片。如果每个分片独立发送数据，下游收到数据的顺序就很可能与数据产生的顺序不同，那么逃生库就会看到不同的执行结果。

我们通过下面的例子来说明。

小明打算购买番茄银行的自有理财产品。之前他的银行账号有 60,000 元活期存款，他在了解产品的收益情况后，购买了 50,000 元理财并支付了 50 元的手续费。最后，小明账户上有 50,000 元的理财产品和 9,950 元的活期存款。

假设，番茄银行的系统通过下面这段 SQL 来处理相应的数据变更。

begin;
//在小明的活期账户上扣减50,000元，剩余10,000元
update balance_num = balance_num - 50000 where id = 2234;
//在小明的理财产品账户上增加50,000元
update fund_num = fund_num + 50000 = where id = 2234;
//扣减手续费50元
update balance_num = balance_num - 50 where id = 2234;
//其他操作
……
commit;

小明的理财记录和活期账户记录对应两个分片，这两个分片恰好分布在 Node1 和 Node2 这两个不同节点上，两个分片各自独立发送变更消息流。

在前面的课程中我们已经介绍过，同一个事务中发生变更的数据必定拥有用相同的提交时间戳，所以使用时间戳就可以回溯同一事务内的相关操作。同时，晚提交的事务一定拥有更大的时间戳。

那么按照这个规则，逃生库在 T1 时刻发现已经收到了时间戳为 ts2 的变更消息，而且 ts2 > ts1，所以判断 ts1 事务的操作已经执行完毕。执行 ts1 下的所有操作，于是我们在逃生库看到小明的活期账户余额是 10,000 元，理财账户余额是 50,000 元。

但是，这个结果显然是不正确的。在保证事务一致性的前提下，其他事务看到的小明活期账户余额只可能是 60,000 元或 9,950，这两个数值一个是在事务开始前，一个是在事务提交后。活期账户余额从 60,000 变更到 10,000 对于外部是不可见的，逃生库却暴露了这个数据，没有实现已提交读（Read Committed）隔离级别，也就是说，没有实现基本的事务一致性。

产生这个问题原因在于，变更消息中的时间戳只是标识了数据产生的时间，这并不代表逃生库能够在同一时间收到所有相同时间戳的变更消息，也就不能用更晚的时间戳来代表前一个事务的变更消息已经接受完毕。那么，要怎么知道同一时间戳的数据已经接受完毕了呢？

为了解决这个问题，CockroachDB 引入了一个特殊时间戳标志“Resolved”，用来表示当前节点上这个时间戳已经关闭。结合上面的例子，它的意思就是一旦 Node1 发出“ts1:Resolved”消息后，则 Node1 就不会再发出任何时间戳小于或者等于 ts1 的变更消息。

在每个节点的变更消息流中增加了 Resolved 消息后，逃生库就可以在 T2 时间判断，所有 ts1 的变更消息已经发送完毕，可以执行整个事务操作了。

小结

好了，今天的课程就到这里了，让我们梳理一下这一讲的要点。

分布式数据库作为一种新兴技术，往往需要提供充分可靠的备用方案，用于降低上线初期的运行风险，这种备用方案往往被称为逃生通道。逃生通道的本质是一套基于异构数据库的高可用方案。用来作为备库的数据库称为逃生库，通常会选择用户熟悉的单体数据库。

整套方案要解决两个基本问题，分别是日志格式的适配和性能的适配。如果逃生库与分布式数据库本身兼容，则日志格式问题自然消除，这大多限于 MySQL 和 PostgreSQL 等开源数据库。如果日志格式不兼容，就要借助厂商工具或者用户定制开发来实现。传统企业由于大量使用商业数据库，这个问题较为突出。性能适配是因为分布式数据库的性能远高于基于 x86 的单体数据库，需要通过分布式消息队列来适配，缓存日志消息。

因为分布式数据库的日志是每个分片独立记录，所以当发生分布式事务时，逃生库会出现数据错误。如果有严格的事务一致性要求，则需要考虑多分片之间变更消息的顺序问题。CockroachDB 提供一种特殊的时间戳消息，用于标识节点上的时间戳关闭，这样在复制过程中也能保证多分片事务的一致性。

逃生通道作为一个异构高可用方案，在我们日常的系统架构中并不常见。这是因为传统数据库的高可用方案已经有足够成熟，我们对它也足够信任。所以，那些 CDC 工具对单体数据库来说，也是一个有点边缘化的产品，甚至很多有经验的 DBA 也不熟悉。

CDC 的主要工作场景是为分析性系统推送准实时的数据变更，支持类似 Kappa 架构的整体方案。但是对于分布式数据库来说，CDC 成为了逃生通道的核心组件，远远超过它在单体数据库中的重要性。目前很多分布式数据库已经推出了相关的功能或组件，例如 CockroachDB、TiDB、GoldenDB 等。

思考题

课程的最后，我们来看下思考题。今天课程中介绍的逃生通道实际上是一个异构的高可用方案。整个方案由三部分构成，除了分布式数据库和单体数据库以外，还增加了 Kafka 这样的分布式消息队列，通过缓存变更消息的方式适配前两者的处理性能。

我们还说到，对于单个分片来说 WAL 本身就是有序的，直接开放就可以提供顺序一致的变更消息。我的问题是，单分片的变更消息流在这个异构高可用方案中真的能够保证顺序一致吗？会不会出现什么问题呢？

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对逃生通道这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

26 | 容器化：分布式数据库要不要上云，你想好了吗？

你好，我是王磊，你也可以叫我 Ivan。

今天，我想和你分享的话题是分布式数据库的容器化部署。当数据库遇到容器，我知道这一定是个很有争议的话题。但是，在容器化技术大规模落地的背景下，这也是一个无法回避的话题。

容器化技术可以将资源虚拟化，从而更灵活快速地调配。容器镜像为应用打包提供了完美的解决方案，也为 DevOps 理念的落地扫清了技术障碍。可以说，容器已经成为现代软件工程化的基础设施，容器化已经成为一个不可逆的发展趋势。

但是，具体谈到数据库的容器化，我们又有太多的纠结。常见的反对意见就是数据库因为有状态、高 I/O 消耗和稳定运行等要求，所以不适合容器化部署。

那么，随着技术的快速发展，这些理由是不是还成立呢？为了说清楚这个问题，我们先来介绍一些 Kubernetes 的基本概念。

Kubernetes 基本概念

Container

容器化就是将物理机划分为若干容器（Container），应用程序是直接部署在容器上的，并不会感知到物理机的存在。具体来说，这个容器就是 Docker，它使用的主要技术包括 Cgroup 和 Namespace。

Cgroup 是控制组群（Control Groups）的缩写，用来限制一个进程组能够使用的资源上限，包括 CPU、内存、磁盘、网络带宽等，本质上实现了资源的隔离。Namespace 修改了进程视图，使当前容器处于一个独立的进程空间，无法看到宿主机上的其他进程，本质上实现了权限上的隔离。这两项其实都是 Linux 平台上的成熟技术，甚至在 Docker 出现前已经被用在 Cloud Foundary 的 PaaS 平台上。

Docker 能够快速崛起的重要原因是，它通过容器镜像可以将文件系统与应用程序一起打包。这个文件系统是指操作系统所包含的文件、配置和目录。这样，就不会出现各种环境参数差异导致的错误，应用的部署变得非常容易，完美解决了应用打包的问题。

容器的本质是进程，但复杂的应用系统往往是一个进程组。如果为每个进程建立一个容器，那么容器间就有非常密切的交互关系，这样管理起来就更加复杂。

那么，有什么简化的办法吗？

Kubernetes 给出的答案就是 Pod。

Pod

在 Kubernetes 的设计中，最基本的管理单位是 Pod，而不是容器。

Pod 是 Kubernetes 在容器之上的一层封装，它由运行在同一主机上的一个或者多个容器构成。而且，同一个 Pod 中的容器可以共享一个 Network Namespace 和同一组数据卷，从而高效的信息交换。因为具有了这些特性，Pod 通常会被类比为物理机。

Pod 存在的意义还在于可以屏蔽容器之间可能存在依赖关系，这样做到 Pod 在拓扑关系上的完全对等，调度起来更加简单。

那么 Pod 又是如何被管理的呢？这就要说到 Kubernetes 的整体架构。

整体架构

总体上，Kubernetes 是一个主从结构（Master-Slave）。其中 Master 是一个控制面板，每个 Salve 就是物理节点上代理 Kubelet，Master 与 Kubelet 通讯完成对物理节点上 Pod 和 Container 的管理。外部网络可以直接通过节点上的 Proxy，调用容器中的服务，不需要经过 Master。

Master 内部又包括 API Server、Scheduler、Controller 和 ETCD 四个部分，它们的职责分工和这一讲的内容没有直接关系，为了降低学习难度，这里就不做具体介绍了。如果你想再深入了解 Kubernetes 相关知识，可以在张磊老师的专栏找到更多资料。

有状态服务（StatefulSet）

对于无状态服务的管理，用户可以定义好无状态服务的副本数量，Kubernetes 调度器就会在不同节点上启动多个 Pod，实现负载均衡和故障转移。多个副本对应的 Pod 是无差别的，所以在节点出现故障时，可以直接在新节点上启动新的 Pod 替换已经失效的 Pod。整个过程不涉及状态迁移问题，管理起来很简单。

但是，作为应用系统的基石，数据库和存储系统都是有状态服务。如果将它们排除在外，那容器化的价值将大打折扣。所以，Kubernetes 在 V1.3 版本开始推出 PetSet 用于管理有状态服务，并在 V1.5 版本更名为 StatefulSet。

通常，我们说数据库是一个有状态的服务，是指服务要依赖持久化数据，也就是存储状态。而对于 StatefulSet 来说，状态又分为两部分，除了存储状态，还有系统的拓扑状态。

拓扑状态

拓扑状态是指集群内节点之间的关系，在 Kubernetes 中就是 Pod 之间的关系。

对于应用服务器来说，因为它们互相之间是不感知、不依赖的，所以可以随意创建和销毁。但数据库就不一样了，即使是单体数据库，也会有主从复制的关系，从节点是依赖于主节点的。

而分布式数据库的节点角色更加复杂。除了 CockroachDB 这样的 P2P 架构，多数分布式数据库中的节点拓扑关系是不对等的，会明确的划分为计算节点、数据节点、元数据和全局时钟等。

拓扑状态管理的第一步是标识每个 Pod。我们已经知道，Pod 随时会被销毁、重建，而重建之后 IP 会发生变化，这将导致无法确定每个 Pod 的角色。StatefulSet 采用记录域名的方式，再通过 DNS 解析出 Pod 的 IP 地址。这样 Pod 就具有了稳定的网络标识。

同时，为了确保体现 Pod 之间的依赖关系，StatefulSet 还引入顺序的概念，将 Pod 的拓扑状态，也就是启动顺序，按照 Pod 的“名字 + 编号”的方式固定了下来。

存储状态

数据库的核心功能是存储，我们再来看看如何实现存储状态的管理。

早期版本中，提供了两种存储方式，分别是本地临时存储和远程存储。顾名思义，本地临时存储中的数据会随着 Pod 销毁而被清空，所以无法用做数据库的底层持久存储。

远程存储可以保证数据持久化，所以是一种可选方案。具体来说，就是使用持久化卷（Persistent Volume）作为数据的存储载体。当 Pod 进行迁移时，对应的 PV 也会重新挂载，而 PV 的底层实现是分布式文件系统，所以新的 Pod 仍然能访问之前保存的数据。

远程存储虽然可以使用，但是它与分布式数据库的架构设计理念存在很大冲突。我们在课程中介绍过，分布式数据库都是采用本地磁盘存储的模式，并且对存储层做了很多针对性的优化。如果改为远程存储，这些设计将会失效，导致性能上有较大的损失。

事实上，这就是很多人反对数据库容器化部署的最主要的原因，数据密集型应用对 I/O 资源的消耗巨大，远程存储无法适应这个场景。

针对这个不足，Kubernetes 尝试引入本地持久卷（Local Persistent Volume）来解决。简单来说，就是一个 Local PV 对应一块本地磁盘。开启 Local PV 特性后，调度器会先获取所有节点与 Local PV 对应磁盘的关系，基于这些信息来调度 Pod。

与普通 PV 不同，当挂载 Local PV 的节点宕机并无法恢复时，数据可能就会丢失。这和物理机部署方案面临的问题是一样的，而分布式数据库的多副本机制正好弥补了这一点。

不过 Local PV 推出得较晚，直到 V1.10（2018 年发布）Kubernetes 才相对稳定地支持调度功能，在 2019 年 3 月的 V1.14 中正式发布。

通过对拓扑状态和存储状态的管理，StatefulSet 初步解决了有状态服务的管理问题，但其中的一些关键特性（比如 Local PV）还不够成熟。另外，由于 Kubernetes 的抽象程度比较高，所以在真正实现分布式数据库的部署还需要很多复杂的控制。

Operator

控制逻辑的复杂性是与具体软件产品相关的，比如一个常见的问题就是对于 Pod 状态的判断。

Kubernetes 判断节点故障依据是每个节点上的 Kubelet 服务上报的节点状态。但是，Kubelet 作为一个独立的进程，从理论上说，它能否正常工作与用户应用并没有必然联系。那么就有可能出现，Kubelet 无法正常启动，但对应节点上的容器还可以正常运行。

这时，如果不处置原来的 Pod，立即在其他节点创建新的 Pod，应用系统可能就会出现异常。所以，调度过程还需要结合应用本身的状态来进行，其中的复杂性无法被统一封装，于是也就催生了一系列的 Operator。

Operator 的工作原理就是利用了 Kubernetes 的自定义资源，来描述用户期望的部署状态；然后在自定义控制器里，根据自定义 API 对象的变化，来完成具体的部署和运维工作。简单来说，Operator 就是将运维人员对软件操作的知识代码化。

etcd-operator 是最早出现的 Operator，用于实现 etcd 的容器部署。Operator 封装了有状态服务容器化的操作复杂性，对于应用系统上云的有重要的意义，有的分布式数据库厂商也开发了自己的 Operator，比如 TiDB 和 CockroachDB。

小结

那么，今天的课程就到这里了，让我们梳理一下这一讲的要点。

容器可以更加精细的控制资源，并通过镜像功能实现应用打包部署，使开发、测试、生产等各个环境完美统一，因此得到了越来越广泛的使用。

容器是单进程模型，为了适应更复杂的应用，Kubernetes 又引入了 Pod 概念，一个 Pod 包含了同一节点上的多个容器。Kubernetes 通过调度管理 Pod 将复杂的应用系统快速容器化部署，这种技术称为“容器编排”。因为容器的本质是进程，所以 Kubernetes 也被成为下一代操作系统。

Kubernetes 早期以支持无状态服务为主，Pod 完全对等，调度过程简单。为了适应更复杂的应用部署模式，Kubernetes 也在不断完善对有状态服务的支持，使用 StatefulSet 对象封装了相应的功能，包括拓扑状态管理和存储状态管理。在 1.14 版本中发布了本地持久化卷特性，增强了存储状态管理能力。

对于分布式系统来说，有状态服务的管理非常复杂，通过简单的 Kubelet 无法把握各个服务的真实状态，所以就有了 Operator 这个扩展方式，每个产品厂商可以拓展自定义控制器，进行更有针对性的管理。目前，Operator 的接受程度在不断提高，TiDB、CockroachDB 都开发了自己的 Operator。

今天，容器化对于分布式数据库来说并不是一个默认选项。少数产品如 TiDB 和 CockroachDB 开始提供容器化部署方案，这和他们正在开展的云服务商业模式有比较直接的关系。而更多的分布式数据库仍然使用物理机，例如 OceanBase 和 GoldenDB 等。没有厂商的支持，依靠用户自身的力量实现容器化，还有比较大的困难。

我认为在未来的一两年里，“数据库是否要容器化部署”仍然是一个有争议的话题。但数据库的容器化趋势应该是确定的，因为 Kubernetes 提供支持越来越完善，真正大规模落地只是时间早晚的问题。而随着分布式数据库普及，数据库的节点规模必然会快速扩大，运维成本随之提升，这也给数据库容器化带来更强的推动力。

如果你想更积极地尝试数据库容器化，可以把大数据应用作为一个风向标。这是因为大数据应用的可用性要求低于 OLTP 场景，而集群规模更大，上百甚至上千节点都是正常规模。这意味着，大数据应用的容器化改造要承担的风险更小，但收益更大。

思考题

课程的最后部分是我们今天的思考题。今天的课程中，我们简要介绍了 Kubernetes 对有状态服务的支持情况，从中不难发现，资源调度是它非常核心的功能。我的问题是，除了 Kubernetes 你还知道哪些集群资源调度系统？它们在设计上有什么差异吗？

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对数据库的容器化部署这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

27 | 产品测试：除了性能跑分，还能测个啥？

你好，我是王磊，你也可以叫我 Ivan。

这一讲我们的关键词是“测试”。无论是作为程序员还是架构师，我们都不会忽视测试的重要性，它贯穿于软件工程的整个生命周期，是软件质量的重要保障手段。

不过，提到分布式数据库的测试，你也许会有些疑问，我又不是数据库研发人员，还要关心测试吗？

当然是需要了。比如，拿我来说，一名银行的科技人员。银行和很多传统企业一样，多数应用系统都是构建在商业软件之上，对于基础软件研发的投入比较有限，所以多数银行是不具备自研分布式数据库能力的。但是，分布式数据库的高并发、高可用性特点，意味着使用它的一定是非常重要和关键的业务系统。那么，为了保证系统的安全运行，即使不是开发者，我们也仍然需要做大量的验证和测试工作。

说到这，我猜你会想到一个词。对，就是 POC（Proof of Concept）。POC 的意思是概念验证，通常是指对客户具体应用的验证性测试。那验证性测试又具体要测些什么呢？对于数据密集型系统，很多企业的 POC 都会使用 TPC 基准测试。

TPC-C

TPC（Transaction Processing Performance Council），也就是国际事务性能委员会，是数十家会员公司参与的非盈利组织。它针对数据库不同的使用场景组织发布了多项测试标准，其中被业界广泛接受有 TPC-C 、TPC-H 和 TPC-DS。

这三个测试标准针对不同的细分场景。简单来说，TPC-C 针对 OLTP 场景；TPC-H 针对 OLAP 场景；而更晚些时候推出的 TPC-DS，在 TPC-H 的基础上又针对数据仓库的建模特点做了更新，并且在 2.0 版本中又增加对大数据技术的针对性测试。

这里，因为我们讲的分布式数据库主要服务于 OLTP 场景，所以我们重点关注 TPC-C。

TPC-C 发布的标准规范中，模拟了一家大型电子商务网站的日常业务。根据规范中的背景设定，这家公司的业务覆盖了很大的地理范围，所以设立了很多的仓库来支持邻近的销售区域，每个仓库都要维护 100,000 种商品的库存记录并支持 10 个销售区域，每个销售区域服务 3,000 个客户。

引自 TPC-C 标准规范（Revision 5.11）

这个场景对应的数据库模型一共包含 9 张表，覆盖了订单创建、支付、订单状态查询、发货和检查库存等五种事务操作。客户会查询已经存在订单的状态或者下一个新的订单。平均每个订单有 10 个订单行（Order-Line），有 1% 订单行的商品在其对应的仓库中没有存货，必须由其他区域的仓库来供货。

引自 TPC-C 标准规范（Revision 5.11）

可以看出，TPC-C 模拟的整个业务场景和我们日常使用的电子商务网站是非常相似的。所以说，TPC-C 测试场景是很有代表性的 OLTP 业务。

TPC-C 为数据库测试提供了一个开放的测试标准，很多 POC 甚至会直接套用这些数据模型和事务操作。可 POC 做起来真有这么容易吗？

如果你实际组织过 POC，就肯定听到过类似这样的一些说法：

“A 公司的数据库是针对 TPC-C 做了优化的，只是测试分数高，实际用起来不行。”

“B 公司的数据库为某个查询语句设置了缓存，这样的测试对我们不公平。”

总之，就是友商为刷高分做了优化，有作弊的嫌疑。

这时，作为组织者该怎么处理呢？可以用一些管理上的办法去协调，但真正解决问题的只有一个，就是要对产品架构有深入的了解，这样才能判断特定的优化措施在自己的真实业务场景下是否普遍有效。如果是普适的，那自然就没问题。

你看，要做好甲方工程师，也是有要求的。

TPC-C 的测试用例除了性能测试，也包含了事务一致性的测试，但实际测试中这部分往往会被忽略。这一方面是为了简化测试过程，另一方面是因为大家会觉得没有必要。既然这些产品都有不少实际案例了，那事务一致性应该就没问题了吧。

可是，对于分布式数据库来说，这真不是个简单的事情，甚至要更加严谨的技术手段来证明。那么，事务一致性方面有没有比 TPC-C 更权威的测试标准呢？

当然有了，这就是 Jepsen。

Jepsen

这个名字是不是有点耳熟？其实我们在第 3 讲介绍事务一致性时就提到过它。Jepsen 是一个开源的分布式一致性验证框架，专门用来测试分布式存储系统，比如分布式数据库、分布式键值系统和分布式消息队列等等。

Jepsen 曾经对很多知名的分布式存储系统进行了测试，而且往往都会发现一些问题，其中分布式数据库就包括 CockroachDB、YugabyteDB、TiDB、VoltDB 和 FaunaDB 等。以下是摘自Jepsen 官网的所有测试系统列表。

引自Jepsen官网

要知道这些测试并不是 Jepsen 单方面开展的，而都是和产品团队共同协作完成的，并且这些产品厂商还要向 Jepsen 支付费用。由此可见，Jepsen 在分布式系统测试方面，已经具有一定的权威性。

作为开源软件，你可以从 Github 上下载到Jepsen 的源码，所以有些厂商就在它的基础上定制自己的测试系统。但是比较遗憾的是，Jepsen 的作者选择了一种小众的开发语言 Clojure。我猜，这给多数程序员带来了障碍，因为我能想到的 Clojure 项目似乎只有 Storm。

这里，我们简单介绍下 Jepsen 的架构。

按照 Jepsen 的推荐方案，被测试的分布式系统通常部署在 5 个节点上，而 Jepsen 的程序主要部署在另外的控制节点上。这个控制节点会初始化若干个进程作为分布式系统的访问客户端，当然这里也包括了分布式系统提供的客户端代码。

测试过程中，控制节点要完成三项工作，第一是通过 Generator 生成每个客户端的操作，第二是通过 Nemesis 实现故障注入，最后使用 Checker 分析每个客户端的操作记录来验证一致性。

在整个测试框架中，Nemesis 是特别重要的部分，这是因为 Jepsen 的核心逻辑就是要在各种错误情况下，检测分布式系统还能否正常运行。

“故障注入”在普通测试中并不常见，这里的故障是特指网络分区、时钟不同步这样的底层基础设施层面的问题。因为分布式系统的架构复杂，节点间有千丝万缕的联系，任何软硬件基础设施的错误都可能造成不可收拾的后果，但业务逻辑层面的测试用例又无法覆盖这类场景，所以要靠 Jepsen 来填补这块空白。

说到这，你或许会问，既然故障注入这么重要，那么 Jepsen 注入的这些故障就够了吗？我们是不是要按照自己的业务场景增加一些故障呢？

嗯，不少人也有类似的想法。这就要说一下混沌工程的概念了。

混沌工程

混沌工程（Chaos Engineering）最早是由 Netflix 工程师提出来的。他们给出了这样的定义：混沌工程是在分布式系统上进行实验的学科, 旨在提升系统的容错性，建立对系统抵御生产环境中发生不可预知问题的信心。

我们可以从三个层面来理解这个定义。

复杂性

首先，分布式系统的复杂性是混沌工程产生的基础。相比传统的单体系统，分布式系统中包含更多硬件设备，多样化的服务和复杂交互机制。这些因素单独来看似乎是可控的，也有完备的异常处置手段，但当它们组合在一起就会相互影响从而引发不可预知的结果，导致故障发生。而人力是不可能完全阻止这些故障。

混沌工程就是在这些故障发生前，尽可能多的识别出导致这些异常的因素，主动找出系统脆弱环节的方法学。

实验

第二关键点是实验。混沌工程与单纯的故障注入是有区别的，混沌工程的输入是尝试性，目的是探索更多可能发生的奇怪场景，促使正常情况下不可预测的事情发生，从而确认系统的稳定性。我想，正是因为结果具有很大的不确定性，这个过程才会称为“实验”。

最早的混沌测试工具是 Netflix 的 Chaos Monkey，它只会注入一种混乱，那就是随机杀死节点。后来逐步发展，混沌测试框架引入的故障越来越多，包括模拟网络通讯延迟、磁盘故障、CPU 负载过高等等。而混沌测试的观察对象也不仅是一致性（像 Jepsen 那样），而是从系统的各个维度上定义一系列稳态指标，观察混乱注入后系统是否能够快速恢复。

生产环境

第三点，也是混沌工程非常核心的理念，混沌实验在生产环境进行才会获得更大的价值，因为这样才能真正建立起信心，相信系统能抵御各种故障。不过，这个理念也有一定的争议。比如，你今天坐飞机出差，这时混沌工程师要在飞机的控制系统上注入一些故障，想看看系统会不会崩溃，你能接受吗？我想，正常人都会拒绝吧。

显然，对真实业务造成什么后果是做出判断的依据。虽然混沌工程还有一个爆炸半径理念，要限定对生产环境的影响。但是，在生产环境注入混乱来验证系统稳定性的这个理念，对哪些行业适用，进一步又对哪些业务适用，我觉得还是有待探讨的话题。

目前，一些分布式数据库也应用混沌工程进行系统测试，例如 GoldenDB、CockroachDB 和 TiDB。

到这里，对于测试这个话题，我们已经谈了很多，但其实还漏掉了很重要的一点。你能猜到是什么吗？别着急，让我先给你讲一个小故事。

TLA

在前面的课程中，我曾提到过我设计的一款软件 Pharos。它有一个试验特性是在写入数据时，始终保持索引与数据的事务一致性，要知道它的底层是 HBase，本身是不支持跨行事务的，所以说实现这个特性还是有点难度的。

有一次在介绍 Pharos 时，一位同学问我，怎么证明 Pharos 实现了事务一致性呢？我列举了做过的很多破坏性测试，比如杀掉进程、直接重启服务器等等，这些都没影响到事务一致性。但是，讲完之后，我们两个人似乎都对这个答案不太满意。

后来，我就想，我的测试方法还能改进吗？再增加一些异常场景？可似乎都没有本质上的变化。你看，无论 TPC-C、Jepsen 还是混沌工程，虽然方法、理念各不相同，但是都有一个共同点，就是它们只能发现错误，却无法证明正确性。

换句话说，测试是在用证伪的方式来检查软件质量，但是对一个复杂系统来说，测试用例是无法穷尽的，那也就永远不能排除存在 Bug 的可能性。这个结论很让人沮丧，那么，有没有“证明”的方法呢？

方法也是有的，叫做形式化验证（Formal Verification），就是用数学方法去证明我们的系统是无 Bug 的，具体就是用数学工具进行定义、开发和验证（Specification, Development and Verification）。从一个更高阶的视角来看，不论硬件还是软件，归根结底是在解决数学问题。形式化验证的逻辑就是，如果能够按照严格的数学方法描述设计，那么结果的正确性就也是可以被证明。

但是，要把关键设计按照数学的方式表述一遍，这个实现成本就比较高。所以，形式化验证在软件领域并不常见，而是从硬件领域开始普及，比如 Intel 就在芯片设计中就广泛采用形式化方法。而后随着分布式系统的流行，形式化验证被应用的越来越多。

那么，形式化方法如何在软件工程落地呢？方法就是 Leslie Lamport 提出的 TLA（Temporal Logical of Actions，行为时态逻辑），对又是这位大神。TLA 就是使用数理逻辑来描述系统的时序状态，并验证程序的正确性。

1994 年 Lamport 发表了同名论文。1999 年 Lamport 又发表“Specifying Concurrent Systems with TLA+”论文，提出了 TLA+。TLA+ 是一种软件建模语言，再加上配套的模型校验工具 TLC，这样我们就可以像写程序一样编写 TLA，可以运行来验证最终结果的。2002 年 Lamport 又发布了一本完整的 TLA+ 教科书Specifying Systems: The TLA+ Language and Tools for Software Engineers。因为 TLA+ 使用的是数学化的表达方式，对程序员并不友好，所以后来又出现了 PlusCal。它比 TLA+ 更接近于编程语言，写好的代码可以很方便的转换成 TLA+ 并使用 TLA+ 的模型验证。

小结

好了，今天的课程就到这里了，让我们梳理一下这一讲的要点。

TPC-C 是国际事务性能委员会针对 OLTP 数据库建立的一套测试规范，也是目前广泛接受的测试基准。在很多企业的 POC 测试中会引入 TPC-C，但是由于 TPC-C 的开放性，有的产品会进行针对性优化，使得最终的评测指标失真。要能够分辨优化手段是否对你的业务有普适性，还需要对产品架构的深入掌握。

Jepsen 是针对分布式存储系统，进行数据一致性和事务一致性测试的工具。目前已经测试了很多知名系统，具有一定的权威性。Jepsen 通过故障注入的方式进行测试，会覆盖很多在普通测试不能发现的场景。

混沌工程是针对分布式系统提出的方法学。它和测试一样都是为了提高系统的可用性和稳定性，以故障注入为主要手段。混沌工程并不仅是针对某个具体分布式系统提出的。它强调在生产环境注入故障，在受控的范围内观测系统，体现了反脆弱的思想。总之，混沌工程试图从企业整体运维的视角，用截然不同的理念来提升系统的可用性。

所有的测试方法都只能发现问题，但无法证明正确性。形式化验证可以完美解决这问题，并在很多硬件设计领域有落地实践。Lamport 提出的 TLA 将形式化验证引入软件工程，使用数学工具定义程序逻辑，从而可以达到证明软件无 Bug 的目标。经过不断完善，TLA 从模型到语言、工具建立了一套完备的机制，很多企业也开始使用 TLA 证明关键设计逻辑的正确性。

今天的课程内容可以归结为测试和形式化验证两部分，我们也做了一些比对说明，但并不是说要用验证（Verification）来代替测试（Testing）。通过 TLA 可以验证程序逻辑是否正确，这是个了不起的成就，但是要用数学语言再翻写一遍，付出的成本太高。而且，实际工程中也不可能将所有的代码都转换为 PlusCal 或 TLA+ 来做全面的验证。更多情况下，只是 TLA 来验证关键设计逻辑，剩余的多数代码还是要靠测试来发现问题。总之，测试是不能被验证替代的。

思考题

课程的最后，我们来看下思考题。今天我们的关键词是“测试”，也谈了很多测试方法和理念。不难发现，这些规范和工具都伴随着分布式系统的普及演进。而分布式系统并不限于分布式数据库，其他类型的分布式存储系统也越来越多，所以单一面向数据库的测试工具已经不能满足要求。那么，我今天的问题就是，对于其他类型的分布式存储系统，你知道有哪些主流的测试工具吗？

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对数据库测试这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

GitHub: jepsen

Jepsen: Analyses

Leslie Lamport: A Temporal Logic of Actions

Leslie Lamport: Specifying Concurrent Systems with TLA+

Leslie Lamport: Specifying Systems: The TLA+ Language and Tools for Software Engineers

Transaction Processing Performance Council: TPC BENCHMARK™ C：Standard Specification (Revision 5.11)

28 | 选型案例：银行是怎么选择分布式数据库的？

你好，我是王磊，你也可以叫我 Ivan。

在前面的课程中，我们已经介绍了分布式数据库方方面面的知识。这些知识，我觉得大概会在三个方面帮到你，分别是数据库研发、架构思维提升和产品选型。今天，我会通过几家银行的案例带你了解如何做分布式数据库的选型。

为什么要讲银行的分布式数据库选型呢？

你肯定在想，这是因为王磊老师刚好是在银行工作，更熟悉这个行业。这么说也对，银行业确实是我最熟悉的行业。但是更重要的原因是，数据库作为一款基础软件，稳定性和可靠性就是它的立身之本。而对稳定性要求最严苛的行业，就是金融业，尤其是银行业。所以，只有经过金融场景的考验，一款数据库产品才能真正扬名立万，让其他行业的用户放心使用。

也是因为这个原因，不少厂商都打出了“金融级分布式数据库”的旗号。

分布式数据库的应用场景主要特征是海量并发，所以理论上说，业务规模越大，使用分布式数据库的需求也就越迫切。无论从用户数量还是资产规模，国内最大的银行肯定是工商银行。

工商银行（分布式中间件）

工行之前的数据库主要是 Oracle 和 IBM 的 DB2，这很代表性，过去的 20 年银行业基本上就是在这两种产品中二选一。

Oracle 和 DB2 都是单体数据库，只能采用垂直扩展方式，在碰到技术天花板后就会限制业务的发展。作为业务量最大的“宇宙行”，工行面对这个问题的选择是，从单体数据库向以 MySQL 为基础的分库分表方案转型。

为什么工行没有选择真正的分布式数据库呢？

我认为，其中主要原因是产品成熟度。工行的架构改造在 2018 年大规模落地，而调研和试点工作则在更早的 2016-2017 年。这个时点上，商用 NewSQL 数据库刚刚推出不久，而金融场景的种种严苛要求，注定了银行不会做第一个吃螃蟹的人，那么这种可能性就被排除了。同样，另一种 PGXC 风格的分布式数据库也正待破茧而出，反而是它的前身，“分布式中间件 + 开源单体数据库”的组合更加普及。

所以，对当时的工行来说，产品架构上是没有什么选择余地的，能做的就是选择产品。后来的结果是选择了 DBLE + MySQL 的组合，选择 MySQL 是因为它的普及程度足够高；而选择 DBLE 则因为它是在 MyCat 的基础上研发，号称是“增强版 MyCat”，由于 MyCat 已经有较多的应用案例，所以这一点给 DBLE 带来不少加分。

现在看来，这个方案显得有点平淡无奇。但正是这种稳妥，或者说有点保守的选择，最大程度规避了风险，也坚定了工行从主机下移应用系统的决心。从后来 MySQL 上千节点的使用规模看，这个方案更大的意义在于，使工行逐步脱离了对 IBM 主机和 Oracle 数据库的依赖。分布式数据库的尝试只是这个大目标下的副产品。

相对于 OLTP 技术应用上的平淡，工行在 OLAP 方面的技术创新则令人瞩目。基本是在同期，工行联合华为成功研发了 GaussDB 200，并在生产环境中投入使用。这款数据库对标了 Teradata 和 Greenplum 等国外 OLAP 数据库。在工行案例的加持下，目前不少银行计划或者正在使用这款产品替换 Teradata 数据库。

邮储银行（单元化）

邮政储蓄银行，简称邮储银行，由于特殊的历史沿革，它没有被归入通常所说“四大国有银行”，但这不意味着它的业务规模小。事实上，邮储银行的零售用户在 2019 年已经超过 6 亿。

这个庞大的用户基数使得邮储银行也早早地就开始探讨分布式数据库的使用。那他们采用了什么方案呢？

可能又让你失望了，他们也没有选择分布式数据库。邮储的核心业务系统改造方案更接近于单元化架构，所以连分布式中间件都没使用。它的设计思路就是将原来商业数据库拆分成若干个小的单体数据库，分别设置对应的应用实例。邮储在单体数据库上选择了 PostgreSQL，这个在银行业中是相对较少使用的。

当然，单元化方案从应用整体看也是一种分布式架构，通过应用层面的重构弱化了对数据库的性能和稳定性等方面的要求。说到这，你可能会有个疑问，如果两种方式都能解决问题，那要怎么在单元化和分布式数据库之间选择呢？

从成本上看，系统的单元化改造要付出巨大的代价，是一个推倒重建的过程，远高于过渡到分布式数据库的代价。我认为，邮储之所以会选择这个方式，可能有两个原因：

对数据库的技术把控能力。毕竟分布式数据库带来的技术挑战不容忽视。

核心系统本身重构的必要性。如果应用系统的分布式改造势在必行，那么两个方案的目标都截然不同，成本的比较也就无从谈起了。

同样，民生银行也是在核心系统改造的背景下，完成了向分布式架构的升级。在民生银行的一份宣传材料中对分布式技术平台做了整体描述，其中有两段是和数据库相关，是这么写的“通过分库分表和读写分离实现分布式数据访问功能；基于可靠消息的最终一致性和基于冲正模型的反向处理实现分布式事务功能”。

可见民生银行也选择了与邮储银行大致相同的路线，弱化了分布式数据库的作用，更加强调整体架构改造，在应用系统层面做了更多的工作。

好了，刚才我们说到的三家银行都采用了迂回的方案，下面终于要用到分布式数据库了。

交通银行（研发 NewSQL）

交通银行的分布式数据库之路走得比较特别，它采用联合高校研发的方式，与华东师范大学和西北工业大学共同研发了分布式数据库 CBase。

CBase 研发开始于 2014 年，在分布式数据库中算是非常早的了，但它的整体架构风格非常接近于 NewSQL。不同于前几家银行，CBase 并不是某个重要业务系统的附属品，已经有点技术驱动的味道。

它最先用于历史库系统的数据存储，而后逐步实现了复杂 SQL 语句处理和高并发事务处理能力，在供应链、贷记卡授权和网联支付等系统使用。

引自刘雷 等 (2019)

通过架构图，我们可以看到 CBase 的主要工作负载放在三类节点上，其中的数据存储节点与 NewSQL 风格完全一致，而剩下的 SQL 处理节点和事务处理节点，就是计算节点的细化。而且，CBase 也基于 Raft 协议设计和实现了轻量级分布式选举协议，分布式事务同样是在 2PC 上进行改进的。

这款数据库主要在交通银行内部使用，目前并没有看到其他的商业化案例，所以能够找到的资料比较少。不过，CBase 开发团队在 2019 年发表了一篇论文《分布式数据库在金融应用场景中的探索与实践》（刘雷 等 (2019)），介绍了 CBase 的部分设计，如果你有兴趣可以仔细研读下。

交通银行研发分布式数据库在银行业是很有代表性的，因为之前还很少有在基础软件上进行大规模投入的先例。同样在 2014 年，中信银行也走上了联合研发的道路。

中信银行（研发 PGXC）

终于要说到中信银行了，课程中我们已经多次讲过的 GoldenDB 就是中信银行与中兴通讯联合研发的产品，而目前 GoldenDB 主要用户也是中信银行。中信银行的核心业务系统在 2020 年 5 月正式上线切换到 GoldenDB。这之前，基于 GoldenDB 的信用卡新核心系统已经在 2019 年 10 月投产运行。这两个重要系统的运行，使中信银行无可争议地成为分布式数据库应用最为深入的一家银行。核心业务系统是银行业务的心脏，它的稳定运行无疑为其他银行树立了标杆，客观上也加快分布式数据库的普及速度。

GoldenDB 和 CBase 有大致相同的发展路径，从产品研发到试点应用，只不过中信银行的步子更快些。当然，这并不是说交行研发人员的能力不行，因为这个速度上的差别确实有架构上的因素。GoldenDB 是 PGXC 风格的分布式数据库，遇到的技术挑战更小；当然 NewSQL 架构上的优势，也让我们对 CBase 的未来充满期待。

你有没有注意到，还有一个很有意思的地方，同样是核心系统改造，为什么中信银行就使用了分布式数据库呢？

答案还是在于项目目标不同。中信的目标就是完成 AS400 小机下移，将应用程序翻写到开放平台，但是对应用架构本身并没有改造诉求。所以，数据库层面的平滑过渡就有很大的优势，编码逻辑改动小，测试成本低，最重要的是不会因为技术原因变动业务流程，这样就大大降低了项目的实施难度。

北京银行（NewSQL）

北京银行是城市商业银行中的佼佼者，但相对于前几家银行，在资产规模和用户数量上有较大的差距。北京银行从 2018 年开始，先后在网联支付系统和网贷系统中应用了 TiDB。

事实上，很多比北京银行规模更小的城市商业银行，比如南京银行（OceanBase）、张家港银行（TDSQL）都已经上线了分布式数据库。表面上，我们似乎很难捕捉到他们替换数据库的动因。从业务压力的角度，业务量通常没有达到海量并发级别；同时城商行通常也不涉及“主机下移”带来可用性下降问题。

那么，他们为什么要做出这个选择呢？我觉得主要有三点原因。

国产化的诉求

由于各种原因，继续依赖 Oracle 这样的国外商业产品，很可能让银行将面临更大的风险。而对于小型银行来说，使用开源数据库还是分布式数据库，在成本上可能差异并不大。

随着国内厂商加大技术投入，隐约有一种趋势，就是分布式数据库正在逐步成为国产数据库的代名词。那些原本深耕单体数据库技术的厂商，比如达梦、人大金仓，也在朝着分布式架构转型。

所以，选择分布式数据库也就满足了国产化的诉求。

实际收益

由于小型银行的数据量并不大，上线分布式数据库后集群的节点规模没有大幅增长，对运维的冲击也相对小些。此外，利用分布式数据库的多租户特性，转变成类似 Aurora 使用方式，还能降低数据库实例管理的复杂度。所以，使用分布式数据库也是有一些实际收益的。

技术潮流

这也是我在开篇词中说的，一旦技术的趋势形成，就会在无形中影响人们的选择。就像时尚潮流那样，在同等价位下，大家当然更愿意选那些流行款式。今天，分布式架构转型就是这样的潮流，微服务架构、分布式数据库甚至容器云都是这个大潮下的浪花。

在风险可控的前提下，受到技术潮流的影响，我觉得可能还会有更多的小型银行会选择分布式数据库。

光大银行（NewSQL & 分库分表）

下面，我来聊聊光大银行的分布式数据库实践。简单来说，光大使用了双路线策略，也就是同时使用 NewSQL 和分库分表方案。

在云缴费系统中，光大使用了自研的分库分表方案。首先，这个系统的业务量，也就是缴费的业务量是非常大的。可能你不知道，今天支付宝、微信甚至很多银行的缴费服务，在后台都是要调用光大的云缴费系统的。截至 2019 年，云缴费系统的累计用户达到了 5.49 亿。

其实，缴费业务是非常互联网化的业务，就是银行提供服务对接用户和缴费企业，所以它的业务模型比较简单和统一。这也意味着，它对分布式事务这样的复杂操作没有那么强烈的诉求。最后，用分库方案就很好地解决了海量业务的问题。

光大在另一个系统，新一代财富管理平台使用了 NewSQL 数据库，也就是 TiDB。这个系统是理财业务的全流程管理平台，业务量相对缴费要小很多，但业务要更复杂，而且在联机和批量方面都有计算需求。

这个架构选择更多是面向未来，因为理财业务是光大银行重点发力的业务，对未来业务量的增长还是有很大预期的。同时，我想，伴随着 NewSQL 技术的发展，保持团队对新技术的感知和掌握，应该也是一个重要的原因。

选型建议

好了，到这里已经介绍了六七家银行的选型情况，让我们稍稍总结一下。

产品选型要服从于项目整体目标

局部最优的选择拼装在一起未必是全局最优的方案。如果你的目标是要对整个应用系统做彻底重构，例如把单体架构改为微服务架构，那么要解决原来某些局部的问题，可能会有更多选择。这时候要从整体上评估技术复杂度、工程实施等因素，而不是仅选择局部最合理的方案。

先进的产品可能会延长项目交付时间

最先进的产品不一定是完美的选择。尤其是有进度要求时，往往会选择更稳妥、快速的办法。但是，这本质上是在短期利益和长期利益之间做权衡，没有绝对的对错，搞清楚你想要的是什么就行。

当产品选型可能导致业务流程变更时，请慎重对待

对任何项目来说，协作范围的扩大一定会增加实施难度。当技术部门对业务流程变更没有决定权时，我认为这是多数情况，通过技术手段避免这种变更往往是更好的选择。

产品选型中的非技术因素

正视非技术因素，评估它的合理性不是技术团队的职责。

评估技术潮流对选型影响

跟随潮流并不是人云亦云，你必须能够独立对技术发展趋势做出研判。太过小众的技术往往不能与工程化要求兼容。但同时，保持对新技术的敏感度和掌控力，也是非常必要的。

小结

那么，今天的课程就到这里了，让我们梳理一下这一讲的要点。

工商银行在主机应用下移的过程中，采用分布式中间件加 MySQL 的方式替换了原有的单体数据库。这个选择，一方面受制于当时分布式数据库的成熟度，另外这个方案的主要意义是大量使用了 MySQL 数据库，降低对主机和 Oracle 数据库的依赖，而分布式方案是一个副产品。

邮储银行和民生银行是在新一代核心系统建设的背景下进行分布式架构改造，所以他们有更多的项目目标，也能够承受更大的改造成本，这样分布式数据库能够平滑过渡、减少应用改造的优势也就不那么重要了。最终，两家银行都采用了类似单元化的架构，在应用层处理分布式事务等工作。

交通银行和中信银行都选择了自研方式。中信银行目前已经在核心业务系统上线 GoldeDB，能够更快上线的一个原因就是 PGXC 的架构复用了单体数据库，遇到的技术挑战更少。

北京银行和很多规模更小的城商行也在陆续上线分布式数据库。我认为他们的选择因素有三个，国产化诉求、实际收益和技术潮流。我预测，未来还有更多的小型银行将上线分布式数据库。

光大银行采用了双路线策略，同时采用分库分表方案和 NewSQL。这一方面因为不同系统的业务特点不同，另一方面也是要跟进 NewSQL 技术发展，保持对新技术的感知和应用能力。

今天的课程中，我简单总结几家银行的分布式数据库选型策略。相对于互联网行业，金融场景的苛刻要求，让银行更倾向于保守的策略，但是不难发现，总会有一些企业更具开创精神。我相信，随着分布式数据库的日益成熟，技术红利会驱使更多企业做出积极的尝试。事实上，就在我们课程更新的过程中，2020 年 9 月，工商银行宣布将在对公（法人）理财系统的主机下移方案中采用 OceanBase 数据库。你看，大象也要开始跳舞了。

思考题

课程的最后，我们来看下思考题。今天的课程中，我们简单介绍了几家有代表性的银行进行分布式数据库选型的情况，也给出了一些选型建议。其实，产品选型是系统建设中非常重要的工作，也是从程序员到架构师这个成长过程中的必修课。我的问题是，你觉得针对一个新建系统做产品选型时还要考虑哪些因素呢？这个产品，并不限于数据库。

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友也对产品选型这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

学习资料

刘雷 等: 分布式数据库在金融应用场景中的探索与实践.

29 | 产品图鉴：哪些分布式数据库值得看？

你好，我是王磊，你也可以叫我 Ivan。

今天是课程正文的最后一讲，时间过得好快呀。在基础篇和开发篇，课程安排追求的是庖丁解牛那样的风格，按照第 4 讲提到的数据库基本架构，来逐步拆解分布式数据库系统。在介绍每一个关键部件时，我会去关联主流产品的设计，分析背后的理论依据什么，工程优化的思路又是什么。

这样做的好处是能够将抽象理论与具体产品对应起来，更容易理解每个设计点。但它也有一个缺点，就是产品特性被分散开来，不便于你了解整体产品。

为了弥补这个遗憾，今天这一讲，我会把视角切换到产品方向，为你做一次整体介绍。当然对于具体特性，这里我不再重复，而是会给出前面课程的索引。所以，你也可以将这一讲当作一个产品版的课程索引，让你在二刷这门课程时有一个崭新的视角。

分布式数据库产品，从架构风格上可以分为 PGXC 和 NewSQL 这两个大类，以及另外一些小众些的产品。

NewSQL

Spanner

既然要说分布式数据库产品，第一个必须是 Google 的 Spanner。严格来说，是 Spanner 和 F1 一起开创了 NewSQL 风格，它是这一流派当之无愧的开山鼻祖。

在 2012 年 Google 论文“F1: A Distributed SQL Database That Scales”中首先描述了这个组合的整体架构。

其中 F1 主要作为 SQL 引擎，而事务一致性、复制机制、可扩展存储等特性都是由 Spanner 完成的，所以我们有时会忽略 F1，而更多地提到 Spanner。

Google 在 2012 年的另一篇论文 “Spanner: Google’s Globally-Distributed Database”中介绍了 Spanner 的主要设计。

Spanner 架构中的核心处理模块是 Spanserver，下面是它的架构图。

从图中我们可以看到，Spanserver 的核心工作有三部分：

基于 Paxos 协议的数据复制

基于 Tablet 的分片管理

基于 2PC 的事务一致性管理

这三个特性的我们分别在第 6 讲、第 7 讲和第 9 讲做了介绍。

在第 5 讲和第 12 讲，我们还介绍了基于全局时钟的数据一致性管理机制和处理时间误差的“写等待机制”。

我们都知道，软件架构会随着业务发展而演进，2017 年，Google 又发表了两篇新论文，介绍了 Spanner 和 F1 的最新变化。它们从原来的“金牌组合”走向了“单飞”模式。

Spanner 的论文是“Spanner: Becoming a SQL System”。就像论文名字所说的，Spanner 完善了 SQL 功能，这样就算不借助 F1，也能成为一个完整的数据库。

这篇论文用大量篇幅介绍了 SQL 处理机制，同时在系统定位上相比 2012 版有一个大的变化，强调了兼容 OLTP 和 OLAP，也就是 HTAP。对应的，Spanner 在存储层的设计也从 2012 版中的 CFS 切换到了 Ressi。Ressi 是类似 PAX 的行列混合数据布局（Data Layout），我们在第 18 讲专门谈了对 HTAP 未来发展的看法，并对各种不同的数据布局进行了分析。

F1 的新论文是“F1 Query: Declarative Querying at Scale”。这一版论文中 F1 不再强调和 Spanner 的绑定关系，而是支持更多的底层存储。非常有意思的是，F1 也声称自己可以兼顾 OLTP 和 OLAP。

这个架构中还有一个我们很感兴趣的点，就是 F1 通过 UDF Server 来实现存储过程的支持。我们在第 16 讲中对此进行了简单的探讨。

CockroachDB

按照时间顺序，在 Spanner 之后出现的 NewSQL 产品是 CockroachDB。CockroachDB 和 TiDB、YugabyteDB 都公开声称设计灵感来自 Spanner，所以往往会被认为是同构的产品。尤其是 CockroachDB 和 TiDB，经常会被大家拿来比较。但是，从系统架构上看，这两个产品有非常大的差别。让我们先从 CockroachDB 角度来总结下。

最大的差别来自架构的整体风格。CockroachDB 采用了标准的 P2P 架构，这是一个非常极客的设计。只有 P2P 架构能够做到绝对的无中心，这意味着只要损坏的节点不超过总数一半，那么集群就仍然可以正常工作。因此，CockroachDB 具有超强的生存能力，而这也很符合产品名称的语义。

第二个重要差异是全球化部署。CockroachDB 采用了混合逻辑时钟（HLC），所以能够在全球物理范围下做到数据一致性。这一点对标了 Spanner 的特性，不同之处是 Spanner 的 TrueTime 是依赖硬件的，而 HLC 机制完全基于软件实现。我在第 5 讲对它的设计做了深入的分析，而对于 HLC 的理论基础 Lamport 时钟，我在第 2 讲中也做了介绍。

第三个点则是分片管理机制的不同。因为整体架构的差异，CockroachDB 在分片管理上也跟 TiDB 有明显的区别，我们在第 6 讲中做了介绍。

另外，我还在第 10 讲介绍了 CockroachDB 的 2PC 优化，第 11 讲介绍了 CockroachDB 的读写隔离策略，第 12 讲介绍了如何用读等待方式解决时间误差问题。

2020 年，CockroachDB 发表的论文“CockroachDB: The Resilient Geo-DistributedSQL Database”被 SIGMOD 收录。这篇论文对 CockroachDB 的架构设计做了比较全面的介绍，非常值得你仔细阅读。

TiDB

TiDB 也是对标 Spanner 的 NewSQL 数据库，因为开源的运行方式和良好的社区运营，它在工程师群体中拥有很高的人气。

不同于 CockroachDB 的 P2P 架构，TiDB 采用了分层架构，由 TiDB、TiKV 和 PD 三类角色节点构成，TiKV 作为底层分布式键值存储，TiDB 作为 SQL 引擎，PD 承担元数据管理和全局时钟的职责。

与 Spanner 不同的是，底层存储 TiKV 并不能独立支持事务，而是通过 TiDB 协调实现，事务控制模型采用了 Percolator。我们在第 9 讲和第 13 讲介绍了 TiDB 的事务模型。

作为与 CockroachDB 的另一个显著区别，TiDB 更加坚定的走向 HTAP，在原有架构上拓展 TiSpark 和 TiFlash，在第 18 讲我们介绍了这部分的设计。同时，TiDB 对于周边生态工具建设投入了大量资源，由此诞生了一些衍生项目：

Ti-Binlog 和 Ti-CDC 可以将数据导出，构建逃生通道或者实现数据分析。

Ti-Operator 可以更方便的实现容器云部署

Chaos Mesh 支持混沌工程

第 25 讲、第 26 讲和第 27 讲的内容与上述项目具有直接的对应关系。这些项目的创立，丰富了 TiDB 的服务交付方式，也使 TiDB 的产品线变得更长。但是它们否都有很好的发展前景，还有待观察。

当然，TiDB 架构也存在一些明显的缺陷，比如不支持全球化部署，这为跨地域大规模集群应用 TiDB 设置了障碍。

与 CockroachDB 一样，TiDB 在 2020 年也发布了一篇论文“TiDB, A Raft-based HTAP Database”被 VLDB 收录，论文全面介绍了 TiDB 的架构设计，同样推荐你仔细阅读。

SIGMOD 和 VLDB 是数据库领域公认的两大顶会，而 TiDB 和 CockroachDB 先后发表产品论文，颇有一时瑜亮的感觉。

YugabyteDB

YugabyteDB 是较晚推出的 NewSQL 数据库，在架构上和 CockroachDB 有很多相似之处，比如支持全球化部署，采用混合逻辑时钟（HLC），基于 Percolator 的事务模型，兼容 PostgreSQL 协议。所以，课程中对 CockroachDB 的介绍会帮助你快速了解 YugabyteDB。

为数不多的差异是，YugabyteDB 选择直接复用 PostgreSQL 的部分代码，所以它的语法兼容性更好。

可能是由于高度的相似性，YugabyteDB 与 CockroachDB 的竞争表现得更加激烈。YugabyteDB 率先抛出了产品比对测试，证明自己处理性能全面领先，这引发了 CockroachDB 的反击，随后双方不断回应。我们在第 17 讲引用了这场论战中的部分内容。如果你想了解更多内容可以查看的相关博客。

OceanBase

从历史沿革看，OceanBase 诞生的时间可以追溯到 2010 年，但是那时产品形态是 KV 存储，直到在 1.0 版本后才确立了目前的产品架构。OceanBase 大体上也是 P2P 架构，但会多出一个 Proxy 层。

由于 OceanBase 是一款商业软件，所以对外披露的信息比较有限，我们的课程中在并行执行框架、查询引擎和存储模型三部分对 OceanBase 做了一些介绍，分别对应第 20 讲、第 21 讲和第 22 讲。期待 OceanBase 团队能够放出更多有价值的材料。

PGXC

PGXC 使用单体数据库作为数据节点，在架构上的创新远少于 NewSQL，所以我们在课程内容上所占篇幅也较少，而且是作为一个整体风格进行介绍。在第 3 讲，我们从单体数据库的隔离级别引申到 PGXC 的隔离性协议。第 4 讲，我们详细拆解了 PGXC 的架构，也就是协调节点、数据节点、全局时钟和元数据管理等四个部分。第 6 讲，我们介绍了 PGXC 的分片机制。还有一些特性，例如数据复制是继承自单体数据库。事实上，有关单体数据库实现的说明都对了解 PGXC 有所帮助。

TBase

TBase 是最标准的 PGXC，采用 PostgreSQL 作为数据节点。但 TBase 推出的时间较晚，应用案例也相对较少。从一些宣传资料看，TBase 更加强调 HTAP 的属性。但考虑到 TBase 与 TDSQL 都是腾讯出品，我认为这或许是出于商业策略的考虑，毕竟从架构本身看，TBase 与 TDSQL 的差异是比较有限的。

TDSQL

TDSQL 是腾讯公司内部最早的分布式数据库，它的数据节点选择了 MySQL。我曾经提到过，TDSQL 目前的主推版本并没有实现全局时钟，这意味着它在数据一致性上是存在缺失的。所以严格意义上说，它并不是分布式数据库，当然我们也可以通过一些公开信息了解到 TDSQL 在一致性方面的努力，相信完善这部分功能应该只是时间问题。

GoldenDB

GoldenDB 几乎是国内银行业应用规模最大的分布式数据库，和 TDSQL 同样在数据节点上选择了 MySQL，但全局时钟节点的增加使它称为一个标准的 PGXC 架构。

GoldenDB 对事务模型进行了改造，我们在第 9 讲介绍了它的分布式事务模型“一阶段提交”。

分库分表方案是一个长期流行的方案，有各种同质化产品，所以进一步演化出多少种 PGXC 风格的数据库很难历数清楚。借助单体数据库的优势，无疑会发展的更加快捷，但始终存在一个隐患。那就是单体数据库的许可证问题，尤其是那些选择 MySQL 作为数据节点的商业数据库。

MySQL 采用的 GPL 协议，这意味着不允许修改后和衍生的代码作为商业化软件发布和销售，甚至是只要采用了开源软件的接口和库都必须开放源码，否则将面对很大的法律风险。

Others

除了以上两种主流分布式数据库外，还有一些小众产品。虽然它们的使用不那么广泛也，但也很有特点。

VoltDB

VoltDB 一度也是开源数据库，但在 2019 年正式闭源，变为纯商业化产品。而同时，VoltDB 在国内也没有建立完备的服务支持体系，这在很大程度上影响到它的推广。另外，VoltDB 虽然支持 OLTP 场景，但在国内的落地项目中多用于实时分析场景，例如金融反欺诈等。

VoltDB 的主要特点是完全基于内存的分布式数据库，使用存储过程封装业务逻辑，采用单线程方式简化了事务模型。我们在第 16 讲对 VoltDB 做了简单介绍。

SequoiaDB

巨杉数据库（SquoiadDB）在早期并不支持完整事务，所以应用场景主要是归档数据和影像数据存储，而后从 3.0 版本开始逐步过渡到完整的 OLTP 分布式数据库。我们在第 4 讲介绍了巨杉的全局时钟实现方式，就是它在完善事务一致性方面的重大进展。

巨杉的架构其实跟 MySQL 架构非常相似，也分为 SQL 引擎和存储引擎上下两层。巨杉在上层直接复用了 MySQL 的 SQL 引擎，下层则使用自己的分布式存储引擎替换了 InnoDB。这种架构设计意味着巨杉可以非常好地兼容 MySQL 语法，就像 YugabyteDB 兼容 PostgreSQL 那样。

巨杉支持的还不只 MySQL，事实上它已经逐渐发展为一款多模数据库，同时兼容 MySQL、PostgreSQL、SparkSQL，并支持 Json 类型数据部分兼容 MangoDB，和 S3 对象存储等。

小结

那么，今天的课程就到这里了，让我们梳理一下这一讲的要点。

2012 年 Google 发表的两篇论文奠定了 Spanner/F1 作为 NewSQL 数据库开山鼻祖的地位。因为其中存储引擎、复制机制、分布式事务都在 Spanner 中实现，所以大家有时会略去 F1。而后，Spanner 演进为一个自带 SQL 引擎的完整数据库，并且同时兼顾 OLAP 和 OLTP 场景。

CockroachDB 与 TiDB 都是 Github 上非常热门的项目，设计灵感均来自 Spanner。虽然两者在部分设计上采用了相同的策略，例如引入 RocksDB、支持 Raft 协议等，但在很多重要特性上仍有很大差距，例如全球化部署、时钟机制、分片管理、HTAP 支持程度等。

YugabyteDB 与 CockroachDB 具有更大的相似性，导致两者间的竞争更加激烈。OceanBase 作为阿里的一款自研软件，整体风格上接近于 NewSQL，所以很多设计在原理上完全相同的。但由于商用软件的关系，开放的资料上远少于其他 NewSQL 数据库。

PGXC 数据库是从分库分表方案上发展而来，而这一步跨越的关键就是通过全局时钟支持更严格的数据一致性和事务一致性。PGXC 具有架构稳定的优势，往往被视为更稳妥的方案，但单体数据库的软件许可证始终是 PGXC 架构商业数据库一个很大的法律隐患。

VoltDB 和巨杉数据库是小众一些的数据库，在设计上采用了较为独特的方式，从而也带来了差异化的特性。

思考题

课程的最后，我们来看下思考题。今天，我们简要总结了课程中提到的各类分布式数据库的特点。但是近年来，学术界对分布式数据库的研究非常活跃，工业实践的热潮也日益高涨，短短的 30 讲真的难以历数。所以，我的问题是，除了上面提到的数据库之外，你还了解哪些有特点的分布式数据库吗？提示一下，在课程中出现过的至少还有两种。

欢迎你在评论区留言和我一起讨论，我会在答疑篇和你继续讨论这个问题。如果你身边的朋友想快速了解分布式数据库都有哪些产品，你可以把今天这一讲分享给他，可能会帮他更高效的锁定产品，期待你的朋友们也一起加入我们的讨论。

学习资料

Bart Samwel et al: F1 Query: Declarative Querying at Scale

David F. Bacon et al: Spanner: Becoming a SQL System

Dongxu Huang et al.: TiDB, A Raft-based HTAP Database

Jeff Shute et al.: F1: A Distributed SQL Database That Scales

James C. Corbett et al.: Spanner: Google’s Globally-Distributed Database

Rebecca Taft et al.: CockroachDB: The Resilient Geo-DistributedSQL Database

30 | 实践篇大串讲：重难点回顾+思考题答疑+知识全景图

你好，我是王磊。

今天这一讲是我们课程的最后一个答疑篇。我会回顾第 24 讲到第 29 讲的主要内容，这部分内容是跳出数据库的架构设计，从应用系统的整体视角展开的。接下来，我照例会集中解答留给大家思考题，同时也会回复一些大家关注的热点内容。

第 24 讲：全球化部署

第 24 讲的主题是全球化部署，更接地气的说法就是“异地多活”。

异地多活的目标是保证在区域级灾难事件的发生时，关键业务仍然能够持续开展。其实，异地多活一直是高可用架构所追求的目标，它的难点是有状态服务的处理，尤其是数据库。在实践中，有好几种基于单体数据库的方案，但它们都有局限性，无法实现“永不宕机”和“近在咫尺”这两点要求。

分布式数据库基于新的架构设计思想，是有条件达成这两点的。

实现“永不宕机”的前提是让主副本可以在异地机房之间的漂移，这就对全局时钟有更高的要求，必须做到多时间源、多点授时。目前能够支持的产品有 Spanner、CockroachDB 和 YugabyteDB，而采用单点授时的产品是无法支持的，比如 TiDB、OceanBase 以及 PGXC 风格的数据库。

而实现“近在咫尺”则要做到两点：

让主本数据能够主动漂移到用户侧机房，降低写操作的延迟。

使用就近的副本提供读服务，也就是 Follower Read 功能。

关于第二点，目前还没看到完全成熟的方案，难点在于如何保持副本数据的新鲜度，从而避免访问主副本带来的延迟。目前，已经有一些产品尝试通过副本与主本、元数据管理节点间的时间戳同步，降低对主本的依赖，保持副本数据的新鲜度。

最后，分布式数据库的多副本机制在实践中也存在一些缺陷。我们知道 Raft 或者 Paxos 都是多数派协议。按照协议原理，当出现网络分区时，在任何一个联通的子网络中，节点数量必须大于总节点数的一半才能选举出新的 Leader，从而继续对外服务。

那么，在两地三中心或三地五机房模式下，如果机房间网络全部中断，即使主机房节点全部正常也不能提供服务。这是因为考虑到 RPO 为零的目标，主机房的节点通常不会超过半数。这意味着，三机房或五机房的部署方案都会让机房间的网络成为易受到外来攻击的风险点，从而数据库变得更加脆弱。

这一讲的思考题是“课程中提到了 Raft 协议降级处理，它允许数据库在仅保留少数副本的情况下，仍然可以继续对外提供服务。这和标准 Raft 显然是不同的，你觉得应该如何设计这种降级机制呢？”

这个机制并不复杂。首先是固定主副本的位置，只有主机房的副本才能被选举为 Leader，这样就能保证主机房的数据足够新；其次是引入一个“低水位线”的概念，约定节点数量的下限，允许节点数量少于半数但高于下限时，仍然可以对外提供服务。

事实上，这两点不单是理论探讨。第一点在很多分布式数据库的实际应用中都有实现，因为这种约束本身就能降低运维的复杂度；第二点在 GoldenDB 等数据库中已经可以看到类似的设计。

第 25 讲：逃生通道

第 25 讲的关键词是逃生通道。逃生通道本质上是一个异构高可用方案，这种设计并不常见，但在分布式数据库的应用中是一个普遍需求。背后的原因是，数据库必须实现高可靠，而分布式数据库本身的成熟度不足以让用户安心，需要用更成熟的单体数据库做兜底方案。

高可靠方案的核心是如何将变更信息流推送给单体数据库，这个过程中会面临三个问题。其中性能适配和日志适配这两个问题相对容易处理；第三个问题是实现逃生库的事务一致性，要在变更信息流中添加特殊的时间戳消息，用来标识消息的完整性。

这一讲的是思考题是“对单个数据分片来说 WAL 本身就是有序的，直接开放就可以提供顺序一致的变更消息。而逃生通道作为一个异构高可用方案，往往会使用 Kafka 这样的分布式消息队列。那么单分片的变更消息流在逃生通道中还能保证顺序一致吗？”

答案是不能保证顺序一致，原因在于 Kafka 的分布式架构设计。

Kafka 按照 Topic 组织消息，而为了提升性能，一个 Topic 会包含多个 Partition，分布到不同节点（Broker）。那么，所有发送到 Kafka 的消息，实际上是会按照一定的负载均衡策略发送到不同的 Broker 上。

这样的设计意味着，在 Broker 内部消息是有序的，但多个 Broker 之间消息是无序的。作为消息的消费者，即使接收的只是单个分片的变更消息流，也不能用当前消息的时间戳来判定小于这个时间戳的消息已经发送完毕。

第 26 讲：容器化

在第 26 讲中，我们讨论了分布式数据库进行容器化部署的可行性。其中的核心问题就是 Kubernetes 对有状态服务的支持情况。

StatefulSet 是有状态服务的核心对象，我们围绕着它介绍了对应持久卷（Persistent Volume）特性的变化。随着 Kubernetes1.14 版的发布，基于本地磁盘的本地持久卷（Local Persistent Volume）已经达到生产级要求，有望彻底解决数据库容器化部署最大的障碍，I/O 性能问题。

分布式数据库本身服务的复杂性也是不容忽视的问题。通过 Kubernetes 自带的简单协调机制很难定义产品级的管理策略。所以，Kubernetes 提供了 Operator 这种扩展机制，让每个产品可以自行定义控制策略。目前，有不少分布式系统开发了自己的 Operator。

这一讲的思考题是“ 资源调度是 Kubernetes 的一项核心功能，那么除了 Kubernetes 你还知道哪些集群资源调度系统？它们在设计上有什么差异吗？”

随着很多基础系统向分布式架构过渡，集群资源调度成为一个非常广泛的需求，尤其是数据密集型系统。除 Kubernetes 外，知名度最高的可能就是 Yarn 和 Mesos，它们都是大数据生态体系下的工具。

Yarn 是 Hadoop 体系下的核心组件，被称为“数据操作系统”，所调度的资源单位和 Docker 有些相似，比如也使用了 Cgroup 隔离资源。Mesos 更是直接从大数据领域杀入容器编排领域，成为 Kubernetes 的直接竞争对手。

第 27 讲：产品测试

第 27 讲中，我们谈的核心内容是测试。测试是保证软件质量的重要手段，而对于分布式数据库来说测试的难度更大，也更为重要。我们先后介绍了 TPC-C、Jepsen 和混沌工程三种测试方式。

TPC-C 是由国际事务性能委员会发布的、针对 OLTP 数据库的测试规范，已经被业界广泛接受。

对于分布式数据库来说，是否正确实现了数据和事务的一致性也是测试的关键。Jepsen 是针对这类测试的权威工具，很多知名的分布式存储系统购买了 Jepsen 的测试服务。

混沌工程和 Jepsen 一样，都是通过注入底层故障的方式进行测试。但混沌工程的测试对象更广泛，涵盖了所有的分布式系统。同时，混沌工程是一种不同的测试文化，强调在生产环境注入故障，在受控的范围内观测系统，体现了反脆弱的思想。

最后，所有的测试都是在证伪，只能发现错误，不能证明正确性。而形式化验证弥补了这个缺憾，提供了证明的方法。在软件工程领域，Lamport 发明的 TLA，衍生出一系列方法、工具甚至编程语言，使我们可以用更严谨的方式确认逻辑的正确性。

这一讲的思考题是“除了分布式数据库，对于其他类型的分布式存储系统，你知道有哪些主流的测试工具吗？”

由于分布式系统的复杂性，测试工具就显得更加重要。除数据库外，比较典型是针对分布式键值系统的测试工具，例如 YCSB。YCSB（Yahoo! Cloud Serving Benchmark）是一个开源的测试框架，你可以从 Github 上下载到源码。

YCSB 支持几乎所有的主流分布式键值系统，包含 HBase、Redis、Cassandra 和 BigTable 等。YCSB 支持典型的 PUT\GET\SCAN 操作，你可以很容易地在它的基础上扩展，增加对其他键值系统的支持。

第 28 讲：选型案例

在第 28 讲，我们讨论的话题是如何进行分布式数据库产品选型。因为对于数据库产品来说，金融场景是公认的试金石，所以我们这一讲主要介绍了银行业的选型情况。

总的来说，银行的态度分为三种。

最保守的方案是采用分库分表，甚至单元化的方案，在应用层付出更大的代价，而在数据库层沿用单体数据库，保持充分的稳定性。这种方式的前提是必须有足够的业务驱动力，才能推动应用系统进行重大的调整。

第二种方式是自研数据库，周期长、投入大，但可以得到更加切合自身的解决方案。PGXC 数据库的难度较低，所以推进更快。

第三种方式是采购分布式数据库。目前随着技术的成熟，这种模式越来越普遍，甚至一些业务量较小的城商行也上线分布式数据库。

这一讲的思考题是“产品选型是系统建设中非常重要的工作，你觉得针对一个新建系统做产品选型时还要考虑哪些因素呢？这个产品，并不限于数据库？”

对于分布式数据库的选型，我认为最重要的是分析业务场景对于事务和查询方面的要求。事实上，这两部分内容也占据了我们课程的大半。在实践中，有的海量并发业务其实并没有相关性，也就是说，不同事务间数据重叠的概率非常低，这时就可以考虑分库分表方案。不过，多数的分库分表方案只能对数据做单向的路由分发，如果有跨分片的查询需求则很难满足。虽然，也可以通过 CDC 等工具导出到分析型系统实现这一点，但这时就要平衡系统的整体复杂度了。

跳出具体技术，我认为产品选型要结合具体项目目标，选择尽量少的产品或技术组件，尽量不采用同质化的多种技术，让架构更加简洁和优雅。

第 29 讲：产品图鉴

第 29 讲，我们转换到了产品视角，对课程内容做了一次完整的回顾。

我相信，很多同学都会有这样的问题：哪些分布式数据库更有发展，值得花时间学习？

这一讲，我根据个人经验列举了常见的十款数据库产品，并给出了相关内容在课程中对应的位置，方便你快速查找。

谈到产品，我们还是依据第 4 讲的分析框架将产品分为三类，NewSQL、PGXC 和其他。其中 NewSQL 是我们课程的重点，原因在于它体现了学术界和工业界近年来的很多创新。我们以 Spanner 和 F1 为基础，进行了比较完整的说明，并在此基础上介绍了其他四种 NewSQL 数据库的特点。

作为另一个主要流派，PGXC 是从分库分表方案演进而来的，演进过程的里程碑就是全局时钟。因为 PGXC 更加依赖单体数据库，技术门槛相对较低，所以产品数量就较多，很难全部列举。最终，我们只挑选了比较典型的三款产品。PGXC 虽然在架构上更加稳健，但部分单体数据库的许可证要求基于其开发的软件也必须开源，所以对于没有按照这一要求执行的商业产品来说，始终存在着法律风险。

最后，作为小众产品的代表，我们简述了 VoltDB 和巨杉数据库。它们也都是各种产品评测中的常客，但是由于各种原因，应用案例会相对少些。在一些特定场景下，这些产品也是重要的备选项。

这一讲的思考题是“除了第 29 讲提到的数据库之外，你还了解哪些有特点的分布式数据库吗？”

我在课程中还提到过两种不同的分布式数据库，分别是 FoundationDB 和 FaunaDB。最早，

FoundationDB 是一个开源项目，而后被苹果收购后闭源，2018 年又重新开源。严格来说，它是一个支持 ACID 语义的 NoSQL 数据库，有比较独特的乐观并发控制。FoundationDB 可以通过上层的扩展可以支持更多的场景，而在增加了 Record Layer 后，它就成为一个关系型数据库，不过还不支持 SQL 接口。总的来说，FoundationDB 值得我们持续关注。

对于 FaunaDB，可能了解的人更少些。它是一款商业数据库，从 NoSQL 逐渐过渡成为分布式数据库。FaunaDB 的理论原型是 Calvin，它和 Spanner 一样在 2012 年发表了原型论文，被 SIGMOD 收录。虽然它们的目标都是提供强一致性的分布式关系型数据库，但设计思路截然不同。

在 Calvin 的架构中设计了 Sequencer 和 Scheduler 两个组件。

其中 Sequencer 是基于锁机制为输入的事务操作进行排序，这样在事务真正执行前就能获得确定性的顺序，避免了过程中的无效交互和资源冲突。Scheduler 则是按照这个顺序执行，调度不同的事务执行线程完成最终操作。

这个设计思路被称为确定性并发控制，对应的数据库则称为确定性数据库（Deterministic Database）。

确定性并发控制算法的基本思路是，确保不同的副本始终能够获得相同的输入，所以就能独立地产生相同的结果，从而避免使用开销更大的提交协议和复制协议。通常，这类算法可以降低协调工作的通信开销，提高分布式事务的处理效率。而它的代价之一是无法为客户端提供一个交互操作过程，这就限制了它的使用场景。

目前基于 Calvin 理论模型的工业产品，似乎只有 FaunaDB。作为一种有代表性的分布式数据库，它值得我们继续关注。

此外，确定性数据库也是学术领域的重要研究方向。VLDB2020 收录的一篇论文“Aria: A Fast and Practical Deterministic OLTP Database”，就探讨了对确定性控制算法的优化方案，如果你有兴趣可以仔细研究下。

小结

最后，我想把这 30 讲的组织方式总结一下。

在基础篇，我重点铺垫了数据库和分布式架构的基本概念，这些内容可以独立成篇，阅读难度相对较低。

在开发篇，我们一起深入到分布式数据库的内部，对关键设计进行拆解，这一部分涉及大量的学术论文和工程实践。其中，事务和查询无疑是两座高峰，为了你有个歇脚喘气的地方，我在中间也穿插了一些相对简单的内容。

最后的实践篇，其实已经跳出数据库的范畴。这一部分的话题主要是围绕应用系统建设的整体视角展开，目的是希望将课程与你的日常工作衔接起来。

对于分布式数据库来说，30 讲的篇幅还是很短的，有些原理可能还没有完全说透，还有些有趣的东西或许被遗漏了。不过，这并不要紧，因为课程虽然结束了，但我们的交流还在继续。

如果你对课程任何内容有疑问，欢迎在评论区留言和我一起讨论。要是你身边的朋友也对分布式数据库感兴趣，你也可以把这门课程分享给他，我们一起讨论。

分布式数据全景图 4/4

学习资料

Alexander Thomson et al.: Calvin: Fast Distributed Transactions for Partitioned Database Systems

Yi Lu et al.: Aria: A Fast and Practical Deterministic OLTP Database

用户故事 | 李兆龙：博观而约取，厚积而薄发

你好，我叫李兆龙，来自西安邮电大学，是一个软件工程专业的大三学生。很高兴有机会，跟你分享我学习《分布式数据库 30 讲》的经历和心得。

为什么要学习这个专栏

故事还得从大二的寒假说起。2020 年是特殊的一年，因为疫情的原因，很多大学生其实都是在家度过第二个学期的。这对我来说，既是一个坏消息，也是一个好消息。

我说它是坏消息，是因为在家度过了大学八分之一的宝贵时光，这确实是让人觉得可惜的一件事情，毕竟在学校的生活实在是让人怀念。

说是好消息，则是因为这是一个整段的学习时间。它不像在学校上课的日子，时间被拆分成了多个片段，而且晚上十一点半楼管阿姨准时锁门，每次我想去钻研一些有意思的东西时，总感到不够尽兴。平时只有暑期留校的时候才有这种机会，所以我说这也是一个好消息！

其实我最初只是基于 MySQL 学了数据库原理，第一次看到多个机器组合产生的化学反应竟然如此有趣。于是在那个时候，我就已经在心中种下了一颗向往分布式的种子，也才有了后面的故事。

后来我有幸阅读了 Redis 的源码。基础的数据结构部分，包括后面的持久化、网络框架、和内存淘汰策略，虽然比较复杂，但是代码的实现毕竟没有跳出单机的思维，所以我在阅读上是没有什么大问题的。但是这个过程在学习到哨兵机制和集群的时候发生了改变，这些改变来自于两个方面，即分布式代码的设计与实现。

脱离了单机的程序，框架设计是复杂的。因为涉及到很多边界条件的考虑，而相当一部分问题只有在程序的吞吐量到达一定程度的时候才会出现问题。虽然这些思路落地产生的代码看似并不复杂，但涉及分布式的代码调错实在是让人想抓耳挠腮。

总之，在经过了一个漫长的过程以后，我总算是搞清楚了 Redis 集群与哨兵的设计与实现。虽然这一个阶段已经结束，但是我对分布式存储的兴趣却更加浓厚了。后来在一个前辈的指点下，我去学习了MIT 6.824，并且阅读了Designing Data-Intensive Applications。完成了 6.824 的大部分的论文阅读和实验以后，我的心里其实可以说有一些不是滋味。

课程的质量自然没得说，我相信每一个真正沉心其中的人，一定愿意竖起自己的大拇指。让我感到不是滋味的原因是，国内相关资料的匮乏实在是让人咋舌，我能够找到的资料基本都是社区内高质量的博客，而这类博客的内容往往也只是点到为止，很少有相关知识的扩展。这对于像我这样有这方面兴趣的小白来说，确实是一个不小的挑战。

大概七月的时候，我知道了本校软件工程专业在大三上半学期，会开设分布式原理的课程。说实话，我在假期的时候确实是为此兴奋了很久。但是实际课程的效果其实更偏向于科普，对于分布式中每一个细分的地方都是泛泛而谈，跟代码实现的距离着实不近，而且代课老师的研究方向也并不是这个领域。

我所在的实验室“XiyouLinux 兴趣小组”的研究方向，也是偏向于 Linux 内核，而不涉及分布式。因此，我从学校获取更多资源的愿望也泡汤了。所以，很多时候我还是很羡慕一些学校更好的同学的。

于是，更加深入的学习，我就只能靠阅读分布式基础理论论文和一些经典工业实现的论文。这样虽好，但是却缺乏一个站在更高角度的宏观指导。这样的学习其实是事倍功半的，但我却没有更好的办法。在此“绝境之时”，我正好看到了王磊老师的《分布式数据库 30 讲》这门课程，实在可以说是恰逢甘霖！它正好填补了我心中缺失的那块拼图，所以我学习这门课程的信念也就越来越坚定了！

我是如何学习专栏的

我应该算是最早的一批学习这个课程的同学了，在第一次看到课程的时候，它才更新了两讲而已。当时还是暑假期间，每天的时间都非常充裕。

王磊老师的更新进度是一周三更，我的学习过程是“一周四看”。老师会在一三五零点更新课程，我就在第二天早上八点准时坐在电脑前准备把老师想要传达给我们的信息尽可能地汲取到大脑中来，这是三看。

还有一看呢，我分成两个部分。Top half 就是周六周日中某一天的晚上，抽出两个小时左右的时间去把本周的知识点再思考一遍，尝试与以前学过的知识串联起来。比如从全局时钟中想到了 Lamport 的“Time, Clocks and the Ordering of Events in a Distributed System”，进而联想到无锁编程。还记得以前简单学习 C++ 无锁编程的时候，其内存序的设计实在是让人难以理解，由逻辑时钟中得到的启发，也让我对无锁编程理解比以前更深了一步。

Bottom half 则为思考老师对我和其他同学留言中所提问题的解答，以及其他优秀的同学的解答。我认为这也是学习中很重要的一个部分，因为课程的读者中也有很大一部分经验丰富的优秀工程师，他们的见解往往也是精准老辣，也让我受益匪浅。

还有一点也非常的重要，就是对于老师课程中提到的以前没有了解过的知识点，在了解以后及时记录，如果有新的理解或者现有的博客描述不清的话还可以记录成一篇博客，既方便自己以后温习，又能为其他朋友提供一个参考，何乐而不为呢？

美好的事物总是短暂的，最典型的代表就是假期。暑假这个大段的适合钻研自己喜欢的东西的学习时间没有了，取而代之的是需要遵循学校的课程时间。

学校的课程压力确实很重。虽然这学期的课程的知识大多我都已经在大二学习完了，但是老师布置作业的时候却是手下不留情；而且计算机网络和 OS 这些课，以前学习的一些细节我遗忘也比较严重；此外，每天还必须要写的算法题和完善马上春招的项目，这些基本把工作日全部的时间都牢牢占据了。

于是，我学习老师课程与阅读论文的时间就只能放在周末了，所以在学校内一般是在周末去学习老师本周的更新内容。当然，虽然学习王磊老师课程的时间被压缩了，但学习质量是不能够被压缩的，在学习两到三讲以后，还是需要像假期一样温习前几讲所学的东西。其实对大多数像我这样的普通人来说，学习没有什么捷径，不如像个卖油翁一样，无他，唯勤奋尔。

学习专栏给我的帮助

从开篇词中就可以看出，王磊老师的经验是非常丰富的。在看到第一眼的时候我就想，如果老师能够把这些经验用文字呈现出来，哪怕只是一部分，对于我的帮助也绝对是巨大的。

因为我在前面就提到过，我现阶段学习分布式，最大障碍就是无法站在一个宏观的角度看问题。我之前的学习路线，是看分布式基础理论和成功的工业实现的论文，仅靠我一个人其实很难把全部的知识串联起来，进而形成网状的知识体系。

而且一个成功的分布式系统往往是根据某些特定的需求，在此基础上做出取舍。论文往往只说它用了什么，而很少提及为什么不用别的。我也不知道有没有其他解决此类问题的方法，要解决只能去花很大的功夫查找资料，而资料又是匮乏的。此时老师的课程就显得尤为珍贵，因为老师对于课程涉及到的问题，基本上把相关的解决方案都列举出来了，而且还详细地对比了各种方案之间的异同。

比如在第 5 讲介绍全局时钟的时候，老师对于多种方案的比较确实是让人耳目一新的。以前我只是简单了解过 Spanner 的 TrueTime，就以为搞懂了授时这个问题。在学习了这一课以后，我才知道授时的方案该如何划分，还知道了其他的授时方案。如此看来，以前实在是见识短浅啊，在学完这一课以后，我才算是真正地初步了解这个问题。

老师的课程不仅仅是包含分布式相关的描述，更多的部分是描述数据库的设计要领与各种抉择，而这些于我们来说也是很重要的，值得仔细思考。

很多看起来微不足道的进步，都是在不计其数的大牛经过漫长的时间研究和试错才得出的，更不必说分布式数据库这样璀璨耀眼的软件。而我们却能够站在巨人的肩膀上去学习，这对像我这样爱好者来说也未尝不是一种幸运。能够深入、正确地学习喜爱的东西，我想这就是最大的收获了。

总结

当今的互联网环境无时无刻不在贩卖焦虑，尤其以互联网专业为最，我这个大三的普通学生更是感到迷茫不已。摆在面前的问题是很严峻的，在学校学习，如何把精力用在刀刃上？如此迅速的技术变革我们该如何立足？技术的背后共通点是什么？

私以为，技术的变革来源于需求的变革或者创新，刀刃与共通点就是基础技术。如果我们能够静下心来好好学习这些基础技术，比如这门课所展现给我们的作者宝贵的经验，我相信在未来可预测到的新的技术浪潮中，一定有我们的一席之地！

博观而约取，厚积而薄发。这是 XiyouLinux 兴趣小组教给我的，我想这也是每一个现在或者未来的 IT 行业从业者所要牢记心中的。坚持学习，如果现在还没有看到效果，也不必灰心，在可见的未来一定可以绽放属于自己的那一道光！

正如王磊老师在开篇词中所言，分布式数据库凝聚了无数学者与工程师的智慧，灿若星辰！我希望学习这门课程的每一个人都能成为那个“手握日月摘星辰”的人！

结束语 | 享受职业带给你的快乐

你好，我是王磊。

《分布式数据库 30 讲》这门课程，到今天就结束了。对于一门侧重于技术原理的课程，我猜你学起来还是有点吃力的，能够跟着学完整个课程非常不容易。让我为你鼓鼓掌，真的很棒。

既然已经这么不容易了，那么今天我们就聊聊技术以外的话题。编辑同学建议我在结束语给大家鼓鼓劲，就像大学毕业典礼一样，让大家充满动力地投入到下一阶段的工作和学习中。

但是我想，类似的话应该已经有很多老师和你说过了。我想借这个机会，讲些我觉得更重要的东西。

首先，我想和你分享一段话，它听起来有点像寓言故事。

对大多数人来说，一生都要经历三个幻灭的过程。

第一次是在人生的前 20 年。从牙牙学语开始，父母给我们无微不至的照顾，解决生活中的所有难题，我们认为他们无所不能。但是长大后，我们发现父母其实只是普通人，之前的错觉是因为我们没有看到，他们转过身去品尝生活苦涩的样子。

第二次幻灭，发生在人生的第二个 20 年。在这个阶段的开头，我们学业有成，迫不及待地冲入职场想要大干一番。但是，很多人在第一次求职中就碰到前所未有的困难，发现和梦想的公司之间隔着一座大山。也许你更幸运些，获得了一个还算不错的职位，于是加班加点认真工作，希望老板看到你的努力，能在加薪晋升时想起你。但随着时间流逝，你发现自己已经慢慢落后于最优秀的同事和同学，而那些更年轻的同事开始加入竞争，他们比你当年还要努力。然后，你也慢慢接受了自己是普通人的事实。

第三次幻灭，发生在人生的第三个 20 年，你我应该都还没有体会到。那是父母对子女从满心期待、精心培养，到最后接受子女也是普通人的过程。

故事讲完了，怎么样，是不是觉得有点丧？你可能会不服气，怎么就幻灭了，“我命由我不由天”啊！

但是，冷静想想，可能这就是人生。你努力了，却不一定成功，你也不一定有能力做出正确的选择。大佬之所以被仰视，就是因为成功永远只属于少数人。

当然，我讲这些不是要让你堕落，混日子。正如罗曼·罗兰所说的，“世界上只有一种英雄主义，就是看清生活的真相之后依然热爱生活。”

你有没有想过，如果就是这么不巧，你错过了所有机会，又该怎么面对自己人生呢？

真正的勇敢，是放弃“人生有七次机会”这种妄念，找到自己职业的价值，享受职业带给你的快乐。

而更大的职业成就感，在于认清你的工作对于世界的意义。这对于个人和企业都很重要，就像 Google 公司的座右铭——Do not be evil，不作恶。

你怎么看待自己工作的意义呢？如果你不能找到这个意义，那应该重新审视一下自己的工作。我觉得单纯依靠职场成功来驱动自己，并不能长久，甚至会让你忘记初心，变成自己曾经最讨厌的人。

说到这里，我想和你分享一个我自己的故事。

好多年前，那时候刚开始流行 SOA（面向服务）。公司有个项目是要整合客户的遗留系统，最终交付一套服务总线，支持新业务的快速开发。其中有个老系统，简称 T 系统，因为是 C/S 架构无法直接开放服务，所以必须要做一个业务中间件。而我的工作，就是开发这个中间件，简称 CC 吧。

你一定也能看得出来，其实这是个很边缘的工作。当时，项目组有二三十人，大家都在捣鼓当时最新的技术，而我则只是负责翻写业务逻辑。不过，我还是在很认真地对待这项工作，因为技术能力也一般，所以查了不少资料。

这个中间件的重点是延迟和扩展性。

当时的 SOA 主要使用 XML 报文交互，组织方式比较重，而通用的 XML Parser 组件性能普遍不好，会导致整个服务的延迟很长，我试了几个都不满意。所以，最后我自己写了一个简单的 Parser，虽然通用性没那么好，但性能还不错，对这个项目来说足够用了。

在扩展性方面，我使用了设计模式中的模板模式（Template Pattern），在父类中完成逻辑顺序的控制，在子类中实现具体业务。然后，子类逻辑实现中又将业务规则拆分到独立的 XML 配置文件里。

这样设计的优点是业务逻辑的可读性非常好，非技术人员也能通过 XML 看懂大概的意思。当然，现在回头再看，其实也有不少需要优化的地方。比如用 XML 文件来定义业务规则，无法用编译器检验逻辑的正确性，测试成本会比较高，但当时也没想到这么多。

这些工作虽然做得很用心，但是老板并不关注，所以那年我的年终奖非常少，而其他人的奖金是蛮高的。当时，我就很郁闷，觉得自己的心血白费了，感到很不值。

在我离开那家公司几年后，一次和一个前同事聊天，他告诉我那个中间件 CC，他现在还在继续维护，并且还称赞 CC 的架构设计得很好，业务逻辑变更起来很简单。此时，那个 SOA 项目已经下线多年了。再后来，因为 T 系统下线，CC 也完成了它的历史使命，这时候距离 CC 上线已经差不多有十年了。

当年独立开发的软件能够运行十年，我还是挺骄傲的。现在想想，开发 CC 的收获也是很大的。首先它提升了我的技术能力，而且，后来的持续运行也带给我很大的满足感，这远不是一两万块的奖金能够替代的。

所以说，确定工作的意义非常重要。如果你想清楚了这一点，就不会太纠结于当下。也许你的努力，老板没有看到，或者你的成绩算在了别人头上。但你做的事情就在那里，你的程序在那儿运转着，它影响着其他人，这时你已经改变了世界。

对，就是改变世界。

我不是开玩笑，因为确实是在你的努力下，系统更稳定了，或者服务延迟更短了，或者用户体验更好了。总之，世界有那么一点不一样了，变好了那么一点点。

只有将成就感从职场成功中分离出来，您才会更容易体验到职业带来的快乐。就算没有得到命运的眷顾，你仍然可以坦然地面对自己。

我相信，来极客时间学习的同学都有很强的自驱能力，想要不断地精进技能。而这种动力的来源，不应该只是为了获得更好的工作岗位，争取加薪晋升的机会和照顾好家人，还应该包括更出色地完成工作，更多地影响这个世界，让它变得更美好。

做这样一个勇敢面对生活的人，你的运气又怎么会差呢？

让我们一起加油吧！

我在这里为你准备了一份毕业问卷，题目不多，希望你能花两分钟填一下。我非常期待能听你说一说对这个课程的想法和建议。今天虽然是结课了，但我希望你还能继续分布式数据库方面的学习，因为我相信它有很好的未来，掌握这门技术会提升你的价值。如果你在工作中碰到相关的问题，可以再来回顾这个课程，也许就能找到答案，或者你也可以在留言区写下你的问题，我会持续关注的。

好吧，我说到这里了，让我们江湖再见吧。

结课测试｜这些分布式数据库的问题，你都掌握了吗？

你好，我是王磊。

《分布式数据库 30 讲》这门课程已经全部结束了，十分感谢你一直以来的认真学习和支持！

我给你准备了一个结课小测试，来帮助你检验自己的学习效果。

这套测试题共有 20 道题目，包括 14 道单选题和 6 道多选题，满分 100 分，系统自动评分。

还等什么，点击下面按钮开始测试吧！

最后，这里有一份毕业问卷，题目不多，希望你能花两分钟填一下。十分期待能听到你说一说，你对这个课程的想法和建议。