Lec 12 Spanner
阅读资料
对当时而言, 这篇论文是雄心勃勃。目标非常具有挑战,且用到了一下非常巧妙的做法,在当时在 Google 内部大量使用。
实现目标
- 跨区域分布式事务
- 一致的跨区域复制
- 通过 Paxos 复制数据
一些巧妙的想法:
- 基于 Paxos 的两阶段提交。
- 为了快速读/写事务的时钟同步。
技术的需求背景
Google F1 广告数据库(论文第5.4节),之前通过分片存储在多个 MySQL 和 BigTable 数据库中——非常笨重,需求:
- 更好的(同步)复制。
- 跨分片事务。
- 需要强一致性:
- 外部一致性 / 线性化 / 串行化。
当时情况:工作负载主要由只读事务主导(表6)。
基本架构
- 数据中心 A:
- "客户端"是 web 服务器,例如 Gmail。
- 数据分片存储在多个服务器上:
- a-m
- n-z
- 数据中心 B:
- 拥有自己的本地客户端
- 和自己副本的数据分片
- a-m
- n-z
- 数据中心 C:
- 相同的架构
通过 Paxos 管理复制;每个分片一个 Paxos 组。
- 副本存储在不同的数据中心。
- 类似于 Raft——每个 Paxos 组都有一个Leader。
- 与实验室中的 Paxos类似,Paxos 复制操作日志。
为什么选择这种架构?
- 分片允许通过并行化实现巨大的总吞吐量。
- 不同数据中心的副本能够应对整个站点的故障。
- 客户端可以读取本地副本——快速!
- 这也是驱动设计时间戳和 TrueTime 的原因。
- Paxos 只需要大多数副本——可以容忍缓慢/远程副本。
面临的挑战是什么?
- 完全本地读取会带来一些问题:
- 我们能在不锁定的情况下执行多记录的读取事务吗?
- 如果副本不是 Paxos 大多数的成员,数据可能不是最新的。
- 一个事务可能涉及多个分片 -> 多个 Paxos 组。
Spanner 如何区分读/写事务与只读事务?
读/写事务
示例:银行转账:
sql 复制代码 BEGIN x = x + 1 y = y - 1 END我们不希望在两个操作之间,有任何对 x 或 y 的读取或写入。
提交后,所有读取应该看到我们的更新。
总结: 使用 Paxos 复制的参与者进行两阶段提交(2pc)。
- 客户端选择一个唯一的事务 ID(TID)。
- 客户端将每个读取请求发送到相关分片的 Paxos Leader(2.1)。
- 每个分片首先对相关记录加锁。
- 可能需要等待。
- 每个分片有一个单独的锁表,由分片Leader管理。
- 读取锁不会通过 Paxos 复制,因此Leader失败会导致事务中止。
- 每个分片首先对相关记录加锁。
- 客户端在提交时(4.2.1):
- 选择一个 Paxos 组作为 2pc 事务协调器(TC)。
- 向相关分片Leader发送写入请求。
- 每个写入分片Leader:
- 对写入的记录加锁。
- 通过 Paxos 记录一个“准备”记录,以复制锁和新值。
- 告诉 TC 它已准备好。
- 如果崩溃并丢失了锁表,告知 TC“失败”。
- 事务协调器:
- 决定提交或中止。
- 通过 Paxos 向其组记录决定。
- 将结果告知参与者Leader和客户端。
- 每个参与者Leader:
- 通过 Paxos 记录 TC 的决定。
- 执行写入操作。
- 释放事务的锁。
设计的要点:
- 锁(两阶段锁定)确保了串行化。
- 2pc 被广泛不喜欢,因为如果 TC 失败时锁被持有,则会阻塞。
- 使用 Paxos 复制 TC 可以解决这个问题!
- 读/写事务需要较长时间。
- 许多跨数据中心的消息。
- 表6显示跨美国的读/写事务大约需要 100 毫秒。
- 跨城市只需要 14 毫秒(表3)。
- 但是有大量的并行性:许多客户端,许多分片。
- 因此如果繁忙,总吞吐量可以很高。
只读事务
- 这些事务可能涉及多个读取操作,可能来自多个分片。
- 表6显示99.9%的事务是只读的!
- 因此我们希望它们尽可能快,即使牺牲读/写事务的速度。
- 但仍然保持严格的串行化。
- 因此,所有只读事务的读取必须看似在同一时刻发生。
Spanner 通过以下三种方式消除了只读事务的大部分成本:
- 从本地副本读取,以避免 Paxos 和跨数据中心的消息。
- 但请注意,本地副本可能并不是最新的!
- 无需锁,因此只读事务不会强迫读/写事务等待。
- 无需两阶段提交,无需事务管理器。
- 这再次避免了跨数据中心的消息传递。
- 表3和表6表明,只读事务的延迟比读/写事务少了10倍!
- 这非常重要。
如何在切割这些角落的情况下保证正确性?
只读事务的正确性约束:
- 串行化:
- 结果应当像事务一个接一个执行时那样。
- 即使它们实际上并发执行。
- 例如,某个只读事务必须显式地“夹在”读/写事务之间。
- 看到之前事务的所有写入,而不看到之后的事务。
- 即使是与读/写事务并发执行!
- 且没有锁。
- 结果应当像事务一个接一个执行时那样。
- 外部一致性:
- 如果 T1 在 T2 开始前完成,则 T2 必须看到 T1 的写入。
- 这里的“前”指的是实际的(墙上)时间。
- 这类似于线性化。
- 排除读取过时数据的情况。
为什么不让只读事务仅仅读取最新提交的值?
- 假设我们有两个银行转账和一个同时读取它们的事务:
- T1: Wx Wy C
- T2: Wx Wy C
- T3: Rx Ry
- 结果将无法匹配任何串行顺序!
- 不是 T1, T2, T3。
- 也不是 T1, T3, T2。
- 我们希望 T3 看到 T2 的所有写入,或者根本没有。
想法:快照隔离(SI):
- 同步所有计算机的时钟(与实际墙时同步)。
- 给每个事务分配一个时间戳。
- 读/写事务:提交时间。
- 只读事务:开始时间。
- 我们希望结果看起来像是按时间戳顺序依次执行。
- 即使实际读取发生的顺序不同。
- 每个副本存储每条记录的多个时间戳版本。
- 读/写事务的所有写入都获得相同的时间戳。
- 只读事务的读取看到的是该事务时间戳的版本。
- 即该事务时间戳之前最新的记录版本。
通过快照隔离实现的串行化:
- 只有在时间戳较小的事务(例如 T1)写入之后,时间戳较大的事务(例如 T3)才能读取。
- 因此,串行顺序与时间戳顺序相同。
解决不完全同步的时钟:
- 为了确保外部一致性,事务的时间戳需要满足一定的条件,即 T2 在 T1 完成之后开始。
论文阅读:全球分布式数据库 Spanner
Spanner: Google’s Globally-Distributed Database, OSDI 2012
摘要
Spanner 是谷歌可扩展,多版本,全球分布式并支持同步复制的数据库。它是第一个在全球范围内分布数据并支持外部一致性(externally-consistent)的分布式事务的系统。本文描述了Spanner的架构、功能特性、各项设计决策背后的基本原理/动机,以及一个暴露时钟不确定性的新颖时间API。该API及其实现对实现外部一致性以及一系列强大特性至关重要: 在过去时间点进行非阻塞读取,无锁的只读事务以及跨Spanner示例的原子性的schema变更。
1. 引言
Spanner是一个由Google设计,构建和部署的可扩展的全球分布式数据库。从最高层的抽象来看,它是一个将数据分片存储在全球数据中心的多组Paxos状态机的数据库。复制技术用于实现全球可用性(availability) 和地理位置局部性(geographic locality);客户端可以在副本之间自动故障转移(failover)。随着数据量或服务器数量的变化,Spanner 会自动在机器之间重新分片数据,并且会自动在机器之间(甚至跨数据中心)迁移数据以实现负载均衡和响应故障。Spanner 的设计目标是扩展到数百个数据中心的数百万台机器,支持数万亿行数据库记录。
Spanner 的首个客户是 F1,这是 Google 广告后台的重写版本。F1 使用了分布在美国境内的五个副本。大多数其他应用则可能会选择在一个地理区域内的 3 到 5 个数据中心之间复制数据,这些数据中心具有相对独立的故障模式。换句话说,大多数应用会在更低延迟与更高可用性之间选择前者,只要能在 1 至 2 个数据中心故障的情况下继续运行。
Spanner 的主要关注点是管理跨数据中心复制的数据。尽管许多项目都乐于使用 Bigtable [9],但我们也经常收到用户的抱怨,说 Bigtable 对某些类型的应用程序来说可能难以使用。 具体来说,具有复杂、和不断演进的schema的,或是在大范围复制情况下需要强一致性的应用程序。Google 的许多应用程序还是选择使用了 Megastore [5] , 虽然它写入吞吐量相对较差,但由于其半关系型数据模型和对同步复制的支持。因此,Spanner 已经从类似 Bigtable 的版本化键值存储演变成了一个时态多版本数据库。数据存储在模式化的半关系型表中;数据是版本化的,每个版本都会自动用其提交时间打上时间戳;旧版本的数据受可配置的垃圾回收策略管理;应用程序可以读取旧时间戳的数据。Spanner 支持通用事务,并提供基于 SQL 的查询语言。
作为一个全球分布式数据库,Spanner 提供了几个有趣的特性。首先,应用程序可以在细粒度层面上动态控制数据的复制配置。应用程序可以指定约束条件来控制哪些数据中心包含哪些数据、数据距离用户有多远(以控制读取延迟)、副本之间的距离有多远(以控制写入延迟),以及维护多少个副本(以控制持久性、可用性和读取性能)。系统还可以动态且透明地在数据中心之间移动数据以平衡数据中心之间的资源使用。
其次,Spanner 具有两个在分布式数据库中难以实现的特性:它提供外部一致性 [16] 的读写操作,以及在某个时间戳上对整个数据库进行全局一致性读取的能力。这些特性之所以能够实现,是因为尽管事务可能是分布式的,但 Spanner 为事务分配了具有全局意义的提交时间戳。这些时间戳反映了序列化顺序。此外,序列化顺序满足外部一致性(或等效地,线性化 [20]):如果事务 T1 在另一个事务 T2 开始之前提交,那么 T1 的提交时间戳小于 T2 的时间戳。Spanner 是第一个在全球范围内提供此类保证的系统。
这些特性的关键是新的 TrueTime API 及其实现。该 API 直接暴露时钟不确定性,Spanner 时间戳的保证取决于实现提供的界限。如果不确定性较大,Spanner 会放慢速度以等待不确定性消失。Google 的集群管理软件提供了 TrueTime API 的实现。通过使用多个现代时钟参考(GPS 和原子钟),该实现将不确定性保持在较小的范围内(通常小于 10ms)。
各部分内容安排如下:
- 第 2 节:介绍 Spanner 的实现结构、功能特性和设计决策;
- 第 3 节:描述新的 TrueTime API 及其实现概览;
- 第 4 节:讲述 Spanner 如何利用 TrueTime 实现外部一致性事务、无锁只读事务和原子模式更新;
- 第 5 节:展示 Spanner 的性能基准测试与 TrueTime 的行为表现,并分享 F1 项目的使用经验;
- 第 6、7、8 节:探讨相关工作、未来方向,并总结全文。
2. 实现
本节描述了Spanner的实现的结构及其背后的基本原理。然后描述了目录(directory)抽象,它用于管理复制和局部性,并且是数据移动的基本单位。最后,描述了我们的数据模型、解释了为什么Spanner看起来像是关系型数据库而不是键值存储,以及应用程序如何控制数据局部性。
Spanner 的一次部署被称为一个"宇宙"(universe)。鉴于 Spanner 在全球范围内管理数据,运行中的universe数量将会很少。我们目前运行着一个测试/试验宇宙、一个开发/生产宇宙和一个仅用于生产的宇宙。
Spanner 被组织为一组区域(zone),其中每个区域大致类似于 Bigtable 服务器 [9] 的一次部署。区域是管理部署的基本单位。区域集合同时也是数据可以在其间进行复制的位置集合。当新的数据中心投入使用或旧的数据中心关闭时,可以向运行中的系统添加或移除区域。区域也是物理隔离的单位:例如,在同一个数据中心中可能有一个或多个区域,这种情况出现在不同应用程序的数据必须在同一数据中心的不同服务器集合之间进行分区时。

图一展示了Spanner universe中的服务器的组织结构。一个区域有一个区域主控(zonemaster),和100到几千个spannerver。前者负责将数据分配给spanserver; 后者负责将客户端提供数据服务。每个区域的位置代理(location proxy)被客户端用来定位被分配来服务其数据的spanserver。
宇宙主控服务器(universe master)和位置驱动器(placement driver)目前是单例的。universe master主要是一个控制台,用于显示所有区域的状态信息以进行交互式调试。位置驱动器处理跨区域的自动数据移动,这种移动的时间尺度是分钟级的。位置驱动器会定期与 spanserver 通信,以找出需要移动的数据,这种移动可能是为了满足更新后的复制约束或者是为了平衡负载。
由于篇幅原因,我们将只详细描述 spanserver。
2.1 Spanserver软件栈

本节重点介绍spanserver的实现,以说明如何将复制和分布式事务层叠在我们基于Bigtable的实现上。软件栈如图2,在最底层,每个spanserver负责管理100到1000个称为tablet的数据结构实现。类似于Bigtable的tablet抽象,它实现了以下映射的集合:
与 Bigtable 不同的是,Spanner 为数据分配时间戳,这是 Spanner 更像多版本数据库而不是键值存储的一个重要特征。tablet的状态存储在一组B-tree-like的文件和预写日志中,这些文件都位于名为Colossus的分布式文件系统上(这是GFS的继任者)
为了支持复制,每个spanserver在每个tablet之上实现了一个Paxos状态机。(Spanner的早期版本在每个tablet上支持多个Paxos状态机,这允许更灵活的复制配置。但该设计的复杂性让我们放弃了。)每个状态机将其元数据和日志存储在其对应的tablet中。我们的Paxos实现支持基于时间的长期Leader租约,每个租约为10秒。当前的Spanner实现对每个Paxos写入两次日志记录:一次在tablet日志中,一次在Paxos日志中。这个选择处于权宜之计,我们可能最终会改进。我们的Paxos实现是流水线式的,以便在广域网延迟的情况下提高Spanner的吞吐量:但是写入是通过Paxos按顺序生效的(这是我们在第 4 节中将依赖的一个事实)。
Paxos 状态机用于实现一个一致复制的映射集合。每个副本的键值映射状态存储在其对应的 tablet 中。写操作必须在 leader 发起 Paxos 协议;读操作则可以直接从任意一个足够新的副本底层 tablet 读取状态。这些副本的集合共同构成一个 Paxos 组(Paxos group)。
在每个作为Leader的副本上,每个 spanserver 都实现了一个锁表(lock table)来实现并发控制。锁表中保存着两阶段锁(two-phase locking)的状态信息:它将键区间(ranges of keys)映射到锁状态(lock states)。(注意,让 Paxos leader 长时间存活对于高效管理锁表是至关重要的)。在 Bigtable 和 Spanner 中,我们都需要支持长事务(long-lived transactions)(例如生成报表的操作,可能持续几分钟),而这种事务在存在冲突时,如果采用乐观并发控制(OCC)会表现很差。因此,那些需要同步的操作(例如事务性读操作)会在锁表中获取锁; 而其他不需要同步的操作则会绕过锁表。
在每一个作为 leader 的副本 上,每个 spanserver 还实现了一个 事务管理器(transaction manager),用于支持分布式事务。事务管理器用于实现一个 参与者 leader(participant leader); 而该组中的其他副本则称为 参与者从节点(participant slaves)。如果一个事务只涉及单个 Paxos 组(大多数事务属于这种情况), 它可以绕过事务管理器,因为锁表和 Paxos 一起就能保证事务性。但如果一个事务涉及多个 Paxos 组,这些组的 leader 之间就需要协调, 执行 两阶段提交(2PC)。在这些参与者组中,会选出一个组作为 协调者(coordinator): 该组的 leader 被称为 协调者 leader(coordinator leader), 而该组的从节点称为 协调者从节点(coordinator slaves)。每个事务管理器的状态都会存储在其底层的 Paxos 组中, 因此,这些状态信息同样是被复制的(replicated)。
2.2 目录与数据放置
在KV集合之上,Spanner提供了一种称为目录(Directory)的分桶抽象(bucketing abstraction),一个目录是一组具有相同前缀的连续键;支持目录机制的好处是:应用可以通过精心选择键来控制数据的局部性。
一个目录中的所有数据具有相同的复制配置(replication configuration)。当数据在Paxos组之间移动时,Spanner是以目录为基本单位移动的。图3所示。Spanner 可能出于以下原因移动某个目录:
- 从一个 Paxos 组中转移部分负载;
- 将经常一起被访问的目录放到同一个组里;
- 或者将目录移动到更靠近访问者的组中。
目录的移动不会中断客户端操作 一个 50MB 大小的目录通常可以在几秒内完成迁移。

由于一个 Paxos 组中可能包含多个目录,这意味着: Spanner 的 tablet 不同于 Bigtable 的 tablet。Bigtable 的 tablet 通常是按字典序连续的一个键空间分区,而 Spanner 的 tablet 是一个容器(container),它可以封装多个行空间分区(partitions of the row space)。我们做出这种设计,是为了能够将多个经常一起被访问的目录共置(colocate)到同一 tablet 中。
Movedir 是 Spanner 的后台任务,用于在 Paxos 组之间移动目录。 它还用于增加或删除 Paxos 组的副本, 因为目前 Spanner 还不支持 Paxos 内部的动态配置更改。Movedir 不是一个单独的事务,以避免在大规模数据移动时阻塞正在进行的读写操作。相反,Movedir 首先注册一个“即将移动”状态,然后在后台逐步移动数据。当绝大部分数据已经迁移完毕,只剩下少量数据时,它会使用一个事务原子性地完成剩余数据的移动, 并更新两个 Paxos 组的元数据。
一个目录也是应用可以单独指定地理复制属性(geographic-replication properties)的最小单位, 简称为目录的 放置(placement)。
Spanner 的放置规格语言(placement-specification language)将复制配置的管理职责划分为两部分:
系统管理员(administrators)控制以下两个维度:
- 副本的数量与类型;
- 副本的地理放置位置。 他们会定义这些维度下的命名选项(例如: “North America, replicated 5 ways with 1 witness”——北美地区,5 个副本,1 个见证节点)。
应用程序(applications)通过给数据库或单个目录打标签, 使用这些选项的组合,来控制数据如何被复制。
例如,一个应用可以将每个用户的数据放在单独的目录中:这样用户 A 的数据可以有三个副本位于欧洲,而用户 B 的数据可以有五个副本位于北美。为了便于说明,上述描述进行了简化。 实际上,如果一个目录增长过大,Spanner 会将其分片(shard)成多个片段(fragments)。 这些片段可以由不同的 Paxos 组(也就是不同的服务器)提供服务。 因此,Movedir 实际上移动的单位是目录片段(fragments),而不是整个目录。
4. 并发控制
我们介绍TrueTime如何确保正确性,且如何实现外部一致性、无锁只读事务和非阻塞历史读。这些特性可以在时间戳t进行全库审计读取(audit read):将精确反映出截止时间t已经提交的所有事务的效果。
接下来,需要区分两种写操作。Paxos写入,是指Paxos协议层执行的写操作。Spanner客户端写入,是指用户或应用层发起的写操作。
4.1 时间戳管理

表2列出了Spanner支持的操作类型。Spanner支持读写事务、只读事务(预声明的快照隔离事务)、和快照读——对过去时间点的只读操作。前两者系统自动重试,客户端你无需编写重试逻辑。
只读事务(Read-Only Transactions)是一种享有快照隔离性能优势的事务,必须预先声明该事务不会有写操作。制度实物中的所有读操作在系统选定的某个时间戳下执行,无需加锁,因此不会阻塞正在执行的写入操作。这种事务可以在任意一个足够新的副本上执行
快照读(Snapshot Reads)是指过去的某个时间点的只读操作,也不需要加锁。客户端可以直接指定时间戳,或者指定最大允许的时间滞后,由Spanner自行选择时间戳。无论那种,都需要在足够新的副本上执行。
对于只读事务和快照读来说,一旦时间戳被确定,提交是不可避免的。除非该时间戳对应的数据已被垃圾回收(GC)。因此客户端可以避免在重试循环中缓冲结果。当服务器发生故障时, 客户端只需在另一个服务器上使用相同的时间戳和当前读取位置继续查询即可。
4.1.1 Paxos Leader租约
Spanner 的 Paxos 实现使用定时租约来确保leader能长时间存活(默认10秒)。潜在的 leader 会发送请求以获取租约投票;当它获得了法定多数(quorum)的租约投票后,就知道自己拥有租约。副本在每次写入时会隐式延长它的租约投票,而当租约快到期时,leader 会请求延长租约投票。 leader的租约间隔从获得多数投票开始到失去多数投票结束。Spanner 依赖以下 不相交不变式(disjointness invariant):即任意两个leader的租约区间是互不重叠的。(附录A有描述如何保证不变式)
Spanner 允许 Paxos leader 主动退位(abdicate), 方法是让其从节点释放对它的租约投票,但是Spanner限制了退位的时机。我们定义
4.1.2 为读写事务分配时间戳
事务性的读写操作使用2PL,因此,在一个事务中,系统可以在所有锁都已获得、但尚未释放时分配时间戳。 因此,在一个事务中,系统可以在所有锁都已经获得、但未释放时分配时间戳。对于某个事务,Spanner会将其提交时间戳设置为Paxos写入的时间戳。
Spanner依赖以下单调性不变式: 在每个Paxos组内,所有Paxos写入的时间戳必须单调递增,即使换了新的Leader,也要保持递增。对于单一Leader节点,这个要求是显然成立的;而在多个leader之间(即leader更换的场景下)这个不变式利用租约区间不重叠得以保证。每个Leader只能在其租约时间区间内分配时间戳。此外,每当一个新时间戳 s 被分配时, 系统会将 s,以维持区间不重叠的保证。
Spanner还需要保持以下外部一致性不变式: 如果事务T2的开始时间在事务
为保证上述不变式,Spanner 的事务执行与时间戳分配遵循以下两条规则。设事务
Start规则 协调者Leader在收到提交请求(即
也就是说,事务的提交请求时间戳必须不早于当前的TrueTime上确界。
Commit Wait 规则 协调者Leader必须确保,客户端在TrueTime确认已经过了提交时间戳
证明:
4.1.3 在给定时间戳上提供读服务
前一节提到的单调性不变式使 Spanner 能够判断一个副本是否足够新,从而是否可以在某个时间戳 t 上提供一致的读取。