Google F1 Schema 变更控制协议详解

Asynchronous Schema Change in F1

最近在学习 TinySQL 的时候, 也就是一个小型的 TiDB, 才知道分布式数据库中对 Schema 变更的特殊处理, 由于之前不了解分布式数据库, 也不熟悉分布式协议, 找了一些资料, 也读了 Google F1 的原论文, 这篇文章记录了一下小白学习的历程以及对 F1 协议的理解.

一些基本概念

这部分是属于论文的 BackGround 的部分的, 因为我对分布式数据库还不是很熟悉, 所以这部分涉及到的知识都会补充说明一下.

分布式数据库

KV 存储引擎

KV 存储也就是 <Key, Value> 键值对的存储形式, 最常见的这种存储形式例如 C++ 中的 std::unordered_map, Java 中的 Map<>. 我们知道, 他们的底层通常是使用红黑树等数据结构实现的. 例如, 在 CMU15445 中实现的可扩展 HashTable 也是一种简单的 KV 存储引擎.

而基于 KV 存储引擎的数据库是指底层使用 KV 键值对的方式存储数据库的基本单元, 这和 CMU15445 中的 BUSTUB 是不一样的. BUSTUB 中, 内存是划分页的, 并且使用 RID 记录内存位置的方式访问数据库基本存储单元的. KV 存储引擎通常必须支持三种类型的操作, get, del, put. 分别对应着查询, 删除, 与插入, 这里我们不详细解释使用 KV 存储引擎的数据库是如何存储底层数据单元的, 后续会说到.

这里需要补充的是, 要使用 F1 Schema 变更协议, 该数据库的 KV 存储引擎需要满足下列两个条件:

  1. Commit timestamps: 每一个 KV 键值对在插入的时候需要记录上次修改的时间片.
  2. KV 存储引擎需要支持 put 以及 get 操作的原子性. 对数据内存访问时涉及到加锁的处理.

Relational Schema

这里原论文的定义如下:

An F1 Schema is a set of table definitions that enable F1 to interpret the database located in the key–value store.

Schema 是数据库表中对数据结构的定义, 也就是表结构, 字段, 索引, 约束, 类型等. 在 F1 协议中, 属性的值有 primitive types(原始类型) 与 complex types(复杂类型), 后续我们会讲到.

Row representation in KV Store

我们知道关系数据库中有行和列的概念, 那么关系数据库中的一行或者一列在 KV 存储引擎是是如何存储的呢? 在 文章 中我介绍了 BUSTUB 中, 数据库对象在内存中的存储结构, 在 BUSTUB 中, 数据的最小存储单元是一个 Tuple, 当然我们可以通过 Tuple 访问到对应的列元素, 但是我们必须先取到这个 Tuple, 才可以访问这个 Tuple 中某个 column 对应的元素.

在使用 KV 存储引擎的数据库中, KV 存储中的最小单元, 一般是:
Key = 表ID + 主键值 + 列ID(或列名)
Value = 该列在该行中的值
我们使用 \(k_r(C)\) 表示行为 \(r\), 列为 \(C\) 的元素的 Key.

F1 协议还支持二级索引, 二级索引由一张表的所有 columns 的子集构成, 除了每一列的 column value, 还有一个特殊的列, exists 用于判断该行数据是否存在, 也就是标志列.

Relational operations

下面的这些是记录操作的标识, 在文章后续会使用到的, 防止后续看不懂, 我们提前说明一下:

  1. \(insert(R, vk_r, vc_r)\): 向表 \(R\) 中插入一行 \(r\), 插入行的主键是 \(vk_r\), 非主键列的值为 \(vc_r\). 如果主键重复, 插入失败.
  2. \(delete(R, vk_r)\): 从数据库表 \(R\) 中删除主键为 \(vk_r\) 的行 \(r\).
  3. \(update(R, vk_r, vc_r)\): 在数据库表 \(R\) 中将主键为 \(vk_r\) 的行的非主键元素更新为 \(vc_r\), 但是不可以更新主键, 如果需要更新主键, 应该先进行删除操作, 再进行插入操作.
  4. \(\operatorname{query}(\vec{R}, \vec{C}, P)\): 查询操作, 返回满足谓语条件 \(P\) 的查询结果.
  5. \(write(R, vk_r, vc_r)\): 我们将插入, 删除, 与更新都记录为 \(write\) 操作, 意味着对数据的改变.
  6. \(delete_S(R, vk_r)\): 使用下标 \(S\) 表示操作在特定的 Schema 下.
  7. \(update^1_S(R, vk_r, vc_r)\): 使用上标 \(1\) 表示事务的编号.
  8. \(query(R, C, vk_r)\): 表示从表 \(R\) 中读取主键为 \(vk_r\) 的这一行的 column 为 \(C\) 的元素.

Concurrency control

F1 使用基于时间片的乐观并发控制协议, 乐观控制并发协议(OOC) 我们在 CMU15445 最后实现过, 这里就不过多叙述了.
F1 中乐观并发协议在实现的时候也是使用 KV 存储模式, 在实现的过程中为一张表的 Schema 新增了列(columns), optimistic locks. 并且, 对于一张表的每一个属性(column), 都会设置一个对应 optimistic lock, 用于控制对该列的并发访问, 并且每一行数据存储的时候也会存储这个 optimistic lock 的实例.

举个例子:
假设我们有一个表如下:

CREATE TABLE Users (
    id INT PRIMARY KEY,
    name STRING,
    email STRING
)

F1 的 Schema 中可能会额外定义两个 optimistic locks:

  1. \(Lock_1\) 覆盖 name
  2. \(Lock_2\) 覆盖 email

那么每一行的数据除了包含 name 和 email 的实际值之外, 还会附带:

  1. \(Lock_1\) 的一个副本(比如时间戳或版本号)
  2. \(Lock_2\) 的一个副本

当两个事务同时修改某一行的 name 字段时, 它们会争用 \(Lock_1\); 当修改的是 email 字段时, 就争用 \(Lock_2\), 互不干扰.

锁的级别:

F1 默认的锁的级别是行级锁, 与大多数数据库一样, 不同点是 F1 支持更加细粒度的控制, F1 支持用户针对 Schema 手动添加列级锁, 默认情况下, 整行只有一个 lock(比如叫 default_row_lock), 所有列都受它控制. 但是用户可以新增一个特殊的列级别的锁 lock_col_A 用于专门控制数据库中的一列 col_A. 这样可以提供更加细粒度的控制, 在修改表的 Schema 的时候, 需要锁定一些列, 而修改另一些列.

F1 Schema 变更协议简介

在了解了一些背景之后, 我们来看一下 F1 Schema 变更协议的简介, 它是解决什么问题的呢, 基本思路是什么样子的呢?
原论文的第一句话是:

We introduce a protocol for Schema evolution in a globally distributed database management system with shared data, stateless servers, and no global membership.

Schema Evolution: 目前 Schema Evolution 是指对表结构, 字段, 索引, 约束, 类型等的变更与修改, 问题是需要支持在变更的时候, 数据没有损失, 并且要保证分布式数据库的 ACID 特性, 因此是一项比较难的任务.

F1 Schema 变更协议是为一个数据共享, 服务器无状态, 并且 no global membership 的全球分布式数据库提供 Schema 变更的功能. F1 协议的一大特点是, 这个协议是异步, 它允许分布式数据库不同的节点在不同的时刻转移到一个新的 Schema 状态, 为了减少状态转移过程中的冲突问题, F1 协议还设计了几个中间状态, 后续会对这些状态做具体的说明.

F1 特性介绍

F1 是一个具有强一致性, 高可用性的全球分布式数据库, F1 在实现的时候是在 Google Spanner 的基础上的, 但是与 Spanner 又有很多不同, 在 F1 中, Spanner 的作用可以看作是 F1 使用的一个 KV 存储引擎. F1 是在 Spanner 的基础上构建的一个关系型数据库, 当然, 本文不会介绍 F1 的整体架构, 仅介绍 F1 中使用的 Schema 变更协议.

F1 有很多的特性, 我们并不会一一介绍, 我们仅介绍一些重要的特点.

Massively distributed: F1 数据库是全球部署的.
Relational Schema: F1 分布式数据库是一个关系型数据库, 支持 SQL 语言查询.
Shared data storage: F1 所有节点存储的数据都在 Spanner 中, Spanner 为 F1 提供了一个强一致读写, 快照读, MVCC 支持的存储引擎.
Stateless servers: 所有的 F1 服务器是无状态的, 这些服务器可能在使用不同的 Schema, 但是并不会因此记录服务器的状态, 因此 F1 也是 No global membership, 也就是说不会有一个全局的状态变更, 因为 Schema 的变更也是异步的.
Asynchronous Schema change: 在每一个服务器中, Schema 的变更是异步的, 也就是说同一时刻 F1 中的服务器可能会使用不同的 Schema.

在进行后续介绍之前, 我们先抛出一个在分布式数据库中, Schema 变更常见的一个问题.

Schema 变更造成的数据冲突

由于所有的服务器都可以访问底层的全量数据, 在 Schema 变更的时候, 不同的服务器可能在使用不同的 Schema, 这就可能导致得到冲突的结果, 我们用下面的例子说明:

假设在一张表 \(R\) 中, 我们希望将其 Schema 从 \(S_1\) 变更为 \(S_2\), 也就是在 Schema 中添加索引列 \(I\). 不同的服务器在使用不同的 Schema 会出现什么样的冲突呢?

  1. 假设此时服务器 \(M_2\) 的 Schema 为 \(S_2\), \(M_2\) 接收到一个事务的请求, 向表 \(R\) 中插入一行, \(r\), 插入之后, 根据 Schema \(S_2\) 会生成改行对应的索引 \(I_r\).
  2. 假设此时服务器 \(M_1\) 的 Schema 为 \(S_1\), \(M_1\) 继续执行该事务的下一行请求, 删除这一行, 由于 \(M_1\) 中的 Schema 并不知道检索的存在, 因此它不会删除检索, 这就导致底层的 KV 存储引擎(Spanner)中多了一行数据的检索项, 出现数据不一致的问题.

F1 是如何解决这个问题的呢?

F1 中为 Schema 设计了多个中间状态, 具体的实现我们后续会介绍, F1 还有一大特点, 那就是 Schema 不仅支持逻辑层面的变更, 新增或者删除一列, 还支持物理层面的变更, 新增索引, 删除索引等. 在 KV 存储引擎中, 索引和表中的数据单元是分开存储的.

Schema 变更

在了解 Schema 变更之前, 我们需要了解一个概念 database representation, 这是一个抽象概念. 在使用 KV 存储引擎的数据库中, database representation 是 KV 存储引擎中所有的 KV pairs. 我们前面看过一行数据在 KV 存储引擎中的数据结构是如何组织的, 这个组织方式就是可以看作 Schema. F1 服务器接收到客户端的请求后, 要将 SQL 语句翻译为对 KV 存储引擎中 KV pairs 的操作, 就需要通过当前内存中的 Schema.

因为 F1 是一个分布式数据库, 它有很多很多个服务器节点, 不可能实现同步更新所有 F1 服务器中的 Schema 版本, 而所有的 F1 服务器又使用同一个 KV 存储引擎, 这就导致 F1 服务器中, 不同的 Servers 在不同的时间点可能在使用不同的 Schema, 却访问相同的数据, 很容易造成读写错误.

为了解决这个问题, F1 协议中, 在进行 Schema 变更的时候为服务器设计了几个中间状态, F1 服务器在 Schema1 与 Schema2 切换之间, 会处于中间状态. 这些中间状态将 Schema 的变更从一个跳跃性的突兀动作变成丝滑的顺序动作. 为了简化流程, 以及正确性的证明, F1 协议限制所有服务器只能处于两种不同的 Schema 状态, 也就是说某一个时刻不可能有第三种 Schema 存在于某一个 F1 服务器中. 当然, 实际上可以支持多个 Schemas, 但是那样设计的协议太过于复杂了.

Schema 的元素状态

在 F1 协议中, Schema 包含了表, 列, 索引, 限制, 以及乐观锁, 这些也即是 Schema 的元素, 在 F1 的某个服务器中, 每个 Schema 元素都有对应的状态. 注意, 这些 Schema 的状态与 Schema 版本本身无关, 是在 F1 中定义的 Schema 元素的状态. 例如, 为一张表添加了一个索引, Schema 版本从 \(S_1\) 变成 \(S_2\), 在不同的 F1 服务器中, Schema 不同的元素可能处于不同的状态.

F1 协议中有两个非中间状态:

  1. Absent: 如果一个元素不存在 Schema 中, 那么这个元素的状态是 Absent.
  2. Public: 如果一个元素存在于 Schema 中, 并且可以支持对这个 Schema 元素的所有操作, 那么这个 Schema 的状态就是 Public.

F1 协议还设计了两个中间状态:

  1. delete-only: 在 F1 服务器中, 处于 delete-only 状态的 Schema 元素对用户是不可读的, 如果这个 Schema 元素是一张表或者表的一列, 那么仅支持用户对这个 Schema 元素对应的 KV pairs 的删除操作, 不支持读操作. 如果这个 Schema 元素是索引, 那么仅支持索引的删除操作与更新操作, 其中, 更新操作本质上分成两步, 删除与插入, 这里仅支持删除步骤.
  2. write-only: 如果某个 Schema 元素处于 write-only 状态, 那么仅支持对这些元素在 KV 引擎中对应的 KV pairs 进行插入, 删除与更新操作, 不支持读操作. 因为不支持读操作, 如果一个索引处于 write-only 的状态下, F1 服务器在检索时不会使用该索引, 也就是不会使用处于 write-only 状态下的索引.

write-only constraint: 这是 Schema 的一种特殊更改, 在 SQL 语言中, 可能会对 Schema 元素新增一些约束, 这些约束会处于 write-only 的状态, 此时, 新插入的元素需要遵循约束, 但是数据库中原有的数据可以不遵循约束.

数据库一致性

这里的数据库一致性指的是 F1 服务器中的 Schema 与 KV 存储引擎中存储的数据是一一对应的, 也就是说 KV 存储引擎中的数据按照 Schema 的格式存储, 并且不存在多余的数据.

我们定义: 一个数据库表示(database representation) 和 Schema \(S\) 是一致的, 当且仅当满足下列条件:

  1. KV 存储引擎中存储的列的 KV pairs 一定属于某张表的某一行. 对于 KV 存储引擎中的每个列类型的 KV pair \(\langle k_r(C), v_r(C) \rangle \in d\), 一定存在 \(\langle k_r(exists), null \rangle \in d\), 并且存在 \(R \in S\) 包含列 \(C\).
  2. 在 Schema 中存在的列, 一定在 KV 存储引擎中存在对应的数据. 对于表 \(R \in S\) 中的每一列 \(C\), 如果 \(\langle k_r(exists), null \rangle \in d\), 那么 \(\langle k_r(C), v_r(C) \rangle \in d\) 一定成立.
  3. KV 存储引擎中存储的索引一定在 Schema 中存在声明, 也就是如果 \(\langle k_r(I), null \rangle \in d\) 成立, 那么表 \(R \in S\) 中一定声明了索引 \(I\).
  4. 如果一张表声明了索引, 也就是 \(R \in S\) 中存在索引 \(I\), 那么 KV 存储引擎中一定存储了每一行的索引项 \(\langle k_r(I), null \rangle \in d\).
  5. 所有的索引项要指向有效的行, 对于每一个索引项 \(\langle k_r(I), null \rangle \in d\) 在 KV 存储引擎中都有对应存在的列的内容 \(\langle k_r(C), v_r(C) \rangle \in d\).
  6. 所有 KV 存储引擎中的 KV pairs 不会和公开的限制冲突, 但是可能会和 write-only constraint 冲突, 这个我们以及说过了.
  7. 没有多余的内容, 也就是没有垃圾内容, 也就是 #1 和 #3 定义之外的内容.

我们使用符号 \(d \models S\) 表示数据库 \(d\) 与 Schema \(S\) 一致的情况, 使用符号 \(d \not \models S\) 表示数据库 \(d\) 与 Schema \(S\) 不一致的情况.

有下列两种不一致的情况:

  1. orphan data anomaly: 数据库的 KV 存储引擎中包含不满足于 Schema \(S\) 的键值对属性, 这些数据也就变成了孤立数据元素. 如果不满足上述定义的 #1, #3, #5, #7, 那么就会出现孤立数据异常.
  2. integrity anomaly: Schema \(S\) 中定义的键值对在数据库 KV 存储引擎中丢失, 也就是 KV 存储引擎缺少数据, 完整性异常. 如果不满足上述的 #2, #4, #6, 那么就会存在完整性异常.

我们用 \(op_S\) 表示在 Schema \(S\) 下的更新, 删除, 插入与查找操作, 每个数据库的操作 \(op_S\) 能够仍然保持该数据库对 Schema \(S\) 的一致性.

为了后续的证明与描述,我们需要先定义 consistency preserving Schema change. 定义如下:
我们称从一次 Schema \(S_1\) 到 Schema \(S_2\) 的变更是一致性保持的, 当且仅当数据库表示 \(d \models S_1\) 并且 \(d \models S_2\).
这也就意味着, 在变更过程中必须满足下列的条件:

  1. 任何在 \(S_1\) 上的操作 \(op_{S_1}\) 仍然能够保证 \(d \models S_2\).
  2. 任何在 \(S_2\) 上的操作 \(op_{S_2}\) 仍然能够保证 \(d \models S_1\).

我们可以用增加一列 \(C\) 的 Schema 变更来反证上述的条件. 假设我们的 Schema 变更 \(S_1\)\(S_2\) 的过程是新增了一列 \(C\), 在 F1 中不同的服务器可能正在使用不同的 Schema, 如果服务器 \(M_2\) 正在使用 \(S_2\), 并且执行了 \(insert_{S_2}(R, vk_r,v_r(C))\) 在 KV 存储引擎中添加了一个键值对 \(\langle k_r(C), v_r(C) \rangle\), 那么该操作后的数据库 \(d' \models S_2\) 但是 \(d' \not \models S_1\). 如果此时, 另一个正在使用 \(S_1\) 的服务器 \(M_1\) 收到一条请求, \(delete_{S_1}(R, vk_r)\), 该请求在 \(M_1\) 执行后, 由于 \(S_1\) 中没有列 \(C\), 最后键值对 \(\langle k_r(C), v_r(C) \rangle\) 没有被删除, 这个键值对就变成了孤立数据异常(orphan data anomaly). 此时, 执行完删除操作后的数据库 \(d'' \not \models S_1\) 并且 \(d'' \not \models S_2\).

因此我们可以得出下面的结论:

Claim 1: 一个从 Schema \(S_1\) 到 Schema \(S_2\) 的数据库 Schema 变更是一致性保持的, 当且仅当从 Schema \(S_2\) 到 Schema \(S_1\) 的数据库变更也是一致性保持的.

接下来我们要推理以及证明, F1 协议是如何保证在一次 Schema 变更中数据库 \(d\) 满足 \(d \models S_1\) 并且 \(d \models S_2\).

Schema 变更中添加或者删除元素

我们首先证明, 不使用 F1 协议, 直接将 Schema \(S_1\) 变更为 \(S_2\) 是不满足上述的 Schema 一致性保持原则的,

Claim 2: 任何一个增加或者删除一个 public Schema 元素的 Schema 变更 \(S_1\)\(S_2\) 不能满足 Schema 变更过程的一致性保持.

证明: 证明过程比较简单, 对于不同的情况下, 我们都可以发现, 变更后的数据库 \(d'' \not \models S_2\) 或者 \(d'' \not \models S_1\) 或者对 \(S_1\)\(S_2\) 均不能保值一致性.

Claim 3: 一个从 \(S_1\)\(S_2\) 的 Schema 变更是一致性保持的, 当且仅当变更期间能够保证数据库 \(d\) 中不存在对 \(S_1\)\(S_2\) 的孤立数据异常(orphan data anomaly) 与完整性异常(integrity anomaly).

这是一个引理, 比较好证明, 反证法就说明了.

接下来我们分别证明在 Schema 变更中添加或者删除不同的元素, 如何保证数据库 \(d\) 满足 \(d \models S_1\) 并且 \(d \models S_2\).

添加可选的结构元素

Optional structural elements(可选结构元素) 是指可以在数据库 Schema 中存在, 但不会强制数据库中每一行或每个键值对都必须包含这些元素的 Schema 元素. 例如新增的一列电话号码, 这一列, 可以有, 也可以没有.

Claim 4: 对于一个从 \(S_1\)\(S_2\) 的 Schema 变更, 如果这个变更添加的是一个仅支持删除操作的 Schema 元素 \(E\). 对于任何一个数据库 \(d \models S_1\), 在变更过程中能够保证 \(d \models S_2\), 并且在元素 \(E\) 上的所有操作 \(op_{S_1}\)\(op_{S_2}\) 都不会使数据库 \(d\) 在变更过程中出现对 \(S_1\)\(S_2\) 的孤立数据异常(orphan data anomaly).

证明:

  1. 无孤立数据异常证明: 我们以插入索引为例, 初始时 Schema \(S_1\) 中没有这个元素 \(E\)(比如没有某个 index), \(S_2\) 中添加了元素 \(E\), 但它是 delete-only, 因为 \(S_1\) 根本不知道有 \(E\). 所以 \(d \models S_1\) 意味着数据库中根本没有和 \(E\) 相关的数据. 而 \(S_2\) 虽然知道 \(E\), 但由于它是 delete-only, 最多做删除操作, 元素只会减少不会增加, 因此不会有 orphan data(因为没人能写这个元素).
  2. 完整性证明: 因为这个 Schema \(E\) 是可选的, 所以不会存在完整性异常问题.

Claim 5: 对于一个从 \(S_1\)\(S_2\) 的 Schema 变更将一个可选的元素 \(E\) 从仅支持删除状态变更为 public 状态. 对于任何一个数据库 \(d \models S_1\), 在变更过程中能够保证 \(d \models S_2\), 并且在元素 \(E\) 上的所有操作 \(op_{S_1}\)\(op_{S_2}\) 都不会使数据库 \(d\) 在变更过程中出现对 \(S_1\)\(S_2\) 的孤立数据异常(orphan data anomaly).

证明:

  1. 无孤立数据异常证明: 此时 \(E\) 已经在 Schema 中以 delete-only 形式存在, 变更后 \(S_1\) 认为它是 delete-only, \(S_2\) 认为它是 public, 所以操作逻辑是兼容的: 无论是 delete-only(\(S_1\)) 还是 public(\(S_2\)) 状态下, 删除行为都会正确处理 \(E\), 不会存在孤立数据(orphan data).
  2. 完整性证明: 因为 public 是允许写入的, 但 delete-only 不允许写入, 所以整个系统还是不会写出与旧 Schema 不兼容的数据, 不会出现完整性异常.

上述说明, 对于一个可选的结构元素(optional structural elements)添加或删除只需要一个中间状态 delete-only, 但是如果反过来, 删除一个可选的结构元素, 不能简单的将上述流程反过来, 还需要增加一个中间状态 reorganization. 当 Schema 已经处于 delete-only 状态后系统需要进行一次数据库重组织(reorganization) 操作, 彻底清理数据库中与该元素相关的 KV pairs. 并且必须在 Schema 正式从 delete-only 变为 absent 前完成这步清理, 否则会导致: 有些服务器用了不允许该元素存在的 Schema(比如已经删除它的 Schema), 但底层还有它的 KV 数据, 导致孤立数据异常(orphan data anomaly).

而对于必需(required)结构元素或约束(constraints), 光有 delete-only 不够, 还需要额外的中间状态保证数据的一致性.

添加必须的结构元素

对于Schema 中的必需元素, 一旦 Schema 中声明它存在, 则数据库中每条记录都必须有对应的 key–value pair, 否则就违反完整性约束(即 integrity violation), F1 协议中通过添加 write-only 中间状态来解决这个问题, 如下:

\[absent \rightarrow delete \ only \rightarrow write \ only \xrightarrow{\text{db reorg}} public \]

Claim 4 以及证明了从 absent 到 delete only 是不会产生数据不一致的. 下面需要证明从 delete-only 到 write-only 也是不会产生数据不一致.

这里需要提醒, 在 F1 协议中, 有一个重要条件, F1 协议限制所有服务器只能处于两种不同的 Schema 状态, 一旦某节点进入 write-only 状态后, 我们可以保证没有节点还停留在 absent 状态.

Claim 6: 对于一个从 \(S_1\)\(S_2\) 的 Schema 变更, 这个变更将一个索引 \(I\) 或者数据库中的一列 \(E\) 从状态 delete-only 置为 write-only. 对于任何一个数据库 \(d \models S_1\), 在变更过程中能够保证 \(d \models S_2\), 并且在元素 \(E\) 上的所有操作 \(op_{S_1}\)\(op_{S_2}\) 都不会使数据库 \(d\) 在变更过程中出现对 \(S_1\)\(S_2\) 的孤立数据异常(orphan data anomaly)和完整性异常(integrity anomaly).

证明: 假设有两个 Schema: \(S_1\), 其中元素 \(E\) 是 delete-only, \(S_2\), 其中元素 \(E\) 是 write-only, 并且当前数据库状态 \(d \models S_1\), 要证明 \(d \models S_2\).

  1. 无孤立数据异常证明: 反证法, 假设元素 \(E\) 是数据库表的一个索引 \(I\). 假设存在一个孤立异常的键值对 \(\langle k_r(E), null \rangle\), 那么这个孤立异常的键值对(orphan key–value pair) 只能来自于 delete 或者 update 操作, 但是 \(delete_{S_1}\)\(update_{S_1}\) 都不会造成孤立数据异常, 而在 \(S_2\) 中, 由于 \(S_2\) 是 write-only 的, 不可以使用索引加速检索, 因此也不会出现孤立数据异常. 这里, 我之前还有一个疑问, 那就是此时 \(S_2\) 插入的新元素的 KV 键值对对于 \(S_1\) 来说是不是孤立数据异常呢? 答案是否, 因为对于 \(S_1\) 来说, 此时新元素并不是 absent 状态, 而是 delete-only 状态, 也就是说, 在 \(S_1\) 中是有新元素的.
  2. 完整性证明: 因为是内部状态, Schema 并不要求这些元素必须存在, 所以不会违反一致性约束, 我们发现内部状态都是不可读的, 所以对于 Schema 来说, 内部状态不都会存在完整性异常.

现在, 我们已经证明了 F1 中的服务器从 delete-only 状态到 write-only 状态能够保证数据库 \(d \models S_1\) 并且 \(d \models S_2\). 接下来只需要证明最后将 Schema 从 write-only 状态转移到 public 的过程中对 \(S_1\)\(S_2\) 也是一致性保持的即可.

Claim 7: 对于一个从 \(S_1\)\(S_2\) 的 Schema 变更, 这个变更将一个数据库元素 \(E\) 从状态 write-only 置为 public. 对于任何一个数据库 \(d \models S_1\), 在变更过程中能够保证 \(d \models S_2\), 并且在元素 \(E\) 上的所有操作 \(op_{S_1}\)\(op_{S_2}\) 都不会使数据库 \(d\) 在变更过程中出现对 \(S_1\)\(S_2\) 的孤立数据异常(orphan data anomaly)和完整性异常(integrity anomaly).

证明:

  1. 无孤立数据异常证明: 和 Claim6 的证明类似, 需要注意的是, 当 F1 的某台服务器中 \(S_2\) 进入 public 的状态的时候, \(S_1\) 应该是 write-only 状态并且包含数据库元素 \(E\), 因此也不会出现孤立数据异常.
  2. 完整性证明: 完整性异常是指操作破坏了 Schema 对结构元素的约束, 如果 \(E\) 是 optional column; 本身就没要求必须存在 key–value 对, 因此不会有一致性问题如果 \(E\) 是 required column 或索引: \(S_1\) 的 write-only 状态就已经要求所有 insert / update 都 必须更新 \(E\) 的 key–value; 而 \(S_2\) 的 public 也同样要求; 因此: 从 write-only 到 public 的过程, 并没有改变操作的要求, 也不会导致一些 "写操作没处理索引/列" 的问题.

根据上面的 Claims 以及对应的证明步骤, 我们可以得到下面的结论:

理论: 对于一个从 \(S_1\)\(S_2\) 的 Schema 变更, 这个变更新增或者删除一个数据库的结构元素 \(E\). 对于任何一个数据库 \(d_1\) 满足 \(d_1 \models S_1\), 在变更过程存在一系列的一致性保持的 Schema 修改操作以及最多一次的数据库重组操作, 这些操作将所有的 F1 服务器转换为 Schema \(S_2\) 并且修改数据库 \(d_1\)\(d_2\), 这一列的操作能够保证 \(d_2 \models S_2\).

证明: 我们使用从 \(S_1\)\(S_2\) 增加一个索引 \(I\) 为例.

  1. 根据 Claim 4, F1 中的某个节点, 将 \(S_1\) 中添加一个索引, 但是状态为 delete-only 得到 \(S'\), 这个 Schema 变化过程中, 任何数据库的变更仍能保持数据一致性, 变更完成后的数据库 \(d' \models S'\).
  2. 当我们执行一个将 \(S'\) 中的索引 \(I\) 从状态 delete only 变成 write only 的 \(S''\) 的时候, 根据 Claim 6, 这个变更过程是一致性保持的, 因此变更得到的数据库 \(d'' \models S''\).
  3. 最后我们要执行数据库 reorganization 操作, 在 \(d''\) 之的基础上插入 \(d_1\) 数据库中的行对应的索引 \(I\) 对应的键值对, 补全索引, 得到 \(d''' \models S'', S_2\), 因为添加对应的索引值不会造成完整性约束异常.
  4. 最后的 Schema 改变将索引 \(I\) 设置为 public, 从 \(S''\) 得到 \(S_2\), 根据 Claim 7 从 write-onlg 到 public 也不会存在完整性异常, 因此 \(d_2 \models S_2\).

对于删除必须的结构型元素则可通过状态变更的逆过程, 并且数据重组在完成delete-only完成之后进行.

Constraints

关于约束(constraints) 的 Schema 变更机制, 应该如何如何安全地添加和删除约束呢, 尤其是唯一性约束(uniqueness constraint) 和外键约束(foreign key constraint), 我们知道:

在不使用中间状态的情况下, 直接添加或删除约束可能会导致一致性异常. 例如当添加了一个唯一性约束(uniqueness constraint); 一部分服务器还在用旧 Schema \(S_1\) (没有这个约束); 用户在旧 Schema 下插入了重复值, 新 Schema 的服务器就会看到违反唯一性约束的数据. 这就会导致所谓的 integrity anomaly(一致性异常).

在 F1 中可以通过添加中状态 write-only 来解决这个问题:

\[absent \rightarrow write \ only \xrightarrow{\text{db reorg}} public \]

在执行过程中, 所有新的写操作(insert, update, delete)必须满足这个约束; 但是不能被读取(对用户和查询不可见), 此时旧的数据就处于 write-only constraint 状态, 所以这个状态下, F1 对外仍然是满足完整性约束的. 最后通过 Database Reorganization 到 Public 的时候, 完成所有旧数据的 Constraint, 整个过程保持完整性约束.

Changing lock coverage

Concurrency control 中我们提到了 F1 是如何进行版本控制的, 以及其中使用的乐观锁, 在 Schema 变更中, 并发控制会存在什么问题呢? 通常来说, 并发控制不会因为 Schema 的变化而改变. 但在 F1 中, 锁的定义是嵌入在 Schema 中的, 用户可以改锁的粒度(例如从行级变为列级锁), 或者改变锁控制的列的范围. 而改变锁的覆盖范围, 本质上就是修改哪列由哪个锁控制. 如果这个过程实现得不对, 就可能允许非串行化的并发调度(non-serializable schedules) —— 也就是说可能导致串行化调度验证失败, 发生数据不一致的问题!

这是一个经典的并发可见性错位(visibility gap)的问题.

假设如下时间线:

时间点 操作
\(Time_1\) \(query_{S_1}^i(R,C,ck_r)\)
\(Time_2\) \(write_{S_2}^j(R, vk_r, c_r(C))\)
\(Time_3\) \(write_{S_1}^i(R, vk_r, c_r(C))\)

因为 \(T_i\)\(T_j\) 持有的是两个不同的锁, \(C\) 的读写不会产生冲突, 因此系统不会阻止它们并发执行!

但实际行为却不是串行的, 因为:

  • 事务 \(T_i\) 读到了旧值;
  • 事务 \(T_j\) 写了新值;
  • 但调度器没法通过锁检测出它们的冲突. 这是因为在事务 \(T_i\) 更新 \(C\) 之前, 事务 \(T_j\) 使用的是 \(S_2\), 它使用 \(L_2\) 来记录时间片, 并没有更新 \(L_1\) 的时间片信息, 导致事务 \(T_i\) 并没有检测到 \(C\) 的改变, 而进行了新一轮的修改, 最后 Commit 的时候仅检测 \(L_1\) 未能检测出冲突.

结果就是违反了可串行性(serializability)!

那么 🛠️ F1 是怎么解决这个问题的?
答案就是: 引入中间态 Dual Coverage + Reorganization


🔁 方案分解: Dual Coverage + Reorganization

✅ Dual Coverage 状态

  • 当一个列 C 要从 \(L_1\) 切换到 \(L_2\),
  • F1 不直接切换, 而是先进入一个中间 Schema(称为 \(S_0\)),
    • 其中 C 被 \(L_1\)\(L_2\) 两个锁同时覆盖(Dual Coverage).

在这个阶段, 任何操作都必须同时对 \(L_1\)\(L_2\) 做读/校验/写.

🔒 所以一个写操作 op(C) 必须:

Validate(timestamp of L1) AND Validate(timestamp of L2)
Update(timestamp of L1) AND Update(timestamp of L2)

这样可以 防止事务只修改了一个锁, 导致另一个锁没有检测出冲突.


🧹 Reorganization(重整过程)

即便有 Dual Coverage, 还要保证两个锁的时间戳是一致的, 否则仍可能漏检.

比如:

  • \(T_i\) 用旧锁 \(L_1\) 做了读操作(获得 \(t_1\))
  • \(T_j\) 更新了 \(L_1\)(变成 \(t_2\))
  • \(T_i\) 再用新 Schema(只验证 \(L_2\))去写入, 因为 \(L_2\) 还停留在 \(t_1\) —— 没被更新过!

所以, 要执行一个 数据库重整(reorganization)过程:

timestamp(L2) = max(timestamp(L1), timestamp(L2))

这个过程会遍历数据库, 把 \(L_2\) 对应的所有 timestamp 更新为 \(L_1\) 中较大的那个之后, 就可以只用 \(L_2\) 来控制并发了.


🔄 完整的 Schema 变更流程

将原始的 Schema change:
\(S_1\) (C 用 \(L_1\)) -> \(S_2\) (C 用 \(L_2\)) 替换为如下序列:

复制编辑S1 ——(Schema change)——> S0 ——(reorg)——> S2
      (L1)                  (L1 + L2)          (L2)

📌 Claim 9 解释总结

Claim 9: 任何 Lock Coverage Change 都可以通过: 一个 Dual Coverage 状态 + 一个 Reorg + 另一个 Schema 变更, 来实现 保持串行性的一致性演化.

证明

  1. \(S_1 \rightarrow S_0\) 过程中, 系统还是只依赖 \(L_1\), 所有调度都能保持和以前一致事务 Commit 的时候串行性保持不变
  2. Reorg 把 \(L_2\) 的 timestamp 同步为 \(L_1\) 的最大值保证了数据一致性.
  3. \(S_0 \rightarrow S_2\) 之后, 虽然只用 \(L_2\), 但因为 timestamp 同步过, 仍能检测旧事务保证了并发一致性.

因此, 整个变更过程在任意时刻都不会引入非串行化调度.


🧠 举个具体的例子

假设你有表 Orders(id, status), status 原本由 \(Lock_1\) 控制.

你现在想用 \(Lock_2\) 来控制 status, 你做如下操作:

  1. status 列变成被 \(Lock_1\)\(Lock_2\) 同时控制(\(S_0\));
  2. 执行 Reorg, 把 \(Lock_2\) 的 timestamp 同步为 \(Lock_1\);
  3. 切换 Schema, 让 status 只受 \(Lock_2\) 控制(\(S_2\));

这样, 不管哪个事务用的是旧 Schema, 新 Schema, 还是在中间状态, 都不会错过冲突的检测.


Google F1 的实现

F1 协议实现的分布式的 SQL 类型数据库是在谷歌的广告系统中使用的, 所以在性能以及功能上要求肯定很高.

Spanner 的作用

谷歌在实现 F1 的 SQL 分布式数据库的时候, 使用 Spanner 作为基础的 KV 存储引擎, 并且还使用到了其中两个重要的特性, 垃圾回收(Garbage Collection) 和 写入围栏(Write Fencing).

垃圾回收(Garbage Collection)

Spanner的作用
  • Spanner 使用 Schema 来描述数据库中允许的键值对的集合. 简而言之, Spanner 知道哪些键值对是当前 Schema 中允许存在的, 哪些不是.
  • 当一个 Schema 变更导致某些结构元素被删除时, 这些元素的数据会变得不可访问. 这时, Spanner 会通过 垃圾回收 清理掉这些无效的键值对.
F1中的应用
  • 在 F1 中, 如果你删除了某些结构元素(比如列或索引), 这些元素对应的键值对会随着 Schema 变更的进行而被清理掉. 这意味着你可以通过从 Spanner 的 Schema 中移除这些元素来 主动删除所有相关的键值对, 而不需要执行额外的数据库重组.
  • 这样, F1 在进行 Schema 变更时无需额外的数据库重组步骤, 因为垃圾回收机制自动处理了元素的删除.
优点
  • 性能提升: 避免了数据库重组带来的性能瓶颈.
  • 简化实现: 由于 Schema 本身就维护了键值对的有效性, F1 可以更容易地实现 Schema 的变更与删除操作.

写入围栏(Write Fencing)

Spanner的作用
  • 写入围栏是 Spanner 的一个关键特性, 它用来控制写操作的提交时机. 写操作通常会受到许多因素的影响, 例如网络延迟或是长时间执行的事务.
  • 如果一个写操作需要很长时间才能提交, Spanner 会确保该写操作不会在多个 Schema 变更之后才生效. 为此, Spanner 会为每个写操作设置 截止时间(deadline), 确保即使写操作没有在规定时间内完成, 它也不会在多个 Schema 版本之间混乱提交.
F1中的应用
  • 在 F1 中, Schema 变更协议要求所有操作只能使用当前 Schema 或最多使用前一个版本的 Schema. 换句话说, 不能有任何操作跨越多个 Schema 版本, 因为这样会导致数据一致性问题.
  • F1 使用 写入围栏 来确保写操作不会在多个 Schema 变更之间被提交. 如果某个写操作没有在设置的时间内完成, 它会被 拒绝 或推迟, 防止跨多个 Schema 版本提交.
优点
  • 保证一致性: 通过写入围栏, 可以确保写操作始终基于 最多两个版本的 Schema, 这保证了数据的一致性和并发控制.
  • 避免数据混乱: 写入围栏避免了 Schema 变更的过程中, 操作可能会跨多个 Schema 执行, 从而引发不一致的状态.

在 F1 中, Schema ChangeSchema Lease 机制是处理数据库模式变更和保证一致性的核心技术之一. 下面我将分开解释这两个机制及其作用.

Schema Change Process

Schema 变更过程

F1 不像传统的数据库那样直接使用 DDL(数据定义语言)操作(如 ALTER TABLEDROP COLUMN)来执行 Schema 变更. 这是因为直接使用 DDL 语句进行每次变更会带来大量的开销, 尤其是在进行数据库重组时(例如删除列, 添加索引等). 相反, F1 采取了一种批量处理的方式来进行 Schema 变更, 通过将多个 Schema 更新操作合并成一个大操作, 从而减少了重复的开销, 并且可以跟踪所有的 Schema 变更记录.

具体操作流程

  1. 版本控制:F1 将整个数据库的 Schema 作为一个协议缓冲区(Protocol Buffers)编码的文件进行存储. 该文件的版本来自于存储在版本控制系统中的源代码.
  2. 变更过程
    • 当需要修改 Schema 时, 用户会在版本控制系统中更新源代码, 加入新的变更.
    • 然后, 每周管理员会从版本控制系统中取出当前的 Schema, 并应用到正在运行的 F1 服务器上, 直接覆盖旧的 Schema, 而不是修改.
    • 在应用新的 Schema 之前, F1 会分析哪些中间状态和数据库重组操作是必要的, 例如添加与删除索引中间状态的顺序不同等, 以确保 Schema 变更是安全的.
  3. 执行过程:一旦确定了所需的中间状态和重组操作, F1 会按顺序执行这些操作, 并确保在任何时刻, 最多只有两个 Schema 版本同时在系统中运行. 这样, 系统可以保证一致性并避免复杂的并发问题.

通过将 Schema 变更批量化, 并通过版本控制管理, F1 可以高效地处理 Schema 更新, 并减少了每次变更的开销和复杂性.


Schema Lease Mechanism

Schema Lease 机制

F1 服务器中还有一个关键的问题, 就是如何保证最多有两个 Schema 在系统中运行呢?

F1 为每个服务器分配了一个 Schema lease(Schema 租约), 这意味着每个 F1 服务器都有一个租约时间, 在这个时间段内, 它可以继续使用当前的 Schema. 如果租约过期, 服务器会终止并重启到健康的节点上. 这个机制解决了 F1 不使用全局服务器成员也可以确保所有的 F1 服务器都能同步更新 Schema.

如何工作

  1. 租约续期
    • F1 服务器每个租约周期都会重新读取存储在 Spanner 中的 Schema 版本.
    • 如果服务器不能成功续期租约, 它会停止工作. 通常情况下, F1 服务器会在健康节点上自动重启, 确保数据库系统的高可用性.
  2. 用户事务
    • 用户事务可以跨越租约续期, 但每个写操作只能使用当前有效租约的 Schema. 如果写操作执行时间超过了两个租约周期, 它可能会违反 F1 的一致性要求(即同时使用多个 Schema 版本的问题).
  3. 写入围栏
    • 为了确保一致性, F1 使用写入围栏(write fencing)机制, 确保任何基于过期 Schema 版本的写操作都无法提交. 这就保证了在任何时刻, F1 系统中最多只能有两个 Schema 版本被同时使用.

Schema Lease 保证

  • 不超过两个 Schema 版本:为了维持一致性, F1 确保在任何时刻, 最多只有两个 Schema 版本在不同的服务器上使用. 这个机制通过控制 Schema 更新的频率来实现, 每个租约周期内只能写入一个新的 Schema 版本.
  • 租约周期:通常, Schema 变更的执行过程需要 10 到 20 分钟, 具体取决于是否涉及数据库重组等操作.

优化

  • 复制与原子性更新:F1 在 Spanner 中将 Schema 存储的副本分布在多个位置, 通过使用 Spanner 的 原子测试和设置(atomic test-and-set)功能来确保所有副本的一致性.
  • 缓存与优化:F1 服务器还缓存了当前 Schema 版本的提交时间戳, 在续期租约时, 如果 Schema 没有变化, 服务器就不需要重新读取 Schema, 从而提升了性能.

以上就是 F1 Schema 变更协议的大致流程, 我们可以总结一下论文具体说了些什么:

  1. 逻辑上的 Schema 变更需要引入中间状态 delete-only, write-only 来保证 Schema 变更过程中数据的一致性.
  2. 特殊情况, 并发控制中, 锁权限的修改需要使用特殊的中间状态
  3. 谷歌在实现 F1 协议时的架构, 使用 Spanner 作为 KV 存储引擎, 以及更新 Schema 的机制, 租约机制等.
posted @ 2025-04-10 23:04  虾野百鹤  阅读(75)  评论(0)    收藏  举报