YugaByteDB 的诞生也是抓住了 spanner 推行的NewSQL 浪潮的尾巴,以 PG 生态为基础 用C++实现的 支持 SQL 以及 CQL 语法的数据库。
设计之初的目标如下:
后续的介绍对 YugaByteDB 统一称为 YB。
YB 的查询层主要是 YSQL和YCQL 层,YCQL 没有专门的查询优化器和执行器,只有一个语法解析器。YSQL 则复用的 PG的查询引擎,包括 parser,optimizer 和 executor。
YB 架构的核心是在存储层,当然也在向提供AP服务的 mpp 的引擎发力,因为 PG 的执行引擎是火山模型,且是为行存设计,想要服务 AP 场景,性能远远不够。
本篇也主要是看看 YB 的存储层设计,站在 YSQL 角度,可以理解为是PG的存储层的重写。
我们启动一个 单机版本的 YB 集群,可以看看其都有哪些进程,以及数据目录的分布形态,这个过程从而帮助我们更好得理解其逻辑架构。
在 MAC 本地,通过 yugabyted
启动集群之后,集群状态如下:
再看看进程组的情况:
其中yb-master进程和yb-server进程都有各自的数据目录:
在 yb-data目录下的 master
和tserver
数据目录结构都是一样的,在数据表目录之下拥有以 tablet 为子目录的 rocksdb实例 以及 wals目录,保存对应tablet 的 raft-index 和 raft-log。
YB 的核心主要是在 Master 和 TServer 两个进程,接下来看一个官方提供的这两个进程的关系图:
最多三个节点的master进程之间形成了 raft group,通过raft算法的心跳、leader 选举、日志复制的功能来实现高可用;无限 scale out 的 tserver 节点之间也是 raft group,实现数据存储的高可用。
Master 主要有如下功能:
前面介绍基本架构的时候会提到 Master 以及 TServer 存储数据的基本单位是 Tablet,Master这里存储 catalog的时候也是 Tablet,不同的 Tablet会在 Master内部形成 raft-group。同样的后续介绍的 TServer存储用户表数据的时候也是以 tablet 为单位,每一个tablet和其他 TServer的副本形成raft-group。
关于 Tablet 工作方式后续会再详细介绍,总之 Master 负责对 所有 Tablet的管理,包括创建、分裂、迁移等。
TServer 包含 查询层,即 YSQL 以及 YCQL 的功能 以及 数据存储的功能。
每一个 TServer 存储上可以管理多个 Tablet,不同的 Tablet 与其他 TServer 节点的 tablet 副本会形成 raft-group。一个TServer 节点上 同一个表可能会有多个 tablet,且在同一个表目录下,不同的tablet 在不同的目录,数据是物理隔离,每一个tablet 目录可以理解为一是个 rocksdb实例。
除了查询层之外的所有存储层的功能实现可以理解为都在 docdb中, docdb是一个能够感知schema信息的支持事务的分布式kv存储引擎。
Tablet 支持对表数据的 Hash 分片 以及 Range 分片,Docdb 统一对这一些分片后的 tablet 进行管理。
Hash分片下 YB 会选择用户指定的hash-key,比如建表时指定了 id列为primary-key。会取这个列的前两个bytes(16bits)进行hash 映射到对应的tablet中,这样一个表总共会有 2^16 64k个 tablet。
优点: Hash 分片能够尽可能得保证数据跨节点的均匀分布,而且 hash 分片之后的 tablet的管理采用的是一致性hash,在有节点异常或者增加新的节点时能够利用一致性hash 完成高效的tablet 移动。
缺点: 这也是所有按 Hash 分片的无奈之初,即 range-scan,比如扫描某一列大于某一个值的所有行,这个成本就会非常高。
Range 分片下会将表的主键拆分为多个连续的range,每一个range 作为一个tablet,tablet内部基于主键排序。当然 Range分片下的 tablet 最开始只会有一个,随着数据的插入,每一个tablet的range范围会逐渐增加,到某一个阈值则会触发tablet的分裂。
优点: 当然是range-scan的效率极高,按照上下界扫描某一个表的数据效率是极高的,只需要确认 lower-bound和upper-bound所在的tablet之后 只需要顺序扫描文件就可以了。
缺点: range分片的分裂是随着数据的插入进行的,即使用户有很多个可以服务的节点,在没有达到tablet分裂的阈值之前也只能由一个节点调度 query;range 分片下用户的访问热点概率较高,高频访问一段连续的range时负载会集中在很少的几个 tsever节点,这个时候就需要master的参与来为热点访问的节点提供更多的资源。
接下来从 IO 链路 以及 相关的数据存储编码设计 来看看 YB 的存储层是如何实现最开始的目标的。
数据的读写链路部分 还是以 YSQL为主,YB本身兼容的是PG的协议,这里需要区分DDL 和 DML语句的读写链路。
YB 保留了PG 基本所有的的catalog 包括基本的 pg_class,pg_attribute,pg_type等,在initdb的时候写入到 master 集群中 为catalog 创建的tablet中。这样对 catalog的管理就完全集中化了,而且 docdb 对 catalog的数据存储也都是转为了kv,访问也更加符合云上的按需访问的需求,和实际的用户表数据 物理上分离存储又通过docdb统一进行逻辑管理,整体还是非常合理的。后续会详细介绍 YB 对表结构的编码方式。
DDL 主要是对 catalog的增删改查,也就是主要和master进行交互,但是细节上还是会有不少 TServer的参与。
用户和无状态的 postgres进程建立连接之后,发送的 DDL请求会通过 postgres 进程转发给启动时bind的 tserver的 YQL 层进行 query的解析以及后续的操作。
后续整体的操作可以分为两个部分:
用户发送建表请求到收到返回,中间会需要 TServer 进行 请求的解析并完成 schema信息的封装,将 schema 通过 rpc 发送到 Master。在 Master上的 DocDB中完成 schema到KV的封装,并通过 raft 完成数据的复制 以及 后续的 apply(将封装好的kv batch写入rocksdb的memtable,raft的复制过程会写raft-log以及raft-index,不需要写WAL;当然这个过程是满足事务语义的,即利用分布式事务完成的线性一致性写)。
后续的 TServer的各个peer上进行 用户表数据的 tablet的创建则是异步进行,master还需要确保 TServer上的各个tablet 都形成了 raft-group,有对应的 leader-peer才算完成。
其他的DDL 也是类似的操作,比如 drop-table,可能由对 catalog的增加变为catalog的对应数据的删除 以及 TServer上 tablet的清理。
DML 是对用户表的增删改查,本身应只需要TSever的参与,但是在连接的 TServer 第一次访问某一个tablet时需要向 master索取该数据所处的 tablet的leader 信息才行,拿到之后才能到对应的tablet的leader进行数据读/写操作。
Master 在这个过程仅负责提供要访问的tablet信息即可,其他操作均由 TServer内部完成,且 StateLess Postgres 是一个smart-client,会在自己的内存中缓存访问过的数据的tablet信息,后续对相同range/hash 分片的读写可以不用 t_peer0 以及master的参与,直接去到对应的tablet leader即可。
读请求比较简单,无需复制,从任意一个tablet的peer拿到数据之后会直接返回给客户端。
事务体系是在 DocDB层实现的,不论是 Master 集群的catalog 持久化到tablet操作还是 TServer的用户表数据持久化到 tablet 操作都会由 DocDB 进行调度,对于每一个请求都会有事务的执行链路,像上面的读写链路因为是单行操作,会直接在tablet leader本地完成,并不需要分布式事务的参与,RPC 会少很多。
但是真实场景,一个事务往往涉及多行数据的操作,多行数据可能还会跨 tablet,这个时候一定是需要分布式事务来保证线性一致性的操作。
YB 在事务隔离级别上的支持,目前对 YSQL 支持 Repeatable Read,Serializable 以及 正在进行中的 RC,因为期望和 PG支持的隔离级别对齐;YCQL 只需要利用 Snapshot支持 Repeatable Read就好了。
实现这几个隔离级别的技术也比较通用:hlc实现MVCC + 乐观锁。
当然,这样的实现目前肯定没有办法和 PG 的悲观事务体系保持一致,不过YB也在向PG的语义兼容,悲观事务也在实现过程中(主要是当前架构的性能问题,不过要支持AP的话 悲观事务体系还是需要有的,AP场景的query执行时间过长,不可能等到提交的时候才发现有冲突而失败,这样的语义用户来说是不能接受的)。所以目前在 OCC的实现下,如果发现两个事务有冲突,则会直接报错,终止其中一个事务的执行链路,不像 PG实现的是 wait-on-conflict语义。当然这个 wait-on-conflict语义也是在实现中,会和 悲观事务一起完成。fail-on-conflict 目前也是用 lock-table来实现,因为所有的写都会发送到对应的leader-tablet,这样对同一个tablet的同一行的修改的冲突检测就可以在一个 TServer的内存中构建 lock-table 并完成检测。
HLC (Hybrid logical clocks)混合逻辑时钟本质是为了提供请求的因果关系,兼容时钟漂移的同时,用较低的成本(TrueTime依赖硬件且成本太高)提供一个全局单调递增语义的序列 且 仍带有时钟属性。因为其本身就是由 physical-clock + logic seq 组成。
关于HLC的细节 以及 算法演进,可以参考之前写过的一篇 计算机的时钟系统演进。
前面说的只是 事务的隔离级别的实现,但是分布式事务的原子性 比如一个事务修改了多个tablet,且这一些tablet分布在不同的peer,如何保证这样的事务的原子性,要么都提交,要么都abort。这个当然是业界通用的实现方案,2PC。
Prepare阶段 主要做的事情有两件:
上图是向数据的 tablet中写入的当前事务的临时记录,–> 前后分别是 key和value。
其中 Primary provisiional records中的key格式如下:
row1, WeakSIWrite, T130 --> TxnId1
表示 TxnId1 对于row1这一行加了 WeakSIWrite
级别的行锁,且这个key的 hlc版本是 T130。row1.col2, StrongSIWrite, SI,T150 --> TxnId2, value4
,TxnId2 对row1 col2的修改加了 StrongSIWrite
的冲突锁,且hlc版本是 T150。Commit阶段:
Commit时会向 Txn Status tablet发送rpc, 当前事务没有冲突时commit才会成功。Commit成功,则所有的临时数据记录将立即对其他client可见 – 这块猜测是修改了当前事务的 hlc的可见性,比如将最新的hlc(YB里面有一个 safe-time)版本推进到当前事务提交的hlc,否则不太可能说立即可见,类似PG的 latestCompletedXid,这样的实现高效且简单。
Commit完成之后会异步进行数据的apply完成后会临时记录和 当前事务在 Txn Status Tablet的事务状态的清理。
前面有简单提到过事务部分的临时记录的key的形态,接下来看看 YB 的DocDB如何 将表结构转为kv的。
整体来看 YB 在未来会考虑支持两种形态:
当然这两种编码方式都有其适用的场景 以及 痛点的解决方案,目前 YB 还是只支持 第一种编码方式,第二种还在测试中(Packed row format)。
第一种的 Rocksdb k/v 的 编码方式实现如下:
DocKey 是用来标识行,即利用表的主键key的hash值+一个type 来标识SQL的行 或者 CQL的行,type可以用来区分当前key是 hash分片还是 range 分片。
Subkeys 则进一步标识 YSQL 表的列id 或者 CQL的某一个数据结构(set/map/list等)。
DocHybridTime 则是一个 hlc时钟的标识。
Value 在 YSQL下存储的是 列的值,YCQL value 存储的TTL,超过这个时间则需要被清理。
对于 YSQL 来说,我们的数据库表数据的存储会有不少冗余,对于一行来说每一个列都是一个独立的k/v,但是 docKey则需要存储多次(相同前缀的话在Rocksdb是连续存储,虽然对压缩比较友好,但是空间放大仍然是比较严重的),而且insert性能随着表的列宽的增加,性能会越来越差;当然 update/select 性能也还是能够接受的。
在 YB 中,不论是 Master还是 TServer 上,每一个tablet 都是一个rocksdb实例,数据部分的存储都是放在Rocksdb 上,所以对 Rocksdb 也算是深度使用了。
Rocksdb 功能的选择上:
因为已经有 raft-log了,且本身是先写raft-log,所以会关闭 rocksdb的wal功能。这里会有一些工业实践的一些问题,如何确保raft-log的回收是正确的,不会被误删除。需要利用 rocksdb-flush时的一些 event-listener机制来去追踪 flush完成的最新的raft-log的序列(hlc序列),这个 hlc 之前的所有的raft-log是都可以被安全清理的。
YB 的DocDB的 MVCC实现是跨多Rocksdb实例的(分布式事务,且跨多tablet),所以没有办法使用 Rocksdb本身自带的MVCC以及事务机制,实现过程中看代码是参考了不少 Rocksdb的实现,比如lock-table部分。YB 使用了 rocksdb 提供的 timestamp 功能,将hlc编码到了key里,作为internal-key的一部分,原本rocksdb自己实现的单db内递增的 sequence 是没有必要存在了,这块也被移除了,节省了空间。
使用 Rocksdb 加速YB性能:
当然实际肯定还会有一些工业上的问题,比如 TP场景的延时问题,raft-log写盘和memtable-flush 可能会导致磁盘有大毛刺,这个时候有一些rokcsdb的配置以及内核配置能够比较好的解决这一些问题 (比如:ratelimiter + directio_in_compaction/flush–参数忘记了) 。
当然 ratekeeper这种因为有事务流的存在,肯定实在上层做更为合适,比如 YB 就在master实现了这一些功能。
因为个人有一些 Rocksdb 的经验,看到 YB 在Rocksdb上的实践,其实还是有较为可控的研发成本。至少有一个极为稳定的单机k/v存储引擎,以及 TP场景稳定的PG高性能查询引擎,这样 前期 YB 只需要将人力集中投入到存储部分 且用一套较为统一的存储来调度数据以及元数据的读写,真的可以将存储部分做的非常精。
当然 数据库的发展需要跟随时代,如今的云原生数据库 以及 AP 数据库的需求,YB也想要加入,那么新的存储格式的设计(列存:目前这样的key的设计其实也能满足列存的需求了, rocksdb原生append-only 且有工业级的 compaction 以及 压缩实现,每一行的多列在存储上其实也是集中在同一个sst文件内的data-block),新的查询引擎也就需要更多的投入。