理解 Google F1: Schema 变更算法

2015-11-18
数据库 TiDB

注:本文已被《从零开始写分布式数据库》一书收录。

背景

F1 是 Google 开发的分布式关系数据库,主要服务于 Google 的广告系统,它提供强一致性、高可用性,并支持传统 SQL 查询,近来也常常被称之为所谓的 NewSQL。

F1 是构建于 Spanner 之上的。Spanner 是 Google 开发的全球级数据存储引擎,它保证了数据存储的一致性和可用性,还通过 2PC(两阶段提交)提供了分布式事务读写。在分析 F1 时,我们可以简单地认为 Spanner 是一个全球分布的 kv 数据库。

F1 系统运行时由多台独立的 F1 服务器组成,为了保证整个系统的高可用性,F1 服务器被设计为无状态的,而且不存储数据——节点可以随时上线下线,客户端可以连接至任意节点发送请求。F1 服务器主要职能是将 RDBMS 中的结构化数据映射为可存储于 Spanner 的 kv 对,同时将客户端的 SQL 请求翻译成 get, set, del 等简单的 kv 操作。

Schema 也就是关系数据库中表、列、索引、约束等定义,对应于 SQL 中的 DDL。很显然,Schema 决定了 F1 服务器的具体工作方式,客户端请求的解析和验证由 Schema 决定,之后如何翻译成 kv 操作也由 Schema 决定,Schema 可以被认为是 F1 服务器运行时所依赖的元信息。实践中,F1 服务器运行时自身会缓存一份 Schema 并有一定的机制保持定时更新。

F1 中的 Schema 变更是在线的、异步的,Schema 变更的过程中所有数据保持可用,保持数据一致性,并最大限度的减小对性能的影响。最大的难点在于所有 F1 服务器的 Schema 变更是无法同步的,也就是说不同的 F1 服务器会在不同的时间点切换至新 Schema。

由于所有的 F1 服务器共享同一个 kv 存储引擎,Schema 的异步更新可能造成严重的数据错乱。例如我们发起给一次添加索引的变更,更新后的节点会很负责地在添加一行数据的同时写入一条索引,随后另一个还没来得及更新的节点收到了删除同一行数据的请求,这个节点还完全不知道索引的存在,自然也不会去删除索引了,于是错误的索引就被遗留在数据库中。

算法思想

为了更好地阐释算法思想,本节我们以一个简化的情境做类比。

假想我们有一家跨国公司,公司员工分布在全球各地,员工之间以电子邮件的方式互相通信,同时工作的性质要求员工之间发送的消息不能出现丢失。之后在某一天出现这样的需求:管理层决定把员工通信方式由邮件改为 QQ。

对于这样一个跨国公司来说,我们无法瞬间把新的工作方式通知给所有员工。假如先收到通知的员工先改为用 QQ 了,而未收到通知的员工还在用邮件,这样一来自然就会发生大量的消息丢失。

那么我们能不能通知员工在未来的某个时刻统一换用 QQ 呢?仔细一想也是不行的。因为每个员工的手表不可能是完全对准的,总是有的快点有的慢点,只要不能保证所有员工的时间完全校准,总有那么一个不一致的时刻。

下面让我们来看看 Google 的工程师们是怎么解决这个棘手的问题的。

在未知中构建已知

根据上面的讨论,最根本的难点在于无法保证所有员工同时改变通信方式,这基本上是不可能做到的。本来员工都在用邮件,一旦通知发布出去,员工的工作方式就完全是未知的了——在任意一个时刻,任意一个员工都可能收到过通知而换用 QQ,也可能没收到通知而继续用邮件。

如果从现实层面来考虑,员工即使是离得远些,一段足够长的时间以后也应该能收到通知。员工的时间即使是没校准,也不至于错得太离谱。所以我们可以认为在足够长的时间过后,所有员工应该都已经换用 QQ 了。

我们可以使用一系列方案使“足够长的时间”变成“确定长度的时间”。首先,公司创建一个网站张贴最新的员工手册,其中自然包含员工应使用的通信方式等细则。其次,在员工入职时进行培训,要求员工每隔 30 分钟必须上网查看一下员工手册,并依照最新的手册行事。另外,如果出现网络故障等情况,员工在尝试访问网站 10 分钟后还没有看到新的手册,必须立即停止工作,直到重新看到手册为止。

基于这些规定,我们就至少可以知道从通知发布之后的某个时刻开始,所有工作中的员工都已经更新自己的通信方式了。例如我们中午 12:00 在网站上张贴新的手册,员工从 12:00 到 12:30 开始陆续查看手册并换用 QQ,到 12:30 时所有员工都应该尝试过访问网站了,到 12:40 时所有未能看到手册的员工都已经停止了工作,这时我们就可以认为所有工作中的员工都在用 QQ 了。如果再考虑手表时快时慢等特殊情形,我们不妨再多等 20 分钟。到了 13:00,我们可以非常自信地说:现在所有员工都换用 QQ 了。

在此基础之上,我们再规定两次修改员工手册的时间间隔不能少于 1 小时。例如在 QQ 之后我们还想换用微信,那么最早只能在 13:00 发布新的员工手册。根据前面的讨论,13:00 已经没有员工用邮件了,所以在整个演化过程中,有些时刻邮件和 QQ 同时被使用,有些时刻 QQ 和微信同时被使用,但一定不会发生邮件和微信同时被使用的情况。也就是说,在员工手册不断更新的过程中,最多只有两份手册生效:最新发布的这一份以及上一份。

在矛盾中构建一致

上面我们设计了大量的规定和方案,最后只得到了不那么强的结论,看起来对问题的解决并没有什么帮助。不难发现问题的关键在于邮件和 QQ 这两种通信方式是矛盾不兼容的,只要有一个时刻有员工用邮件而有员工用 QQ,那么就很可能会造成消息丢失。

那么问题的本质其实是:在通信方式由邮件变成 QQ 的过程中,邮件和 QQ 这两种通信方式不能同时生效。

请回想一下上一节中我们得到过的结论,邮件和微信一定不可能同时被使用……你想到了吗?

BING!没错,只要我们在邮件和 QQ 中间加入一个其他的通信方式 X 作为过渡,因为同时只有两种连续的手册生效,邮件和 QQ 就很自然地被隔离了。很显然通信方式 X 一定不是微信,它一定是同时与邮件和 QQ 兼容的,在这里 X 的定义可以是:同时从邮件和 QQ 查收消息,发送消息时邮件和 QQ 各发送一遍。

以上就是 F1 Schema 变更的主要思想了。具体在 F1 Schema 变更的过程中,由于数据库本身的复杂性,有些变更无法由一个中间状态隔离,我们需要设计多个逐步递进的状态来进行演化。万变不离其宗,只要我们保证任意相邻两个状态是相互兼容的,整个演化的过程就是可依赖的。

F1 中的算法实现

租约

F1 中 Schema 以特殊的 kv 对存储于 Spanner 中,同时每个 F1 服务器在运行过程中自身也维护一份拷贝。为了保证同一时刻最多只有 2 份 Schema 生效,F1 约定了长度为数分钟的 Schema 租约,所有 F1 服务器在租约到期后都要重新加载 Schema。如果节点无法重新完成续租,它将会自动终止服务并等待被集群管理设施重启。

中间状态

前面已经提过,F1 在 Schema 变更的过程中,会把一次 Schema 的变更拆解为多个逐步递进的中间状态。实际上我们并不需要针对每种 Schema 变更单独设计中间状态,总共只需要两种就够了: delete-onlywrite-only

delete-only 指的是 Schema 元素的存在性只对删除操作可见。

例如当某索引处于 delete-only 状态时,F1 服务器中执行对应表的删除一行数据操作时能“看到”该索引,所以会同时删除该行对应的索引,与之相对的,如果是插入一行数据则“看不到”该索引,所以 F1 不会尝试新增该行对应的索引。

具体的,如果 Schema 元素是 tablecolumn ,该 Schema 元素只对 delete 语句生效;如果 Schema 元素是 index ,则只对 deleteupdate 语句生效,其中 update 语句修改 index 的过程可以认为是先 delete 后再 insert ,在 delete-only 状态时只处理其中的 delete 而忽略掉 insert

总之,只要某 Schema 元素处于 delete-only 状态,F1 保证该 Schema 元素对应的 kv 对总是能够被正确地删除,并且不会为此 Schema 元素创建任何新的 kv 对。

write-only 指的是 Schema 元素对写操作可见,对读操作不可见。

例如当某索引处于 write-only 状态时,不论是 insertdelete ,或是 update ,F1 都保证正确的更新索引,只是对于查询来说该索引仍是不存在的。

简单的归纳下就是 write-only 状态的 Schema 元素可写不可读。

示例推演

Google 论文的叙述过程是描述完两种中间状态后就开始了冗长的形式化推导,最后得以证明按照特定的步骤来做 Schema 演化是能保证一致性的。这里我想先拿出一个例子把 Schema 变更的过程推演一遍,这样形成整体印象后更有助于看懂证明:)我们以添加索引为例,对应的完整 Schema 演化过程如下:

absent --> delete only --> write only --(reorg)--> public

其中 delete-onlywrite-only 是介绍过了的中间状态。 absent 指该索引完全不存在,也就是 Schema 变更的初始状态。 public 自然对应变更完成后就状态,即索引可读可写,对所有操作可见。

reorg 指“database reorganization”,不是一种 Schema 状态,而是发生在 write-only 状态之后的一系列操作。这些操作是为了保证在索引变为 public 之前所有旧数据的索引都被正确地生成。

根据之前的讨论,F1 中同时最多只可能有两份 Schema 生效,我们逐个步骤来分析。

先看 absentdelete-only 。很显然这个过程中不会出现与此索引相关的任何数据。

再看 delete-onlywrite-only 。根据 write-only 的定义,一旦某节点进入 write-only 状态后,任何数据变更都会同时更新索引。当然,不可能所有节点同时进入 write-only 状态,但我们至少能保证没有节点还停留在 absent 状态, delete-onlywrite-only 状态的节点都能保证索引被正确清除。于是我们知道:从 write-only 状态发布时刻开始,数据库中不会存在多余的索引。

接下来是 reorg ,我们来考察 reorg 开始时数据库的状态。首先因为 delete-only 的存在,我们知道此时数据库中不存在多余的索引。另外此时不可能有节点还停留在 delete-only 状态,我们又知道从这时起,所有数据的变更都能正确地更新索引。所以 reorg 要做的就是取到当前时刻的 snapshot,为每条数据补写对应的索引即可。当然 reorg 开始之后数据可能发生变更,这种情况下底层 Spanner 提供的一致性能保证 reorg 的写入操作要么失败(说明新数据已提前写入),要么被新数据覆盖。

基于前面的讨论,到 reorg 完成时,我们的数据不少不多也不错乱,可以放心地改为 public 状态了。

证明过程简介

这里简单介绍下证明过程,以理解为主,详细情况可自行查阅论文。

我们定义数据库表示为存储引擎中所有 kv 对的集合。数据库表示 d 对于 Schema S 是一致的,当且仅当

  1. d 中不存在多余数据。
  2. d 中的数据是完整的。

其中不存在多余数据要求:

  1. d 中的列数据或索引必须是 S 中定义过的列或索引。
  2. d 中所有索引都指向合法的行。
  3. d 中不存在未知数据。

数据的完整性要求:

  1. public 状态的行或索引是完整的。
  2. public 状态的约束是满足的。

我们要求正确实现所有 delete, update, insertquery 操作,保证其对于任何 Schema S ,都不破坏数据库表示的一致性。

我们定义 Schema S1S2 的变更过程是保持一致的,当且仅当

  1. 任何 S1 所定义的操作 OPs1 都保持数据库表示 d 对于 S2 的一致性。
  2. 任何 S2 所定义的操作 OPs2 都保持数据库表示 d 对于 S1 的一致性。

这里看起来第 2 点是没必要的,但确实是必须的,因为 F1 中在变更发生的过程中 S1S2 是会同时生效的。我们考虑为 table 添加一列 optionalC 的变更( optional 表示允许该列在数据库表示 d 中不存在,对应于 SQL 中未定义 NOT NULL 或有 DEFAULT 的情况)。首先, S2 定义的 insert 操作会写入 C 列的数据,其产生的数据库表示 d’S2 是一致的,但对 S1 是不一致的(有多余数据)。现在如果发起由 S1 定义的 delete 操作, C 列对应的数据就不能被正确删除了。

显然根据定义,我们有如下推论:Schema S1S2 的变更过程是保持一致的,当且仅当 S2S1 的变更过程也是保持一致的。

接下来我们可以得出如下结论:任何从Schema S1S2 的变更,如果其添加或删除了一项 public Schema 元素 E ,那么此变更不能保持一致性。

我们先考虑添加 E 的情况。不论 Etable, columnindex ,由 S2 定义的 insert 都将插入 S1 未定义的数据,所以 S1S2 的变更不能保持一致性。根据上面的“可逆”推论,删除的情况也得证。

接下来我们要证明:任何从Schema S1S2 的变更,如果其添加了一项 delete-only Schema 元素 E ,那么此变更过程保持一致。

因为 S1S2 上定义的任何操作都不会为 E 创建数据,显然不会产生多余数据。又因为 E 不处于 public 状态,自然也不会破坏数据完整性。所以该变更保持一致性。

接下来我们要证明:任何从Schema S1S2 的变更,如果其将一项 delete-only 状态的 Schema optional 元素 E 置为 public ,那么此变更过程保持一致。

因为 S1S2 上定义的 delete 操作都能正确地删除 E 所对应的 kv 对,不会产生多余数据。由于 S1Edelete-onlyS1 所定义的 insert 不会为 E 写入对应的数据,但是因为 Eoptional 的,数据的缺失最终不会影响一致性。所以该变更过程保持一致。

到这里,我们就有了针对添加 optional Schema 元素的完整变更方案:

absent --> delete-only --> public

删除 Schema 元素以及添加删除 required 元素的情况都是类似的推导过程,这里就不再赘述了,具体可参考论文。

其他

本文参考文献主要有:

另,我们团队的开源项目 TiDB 中已经有了一份在线 Schema 变更的初步实现,欢迎关注。


欢迎加入技术讨论 QQ 群: 745157974

给TiDB(MySQL)写一个代理网关

引入数据库网关来优化TiDB Cloud服务运营成本的故事,以及处理MySQL协议的糟心细节
TiDB MySQL serverless

TrueTime和原子钟

2021-02-10
分布式系统 TiDB

价值6万元的TiDB Hackathon创意

2020-12-17
TiDB