Spanner的论文已经公布几个月了,技术网站上已经有不少的分析文章。为了更深入的从工程角度理解Spanner,我最近又重新对论文中设计的关键技术细节进行了分析。主要的参考仍然来自于Google Research公布的OSDI 2012上的Spanner论文以及演讲材料。本文的重点是介绍Spanner的TrueTime和分布式事务两项核心功能
Spanner有一套复杂的数据部署模型,以下是论文中描述的一些关键单元
其中directory和fragment是由数据模型逻辑自然产生的(参见1.2节),Spanner使用后台进程(Movedir)不断重新调整directory和fragment在tablet中的部署,目的是使频繁同时查询的数据尽可能聚合在一起,因此一个tablet的元数据是可能发生变化的。
Spanner的另一套机制决定group中的tablet如何在server上分布,这套机制考虑的通常是全球容灾和用户访问延时问题,例如约束某个表的数据在北美保存2个备份、亚洲2份、欧洲1份。
Spanner使用一种有Schema的嵌套数据模型,同时使用类SQL的语法作为访问接口,用论文中的例子来解释
上面的两个SQL语句看似创建了两张表:Users和Albums,但实际上Spanner将它们合并存储。注意第二张表的建表语句使用了多主键:uid和aid,并且通过“INTERLEAVE IN PARENT …”语句表示uid与Users中的主键uid一致。这意味着,Album中的多条uid相同的记录,是嵌套在Users表的一条uid记录之下的。同一个user之下的所有子数据,就构成了一个directory。论文并没有描述表中的非主键如何存储,因此只能推测它们存储在所对应主键之后,即如下结构:
使用嵌套的数据结构,Spanner实现了相关联数据的合并存储,并且对于前面部署模型提到的后台directory迁移机制十分友好,属于同一Users下的所有记录可以同时取出。另外对于需要频繁使用两表主键join的操作,这种模型也能够大幅降低磁盘IO开销。论文中并没有具体说明,这样的嵌套模型是否具有层次深度是否仅限于2层,即是否能创建另一张表interleave in parent Albums。不过这在技术上并没有什么障碍。
关于存储模型,Spanner论文并没有给出具体的说明。考虑到时间戳和多版本的设定,个人推测Spanner采用了与BigTable和LevelDB类似的增量模型(毕竟它们都是由Jeffery Deam等几位大牛牵头的),该模型的特征是数据以增量的方式写入到存储介质,而非在原有存储空间上进行修改。至于如何基于增量模型实现嵌套型数据存储,也并不是一件简单的事情,开源NoSQL数据库还没有一个类似的产品,以后有机会专门写文章详细介绍。
全球分布式和一致性事务,在现有架构下通常很难共存。前者要求读操作可以在任何一个节点进行(通常会选择里客户端最近的节点),后者要求无论客户端链接到哪个节点,读取的数据都是一致的。我们列举2个场景说明同时实现两个要求是如何困难,以及为什么这些困难都与时序问题有关。
场景1:主备一致
上图是主备一致问题的一个具体示例,客户端向Master节点提交(A=2)更新后,转向Slave节点查询A的值,此时Slave可能尚未写入更新的数据。要解决这个问题有两个思路:
已有的数据库经验表明,实现第一个思路的代价是巨大的。因为:1)有些的数据库写操作的代价较大,Slave节点强制同步写入会导致写延时增加。2)主备节点的连接并不可靠(全球部署和备份的系统更是如此),Master向Slave两阶段提交的任何时刻都可能发生故障。
而第二个思路,需要解决的问题是,数据写入和查询的新旧顺序如何确定?进一步,Master、Slave、Client三个分布式节点如何统一的描述一个具有顺序的逻辑?
场景2:跨库(或分区)事务原子性
上图是跨库事务原子性问题的一个示例,Client 1节点提交一个写事务,向两个跨库或分区的Master节点分别提交对记录A和B的写操作。同时Client 2节点希望查询A和B的值。
在传统方法中,Client 2 的对A、B的读取必须封装到一个读事务中。这是因为:由于缺乏分布式的时序,Master 1和Master 2对数据的加锁必须是有次序的,如果来自Client 2的两个读请求不尝试也获得锁,它们就无法保证这两个读操作都发生在写事务之前或之后。有些场景下,读事务对性能可能造成严重影响。假设该模型描述的是一个商品数据,A和B是同一个商品的两个属性,但分布在两个表中,Client 1代表了商品的成交,Client 2则代表商品信息的查询。当查询的频率远高于成交操作,成交操作的性能将受到严重影响。
分布式数据中的Schema变更操作,可能更能说明跨库(分区)中的事务操作多么依赖分布式锁。传统分布式数据库需要对所有有关表加DDL锁,阻塞读写操作。在没有时序逻辑的系统中,一次全局加锁操作的时间代价是巨大的。就是说一旦需要更新Schema,传统分布式数据库就必须忍受一次长时间的全网业务中断。
TrueTime是一套保持Spanner全球服务器的时钟“近似同步”的基础服务。它基于已有的全球精准计时技术:GPS时钟和原子钟。Spanner同时使用两种校时技术是有原因的。解释这个问题,需要先简单介绍两种时钟的原理:
综合以上原因,Spanner决定同时采用GPS时钟和原子钟,并且以GPS时钟为主。这样可以既避免计时误差问题,又保证了GPS失效时的可用性。 此外Google在论文中也透露了,原子钟与GPS时钟的成本在相同数量级,所以成本问题并不是Spanner选择的一个关键因素。
既然是工程角度的探讨,就需要再引申介绍一下,如何搭建基于GPS时钟(原子钟)的服务器。目前的精确校时商用设备,按授时的物理接口可分为以下几类:
可以简单推测Spanner采用的是以太网PTP方式进行的网络校时,形成如下组网架构:
Spanner的每个数据中心里都有一定数量的时钟服务器,其中多数是GPS时钟服务、少数是原子钟服务,它们被统称为time master。提供数据服务的主机称为spanner server。spanner server会周期性的选择若干time master进行网络校时。
spanner server会周期性的将本地时钟调整到全球一致的值(时钟本身和校时产生的误差范围内)。但在相邻校时周期中间,它完全依赖本地时钟读取时间,在此期间由于本地时钟的不精确性,从而积累误差。Google提供的经验值是,一般情况下本地时钟每秒积累的误差不会超过200us,相当于1/5000的误差。以此经验值为前提,TrueTime可以通过调整spanner server的校时周期,保持这种误差小于一个特定的值。例如Spanner的周期设定为30秒,误差上限值被设置为7ms(大于30*0.2ms)。这个值被成为”误差参考值“,记作ε
可以简单的这样理解,Spanner保证每个服务器在任何时刻获取的时间,与“全球标准时间”相差不超过7ms。或者说任意两个Spanner服务器之间的时间误差不超过14ms。后面的分析会说明,这个最大误差值越小,Spanner的事务性能将越高。Google在论文中也提到,缩短校时周期可以有效改善误差时间,个人推测目前使用的30秒周期可能是PTP服务本身的并发性能限制有关,因此增加Time Master的数量可以间接缩短该误差值。
由于定义了误差参考值ε,就可以定义TrueTime API三个接口
举一个例子描述如何使用TrueTime控制分布式系统的时序,要求”节点A上执行事件e1的绝对时间早于节点B上执行事件e2“
不使用TrueTime API的方法如下
使用TrueTime的方法如下:
证明:用tabs(e)表示时间e的执行绝对时间,根据误差参考值定义,tabs(e1) < t1+ε, tabs(e2) > t2-ε,又因为t2-t1>2ε,所以tabs(e1)<tabs(e2)
Spanner是一个具有多版本特征的数据库。它的每个最小粒度的数据都具有一个时间戳属性。时间戳在数据写入时生成,并且遵循以下规则:
Spanner读写操作的类型包括三种:快照读、只读事务、读写事务。
快照读是对历史数据的查询,的流程如下:
快照读使用时间戳对数据进行筛选,客户端可以指定(过去、当前或未来)任意时刻时间戳进行查询,因此查询结果与请求提交的时间无关。Spanner具备查询过去某个版本数据的能力。
快照读引入了读等待所机制(步骤4.1),副本节点达到足够新的状态之后才完成读取,因此即使数据复制是异步执行的,也可以保证跨副本节点的读写一致性。正是由于快照读的事务一致性与副本选择无关,步骤1.2可以根据业务需要自行制定副本选择策略,不限于以下几种:
只读事务针对客户端没有指定时间戳的情况,默认含义是查询当前时刻数据,具体流程如下:
只读事务区分读操作是否在一个group的意义在于,面向多节点读操作需要统一时间戳,这种情况下,由客户端指定时间戳的消息通信代价是最小的。而对于只需要在一个节点上执行的读操作,LastTS(接受消息时刻最后的写事务时间戳)会比now.last要小,特别是该tablet最近一段时间没有写入数据的情况,Spanner针对这种场景将生成时间戳的权利交给Leader节点,能够很大概率降低读阻塞时间。
由于spanner服务端节点引入了读阻塞机制,只读事务和快照读不需要使用锁。
读写事务的流程如下:
Spanner论文对读写事务中锁的描述比较简略,遗留了许多问题需要填补:
读写事务的步骤5.4和6.3是一个复合步骤,涉及Leader节点与Slave节点之间交互,参见3.5节。
在一个group中tablet备份分布在不同节点上,其中一个称为Leader节点,其余称为Slave节点,它们都称为副本节点(replica)。为了降低开销,Spanner采用Multi-Paxos协议进行数据备份。但这个协议会产生一个问题,当Leader节点需要重新选举的时候,可能多个节点同时认为自身是Leader,这将破坏Spanner读写事务中的其它一些设定。因此Spanner必须作出更严格的约束,任何时刻只有一个节点认为自己是Leader。
为了保证异常状况下的Leader及时重新选举,以及避免选举造成过重的开销,Spanner采用有租约的Leader管理机制,时长默认是10秒,即一个节点确认获得授权之后10秒可以认为自己是Leader节点。为了避免Leader节点闪断后重新回到group时出现新旧Leader共存,在一个Leader租约时间内,重新选举新的Leader也是不允许的。另一个问题是,不同节点对于租约起止时刻的判断误差,也可能造成短暂的新旧Leader共存,Spanner使用TrueTime API避免这一点。具体流程如下:
上述流程中,不同节点维护的租约时间不同,而leader节点则取了大多数节点租约投票时间的交集,从而保证不与前后产生的leader节点产生租约时间的重叠。具体的证明可以参见原文附录A。
Spanner还引入了租约延长机制,进一步避免leader重新选举代价,包含显式和隐式两种方法(具体的流程并未说明):
注意到Leader节点延长租约所需的多数节点并不需要是固定的,也就是说当它拥有任意多数节点的租约投票,即可延长Leader租约。
另外,Leader选举流程可能存在活锁问题,即多个replica节点同时试图成为leader时,由于没有一个达到多数replica投票,流程反复启动和终止。特别是步骤2要求投票后的租约时间内不再进行投票,产生一次活锁将导致10秒内Leader无法选举产生。不过虽然论文中没有提及,相信Spanner已经采用了其它机制规避该问题。
Spanner Leader利用Paxos进行binlog数据复制,每个paxos实例中决议的对象是下一条binlog记录,流程如下:
上述数据复制过程仅仅保证binlog数据的主备一致性,因此不涉及数据commit和commit wait,它们交由读写事务流程完成。
Spanner的Schema变更事务流程如下:
Spanner不再需要接收到DDL操作之后开始逐步阻塞所有节点上的读写操作,直到确认所有节点进入阻塞状态再确认执行schema变更。TrueTime保证了,在绝对时刻t所有节点都处于阻塞读写操作状态,而小于2ε时间之后所有节点自行恢复读写,从而将节点阻塞读写操作的时间降至最低。
Spanner哪些地方使用了TrueTime?
关于时间戳规则2和commit等待真的必要吗?
(待补充)