前言
本文不是一篇Spanner的介绍文章,主要想对于Spanner和F1解决的几个有代表性的问题做一个概括和梳理。接下来的行文安排将主要以问答的形式展开。对Spanner和F1不熟悉的盆友可以参考最后一节列出的引用。
Spanner概览
Spanner做到了什么
严格的CP系统以及远超5个9的可用性
基于2PC协议的内部顺序一致性
外部一致性,支持读写事务、只读事务、快照读
全球化的分布式存储系统,延迟是能够接受的
基于模式化的半关系表的数据模型
Spanner的数据模型
directory是什么
在一系列键值映射的上层,Spanner 实现支持一个被称为“目录”的桶抽象,也就是包含公共前缀的连续键的集合。一个目录是数据放置的基本单元。属于一个目录的所有数据,都具有相同的副本配置。 当数据在不同的 Paxos 组之间进行移动时,会一个目录一个目录地转移。
directory和tablet的关联
一个 Paxos 组可以包含多个目录,这意味着一个 Spanner tablet 是不同于一个 BigTable tablet 的。一个 Spanner tablet 没有必要是一个行空间内按照词典顺序连续的分区,相反,它可以是行空间内的多个分区。这样做可以让多个被频繁一起访问的目录被整合到一起,也就是说多个directory会被映射到一个tablet上。
Spanner中的表
每个表都和关系数据库表类似,具备行、列和版本值。每个表都需 要有包含一个或多个主键列的排序集合。主键形成了一个行的名称,每个表都定义了从主键列到非主键列的映射。当一个行存在时,必须要求已经给行的一些键定义了一些值(即使是 NULL)。
如何描述表之间的关联
Spanner中的表具有层次结构,这有些类似于传统关系数据库中一对多关系。户端应用会使用 INTERLEAVE IN 语句在数据库模式中声明这个层次结构。这个层次结构上面的表,是一个目录表。目录表中的每行都具有键 K,和子孙表中的所有以 K 开始(以字典顺序排序)的行一起,构成了一个目录。这样做是想把想关联的表放在同样的位置,利用数据的局部性提高性能,毕竟单个Paxos组内的操作比跨paxos组要来的高效。
Spanner的软件架构
universe和zone的概念
一个 Spanner 部署称为一个 universe。假设 Spanner 在全球范围内管理数据,那么,将会只有可数的、运行中的 universe。我们当前正在运行一个测试用的 universe,一个部署/线上用的 universe 和一个只用于线上应用的 universe。
Spanner 被组织成许多个 zone 的集合,每个 zone 都大概像一个 BigTable 服务器的部署。 zone 是管理部署的基本单元。zone 的集合也是数据可以被复制到的位置的集合。当新的数据中心加入服务,或者老的数据中心被关闭时,zone 可以被加入到一个运行的系统中,或者从中移除。zone 也是物理隔离的单元,在一个数据中心中,可能有一个或者多个 zone, 例如,当属于不同应用的数据必须被分区存储到同一个数据中心的不同服务器集合中时,一个数据中心就会有多个 zone 。
sapnner与paxos
spanner数据的实际存储依靠于分布式文件系统Colossus,在一个存储分区分区单元tablet上运行一个Paxos状态机,基于Paxos实现备份和容错。我们的 Paxos 实现支持长寿命的领导者(采用基于时间的领导者租约),时间通常在 0 到 10 秒之间。当前的 Spanner 实现中,会对每个 Paxos 写操作进行两次记录:一次是写入到 tablet 日志中,一次是写入到 Paxos 日志中。
Paxos的领导者租约
Spanner 的 Paxos 实现中使用了时间化的租约,来实现长时间的领导者地位(默认是 10秒)。一个潜在的领导者会发起请求,请求时间化的租约投票,在收到指定数量的投票后,这个领导者就可以确定自己拥有了一个租约。一个副本在成功完成一个写操作后,会隐式地延期自己的租约。对于一个领导者而言,如果它的租约快要到期了,就要显示地请求租约延期。另一个领导者的租约有个时间区间,这个时间区间的起点就是这个领导者获得指定数量的投票那一刻,时间区间的终点就是这个领导者失去指定数量的投票的那一刻(因为有些投 票已经过期了)。Spanner 依赖于下面这些“不连贯性”:对于每个 Paxos组,每个Paxos领导者的租约时间区间,是和其他领导者的时间区间完全隔离的。
为了保持这种彼此隔离的不连贯性,Spanner 会对什么时候退位做出限制。把 smax 定义为一个领导者可以使用的最大的时间戳。在退位之前,一个领导者必须等到 TT.after(smax)是真。
上面是论文中关于领导着租约的解释,这里我们的问题是为什么需要一个长期的leader lease,不加入这个特性是否可以?下面是我个人的一些看法:
Multi-Paxos协议本身在只有一个proposer的时候,对于每个instance可以将两阶段变为一阶段,提高Paxos协议的效率。当然这只是微小的一方面,毕竟谷歌使用的Paxos实现应该是标准的变体。
Leader变动频率下降有助于减少在操作中查询leader的次数,有助于减轻元信息管理服务的压力
这种做法的前提必定是故障的频率低,如果故障时常发生,一个较长的租约时间将使得故障的发现和处理变慢
Spanner的并发控制
True Time和外部一致性
外部一致性保证了什么
如果一个事务 T2 在事务 T1 提交以后开始执行, 那么,事务 T2 的时间戳一定比事务 T1 的时间戳大。
True Time 提供了什么
TrueTime 会显式地把时间表达成 TTinterval,这是一个时间区间,具有有界限的时间不确定性。表示一个事件 e 的绝对时间,可以利用函数 tabs(e)。如果用更加形式化的术语,TrueTime 可以保证,对于一个调用 tt=TT.now(),有 tt.earliest≤tabs(enow)≤tt.latest,其中, enow 是调用的事件。
这里需要再问一个问题:
每个节点在同一时刻调用TT.now()等到的区间是相同的吗?显然不是,如果那样不就等同于得到一个全球相同的时间点了,但这不影响外部一致性正确的证明。
如何基于True Time提供外部一致性
我们先来讲做法:是基于时间戳分配和commit wait实现的。
Start. 为一个事务 Ti 担任协调者的领导者分配一个提交时间戳 si,不会小于 TT.now().latest 的值,TT.now().latest的值是在esierver事件之后计算得到的。要注意,担任参与者的领导者, 在这里不起作用。第 4.2.1 节描述了这些担任参与者的领导者是如何参与下一条规则的实现的。
Commit Wait. 担任协调者的领导者,必须确保客户端不能看到任何被 Ti 提交的数据,直到 TT.after(si)为真。提交等待,就是要确保 si 会比 Ti 的绝对提交时间小。
那么证明如下:
读事务
读事务可以分为只读事务读当前最新的值,以及快照读。前者可以通过分配时间戳转变为后者,所以我们先讨论快照读的实现。
快照读的实现
首先问一个问题,读必须走Paxos Group 的Leader吗?不是的,首先在同一个Paxos Group内,写操作时间戳的单调性是毫无疑问的,那么只需要找到足够新的副本。这就有一个新问题:
如何判断一个副本是否足够新?
每个副本都会跟踪记录一个值,这个值被称为安全时间 tsafe,它是一个副本最近更新后的最大时间戳。如果一个读操作的时间戳是 t,当满足 t<=tsafe 时, 这个副本就可以被这个读操作读取。这种情形下小于t的写操作必定已经被该副本catch up了(基于Paxos协议)。
如何维护tsafe
这一小节内容请先参阅读写事务部分。
tsafe可以从两个值推导而来,Paxos状态机最大的apply操作的时间戳(这一部分象征着已经生效的写),和事务管理器决定的tasfe(TM),tsafe=min(tsafe(Paxos),tsafe(TM)),后者是什么意思呢?
如果现在该replica没有参与任何事务中,那么理应这一部分不造成任何影响,所以tsafe(TM)=无穷大。(记住tsafe越大,读越容易通过)。如果当前replica参与了一个读写事务Ti,那么本Paxos组的leader会分配一个准备时间戳(见后文读写事务),那么理应tsafe比所有正在参与的事务Ti的准备时间戳都小,并且是满足这个条件时间戳里的最大一个。答案呼之欲出了。
但是,上述的方法依然存在问题,一个未完成的事务将阻止tsafe增长,之后的以有读事务将被阻塞,即使它读的值和该事务写的值没啥关系。这里有点类似于Java对ConcurrentHashMap做的优化了——分段加锁,好了,我们继续接着往下看。我们可以对一个范围的key range来维护参与事务的准备时间戳,那么对于一个读操作,只用检查和它冲突的key range的准备时间戳就好。
tsafe(Paxos)也有一个问题,那就是缺乏Paxos写的时候,也没法增长。如果上次的apply时间在读被分配的时间戳t之前,接下来没有Paxos写的话,快照读t没法执行,这就尴尬了。可以为每一个instance n预估一个将分配给n+1的时间戳。那这里要有一个问题了,预估的这个时间需要满足什么条件呢?个人认为首先是要超过时间戳的最大误差值。
如何为只读事务分配时间戳?
之前提到过对于只读事务可以通过分配时间戳来转化为快照读,那么该如何分配呢,首先看这种分配需要满足什么?
满足写后读一致性,也就是其分配的时间戳要大于所有已提交事务的时间戳
在一个事务开始后的任意时刻,可以简单地分配 sread=TT.now().latest。但这样有一个问题,对于时间戳 sread 而言,如果 tsafe 没有增加到足够大,可能需要对 sread 时刻的读操作进行阻塞。择一个 sread 的值可 能也会增加 smax 的值,从而保证不连贯性。为了减少阻塞的概率,_Spanner 应该分配可以保持外部一致性的最老(小)的时间戳_。
分配一个时间戳需要一个协商阶段,这个协商发生在所有参与到该读操作中的 Paxos 组之间。其过程如下:
对于单个Paxos组内的读操作,把 LastTS()定义为在 Paxos 组中最后提交的写操作的时间戳。如果没有准备提交的事务,这个分配到的时间戳 sread=LastTS()就很容易满足外部一致性要求
对于跨Paxos组的读操作,最复杂的做法是基于多Paxos Leader的LastTS()协商得出;但实际采用的简单做法是采用TT.now().latest,允许某种程度上的阻塞。
写事务
无论事务读不读,都按读写事务来处理
单Paxos组内的读写事务
这种情况下只需要给读写事务分配时间戳,进行快照读,最后发生在一个事务中的写操作会在客户端进行缓存,直到提交。由此导致的结果是,在一个事务中的读操作,不会看到这个事务的写操作的结果。这种设计在 Spanner 中可以很好地工作,因为一个读操作可以返回任何数据读的时间戳,未提交的写操作还没有被分配时间戳。
至于读写事务如何分配时间戳,请参照True Time一章。
多Paxos组的读写事务
客户端对需要读的Paxos组Leader申请加读锁,按分配时间戳读取最新数据。
客户端向leader持续发送“保持活跃信息“,防止leader认为会话过时
读操作结束,缓冲所有写操作,开始2PC,选择协调组发送缓冲信息
对于参与协调的非领导者而言,获取写锁,会选择一个比之前分配给其他事务的任何时间戳都要大的预备时间戳,并且通过 Paxos 把准备提交记录写入日志。然后把自己的准备时间戳通知给协调者。
对于协调者,也会首先获得写锁,但是,会跳过准备阶段。在从所有其他的、扮演参与者的领导者那里获得信息后,它就会为整个事务选择一个时间戳。这个提交时间戳s必须大于或等于所有的准备时间戳,并且s应该大于这个领导者为之前的其他所有事务分配的时间戳,之后就会通过 Paxos 在日志中写入一个提交记录。
在允许任何协调者副本去提交记录之前,扮演协调者的领导者会一直等待到 TT.after(s),满足提交等待条件。
在提交等待之后,协调者就会发送一个提交时间戳给客户端和所有其他参与的领导者。每个参与的领导者会通过 Paxos 把事务结果写入日志。所有的参与者会在同一个时间戳进行提交,然后释放锁。
注意,这里的每个协调者其实都是一个Paxos组,2PC本身是一个反可用性的协议(也就是它要求没有故障才能顺利完成),但是Paxos协议能够在协调者故障时快速选出新主,由于信息已经通过Paxos日志同步了,新主可以继续参与2PC过程,提升了可用性。
模式变更事务
模式变更的难点是什么?
原子模式变更。使用一个标准的事务是不可行的,因为参与者的数量(即数据库中组的数量)可能达到几百万个。在一个数据中心内进行原子模式变更,这个操作会阻塞所有其他操作。
Spanner的做法
一个 Spanner 模式变更事务通常是一个标准事务的、非阻塞的变种。首先,它会显式地分配一个未来的时间戳,这个时间戳会在准备阶段进行注册。由此,跨越几千个服务器的模式变更,可以在不打扰其他并发活动的前提下完成。其次,读操作和写操作,它们都是隐式地依赖于模式,它们都会和任何注册的模式变更时间戳t保持同步:当它们的时间戳小于 t 时, 读写操作就执行到时刻 t;当它们的时间戳大于时刻 t 时,读写操作就必须阻塞,在模式变更事务后面进行等待。
那么,接下来又有几个问题:
如何为模式变更选择时间戳?选择的时间戳需要满足哪些条件?
上述的方法会造成服务不可用吗?如果是,不可用的时间区间是多少?
两次模式变更之间在重合的时间段里有重合组的时候,通过什么方式协调?乐观还是悲观的并发控制?会造成死锁吗,会导致在分配的时间戳之后无法按时完成吗?还是说在准备阶段就侦测这种情况,阻塞下一次模式变更?
关于模式变更的细节在spanner的论文中并没有详细提及,而是在另一篇文章中阐述,如果之后有时间的话会单独就模式变更再写一篇博客来回答这些问题。
Sapnner和CAP原理的讨论
CAP原理存在的误导
“三选二”的观点在几个方面起了误导作用,
首先,由于分区很少发生,那么在系统不存在分区的情况下没什么理由牺牲C或A。
其次,C与A之间的取舍可以在同一系统内以非常细小的粒度反复发生,而每一次的决策可能因为具体的操作,乃至因为牵涉到特定的数据或用户而有所不同。
最后,这三种性质都可以在程度上衡量,并不是非黑即白的有或无。可用性显然是在0%到100%之间连续变化的,一致性分很多级别,连分区也可以细分为不同含义,如系统内的不同部分对于是否存在分区可以有不一样的认知。
CAP理论的经典解释,是忽略网络延迟的,但在实际中延迟和分区紧密相关。CAP从理论变为现实的场景发生在操作的间歇,系统需要在这段时间内做出关于分区的一个重要决定:
取消操作因而降低系统的可用性,还是
继续操作,以冒险损失系统一致性为代价
依靠多次尝试通信的方法来达到一致性,比如Paxos算法或者两阶段事务提交,仅仅是推迟了决策的时间。系统终究要做一个决定;无限期地尝试下去,本身就是选择一致性牺牲可用性的表现。
CAP理论经常在不同方面被人误解,对于可用性和一致性的作用范围的误解尤为严重,可能造成不希望看到的结果。如果用户根本获取不到服务,那么其实谈不上C和A之间做取舍,除非把一部分服务放在客户端上运行。
“三选二”的时候取CA而舍P是否合理?已经有研究者指出了其中的要害——怎样才算“舍P”含义并不明确。设计师可以选择不要分区吗?哪怕原来选了CA,当分区出现的时候,你也只能回头重新在C和A之间再选一次。我们最好从概率的角度去理解:选择CA意味着我们假定,分区出现的可能性要比其他的系统性错误(如自然灾难、并发故障)低很多,打个比方你在单机下就永远不会假设分区,同一机架内部的通信有时我们也认为分区不会出现。
Spanner声称同时达到CA
纯粹主义的答案是“否”,因为网络分区总是可能发生,事实上在Google也确实发生过。在网络分区时,Spanner选择C而放弃了A。因此从技术上来说,它是一个CP系统。我们下面探讨网络分区的影响。考虑到始终提供一致性(C),Spanner声称为CA的真正问题是,它的核心用户是否认可它的可用性(A)。如果实际可用性足够高,用户可以忽略运行中断,则Spanner是可以声称达到了“有效CA”的。
实际上,c差异化可用性与spanner的实际可用性是有差距的,即用户是否确实已经发现Spanner已停掉了。差异化可用性比Spanner的实际可用性还要高,也就是说,Spanner的短暂不可用不一定会立即造成用户的系统不可用。
另外就是运行中断是否由于网络分区造成的。如果Spanner运行中断的主要原因不是网络分区,那么声称CA就更充分了。对于Spanner,这意味着可用性中断的发生,实际并非是由于网络分区,而是一些其它的多种故障(因为单一故障不会造成可用性中断)。
综上,Spanner在技术上是个CP系统,但实际效果上可以说其是CA的。
分区发生时会如何
上面已经提到过了,对于分布式系统的节点来说,它很难感知分区,它所能感知的只有延时。那么这里有几个重要问题:
如何判断分区
分区问题的主要来源
spanner在面对分区的时候做出了什么样的选择
首先,考虑单Paxos组内事务,出现分区后会出现两种情形:
大多数成员可用,选出了新Leader,事务继续运行。但处于少数人的一侧将再也无法更新它的tsafe了,在某个时间戳之后的读操作无法在该分区被服务,牺牲了部分可用性,但数据在多数人中保持一致。
无法维持一个多数人群体,那么事务将暂停,新的事务不会被接受,系统不可用
再考虑跨Paxos组的事务
对跨组事务使用2PC还意味着组内成员的网络分区可以阻止提交。舍弃可用性保证系统数据是安全的。
对于快照读,快照读对网络分区而言更加健壮。特别的,快照读能在以下情况下正常工作:
对于发起读操作的一侧网络分区,每个组至少存在一个副本
对于这些副本,读时间戳是过去的。
如果Leader由于网络分区而暂停(这可能一直持续到网络分区结束),这时第2种情况可能就不成立了。因为这一侧的网络分区上可能无法选出新的Leader(译注:见下节引用的解释)。在网络分区期间,时间戳在分区开始之前的读操作很可能在分区的两侧都能成功,因为任何可达的副本有要读取的数据就足够了。
F1概览
F1是基于Spanner之上的一个分布式类关系数据库,提供了一套类似于SQL(准确说是SQL的超集)的查询语句,支持表定义和数据库事务,同时兼具强大的可扩展性、高可用性、外部事务一致性。接下来主要从几个方面来简要说一说F1是怎么在Spanner之上解决传统数据库的诸多问题的。
F1的基本架构
F1本身不负责数据的存储,只是作为中间层预处理数据并解析SQL生成实际的读写任务。我们知道,大多数时候移动数据要比移动计算昂贵的多,F1节点自身不负责数据的底层读写,那么节点的加入和移除还有负载均衡就变得廉价了。下面放一张F1的结构图:
大部分的F1是无状态的,意味着一个客户端可以发送不同请求到不同F1 server,只有一种状况例外:客户端的事务使用了悲观锁,这样就不能分散请求了,只能在这台F1 server处理剩余的事务。
F1的数据模型
F1支持层级表结构和protobuf复合数据域,示例如下:
这样做的好处主要是:
可以并行化,是因为在子表中可以get到父表主键,对于很多查询可以并行化操作,不用先查父表再查子表
数据局部性,减少跨Paxos组的事务.update一般都有where 字段=XX这样的条件,在层级存储方式下相同row值的都在一个directory里
protobuf支持重复字段,这样也是为了对于array一类的结构在取数据时提升性能
最后,对于索引:
所有索引在F1里都是单独用个表存起来的,而且都为复合索引,因为除了被索引字段,被索引表的主键也必须一并包含.除了对常见数据类型的字段索引,也支持对Buffer Protocol里的字段进行索引.
索引分两种类型:
Local:包含root row主键的索引为local索引,因为索引和root row在同一个directory里;同时,这些索引文件也和被索引row放在同一个spanserver里,所以索引更新的效率会比较高.
global:同理可推global索引不包含root row,也不和被索引row在同一个spanserver里.这种索引一般被shard在多个spanserver上;当有事务需要更新一行数据时,因为索引的分布式,必须要2PC了.当需要更新很多行时,就是个灾难了,每插入一行都需要更新可能分布在多台机器上的索引,开销很大;所以建议插入行数少量多次.
F1的模式变更
模式变更的难点
同步的模式变更可行吗?
显然是不行的,这违反了我们对可用性的追求。
然而在线的、异步的模式变更会造成哪些问题呢?
所有F1服务器的Schema变更是无法同步的,也就是说不同的F1服务器会在不同的时间点切换至新Schema。由于所有的F1服务器共享同一个kv存储引擎,Schema的异步更新可能造成严重的数据错乱。例如我们发起给一次添加索引的变更,更新后的节点会很负责地在添加一行数据的同时写入一条索引,随后另一个还没来得及更新的节点收到了删除同一行数据的请求,这个节点还完全不知道索引的存在,自然也不会去删除索引了,于是错误的索引就被遗留在数据库中。
算法的基本思想
在F1 Schema变更的过程中,由于数据库本身的复杂性,有些变更无法由一个中间状态隔离,我们需要设计多个逐步递进的状态来进行演化。只要我们保证任意相邻两个状态是相互兼容的,整个演化的过程就是可依赖的。
算法的实现
F1中Schema以特殊的kv对存储于Spanner中,同时每个F1服务器在运行过程中自身也维护一份拷贝。为了保证同一时刻最多只有2份Schema生效,F1约定了长度为数分钟的Schema租约,所有F1服务器在租约到期后都要重新加载Schema。如果节点无法重新完成续租,它将会自动终止服务并等待被集群管理设施重启。
定义的中间状态:
delete-only 指的是Schema元素的存在性只对删除操作可见。
write-only 指的是Schema元素对写操作可见,对读操作不可见。
reorg:取到当前时刻的snapshot,为每条数据补写对应的索引
演化过程:
absent --> delete only --> write only --(reorg)--> public
F1的事务支持
F1支持三种事务
快照事务
悲观事务
乐观事务
前面两类都是Spanner直接支持的,这里主要讲讲乐观事务,分为两阶段:第一阶段是读,无锁,可持续任意长时间;第二阶段是写,持续很短.但在写阶段可能会有row记录值冲突(可能多个事务会写同一行),为此,每行都有个隐藏的lock列,包含最后修改的timestamp.任何事务只要commit,都会更新这个lock列,发出请求的客户端收集这些timestamp并发送给F1 Server,一旦F1 Server发现更新的timestamp与自己事务冲突,就会中断本身的事务。
其优势主要如下:
在乐观事务下能长时间运行而不被超时机制中断,也不会影响其他客户端
乐观事务的状态值都在client端,即使F1 Server处理事务失败了,client也能很好转移到另一台F1 Server继续运行.
一个悲观事务一旦失败重新开始也需要上层业务逻辑重新处理,而乐观事务自包含的--即使失败了重来一次对客户端也是透明的.
但在高并发的情景下,冲突变得常见,乐观事务的吞吐率将变得很低。
F1的查询处理
F1的查询处理有点类似于Storm一类流式计算系统,先生成可以由有向无环图表示的执行计划,下面给出一个示例:
F1的运算都在内存中执行,再加上管道的运用,没有中间数据会存放在磁盘上,但缺点也是所有内存数据库都有的--一个节点挂就会导致整个SQL失败要重新再来.F1的实际运行经验表明执行时间不远超过1小时的SQL一般足够稳定。
F1本身不存储数据,由Spanner远程提供给它,所以网络和磁盘就影响重大了;为了减少网络延迟,F1使用批处理和管道技术,同时还有一些优化手段:
对于层级表之间的join,一次性取出所有满足条件的行
支持客户端多进程并发接受数据,每个进程都会收到自己的那部分结果,为避免多接受或少接受,会有一个endpoint标示.
对protobuf数据的处理,即使是只取部分字段,也必须取整个对象并解析,这也是为了换取减少子表开销做出的权衡。