做数据库有一段时间了。最近有一些在校的同学问到,在实际中,分布式数据库中存储层工作内容是什么样的?简单回答了下,想到其他人可能也有类似问题,于是来这里总结下、抛个砖头。经验所限,难免有误,欢迎交流。
注:限定下讨论范围,分布式数据库,存储计算分离,share-noting 架构,仅讨论存储层。
存储层涉及的东西很庞杂,想说清楚,需要有一个合适的切入角度。数据库最本质的功能,是存储数据,以对外提供数据的查询和写入接口。不妨,就首先以这两条线串一下各个模块,然后再补充下不能归到这两条线中的一些组件。
作者:木鸟杂记 https://www.qtmuniao.com/2022/05/04/distributed-database-storage-components 转载请注明出处
查询
查询请求进到存储层,一般表现为下推的执行计划,进而转化为对底层存储引擎的单点查询和范围查询,为了加速查询,一般会给存储引擎配备缓存层。对于每个存储节点来说,为了应对大量的并发请求,需要做 IO 优化。
执行计划
这是存储层的入口,是存储层向查询层暴露的接口。
一个查询语句经过查询层的语法分析(Parser)、语义检查(Validator)、生成计划(Planner)、计划优化(Optimizer)、执行计划(Executor)几个步骤之后,会将需要下推给存储层的算子下发到存储层对应的分片( Partition)所在节点。
对于火山模型来说,我们可以将执行计划理解为一个由基本算子(Executor)组成的 DAG,甚至再简化一些可以想象成一棵树。树中下层的一些小子树,是可以直接推到存储层对应的节点去执行的,这些可以下推的算子通常包括:TableScan,Filter,Project,Limit,TopN 等等。
存储层拿到这些执行计划后,反序列化,组织成内存中的执行计划,以迭代模型[1]或者向量模型,来对数据进行扫描、过滤、排序、投影、聚合等操作后,将结果集返回给查询层。
结果集可以有几种返回方式:
- 一次全量返回
- 流式返回
- 分页返回
计算下推有诸多好处:
- 充分利用存储层的分布式节点进行预计算。
- 减少存储层到查询层的数据传输带宽消耗。
- 提高查询层的处理速度和数据集上限。
缓存
为了对查询进行优化,对于读多写少的场景,一般会在存储引擎之上罩一个缓存层。如果是共享存储层的架构,比如存储层在云上,那么缓存层就必不可少。
缓存在设计时,主要需要考虑缓存粒度和生命周期两方面。
- 缓存粒度。为了保持缓存和后端的数据一致性,势必需要加锁,而缓存粒度和加锁粒度息息相关。一个节点上的不同 Partition 的缓存要不要共享一个缓存池,也是缓存粒度需要考虑的问题。
- 生命周期。何时写入后端,何时让缓存失效,这涉及到缓存控制策略,是同步读写穿透,还是异步更新,都是需要根据实际情况考量的问题。
RPC IO 优化
任何服务都是类似的,大量请求过来时,得用线程池、异步、协程等各种手段优化,提高并发,从而提高吞吐,减小延迟。
有的 RPC 框架能解决这些问题,比如有些 RPC 框架内置协程模型,支持 M 比 N 模型、协程窃取等等。如果 RPC 框架不管,就需要用额外的线程池库、异步库(promise、future)、协程库来手动控制请求的执行流并发执行。
写入
分布式系统中,一般会使用多副本来存储数据。在写入时,为了维持所有副本看到一致的写入顺序,会引入共识算法。共识算法通常都是维持一个逻辑上 endless 的逻辑操作日志,然后每个副本将逻辑日志应用到自己本地的状态机——存储引擎。在写入数据时,需要对用户数据进行数据编码,转化为二进制串,从而写入存储引擎。对于一些一致性(区别于多副本间的一致,此处是多语句间并发执行的一致)要求严苛的场景,数据库需要对用户提供多个语句原子化执行的保证,即分布式事务。
共识算法
对于 share-nothing 架构,为了保证高可用,都会使用多副本(Replication),并放到容错阈不同的多台机器上。使用多副本,就自然会引入多副本数据一致性的问题,一般我们会使用共识算法(Raft、MultiPaxos)来解决。
使用共识算法,对于每个数据分片(Partition),可以维护一个多机一致的操作日志(operation log,WAL):即所有写入操作,都会序列化成操作日志记录,并在所有的副本按唯一的顺序进行追加写。有了一致的操作日志,我们再将其各种应用到本地的状态机(也就是存储引擎),辅以 log id,就可以对外提供一致的读写视图。
存储引擎
这里指的是单机存储引擎,也就是上文所说的状态机。它解决的问题是,如何将数据组织在单机的存储体系中,以最少的空间,应对特定场景的高效的写入和读取。一般分为数据编码、索引组织、并发控制等等几个子模块。
存储引擎主要分为两个流派:原地更新的 B-Tree 流派和基于追加的 LSM-Tree 流派。这里推荐两个个学习的项目,B-Tree 的可以看看 BoltDB[2];LSM-Tree 可以看看 LevelDB[3]。但实际使用中会用更复杂强大一点的变种,比如 RocksDB。
对于 AP 场景来说,一般使用列式存储,可以更方便的进行数据压缩和进行向量化计算。
数据编码
数据编解码解决的问题是,如何将逻辑上的一个记录(如关系型数据库中的 Row),高效(耗时少、占空间少)的编码为二进制串,写入存储引擎。
在编码时,需要考虑和 Schema (该行有哪些字段,字段的类型是什么)的对应关系,也要考虑在 Schema 变化时(加字段,删字段,改字段类型),如何保证数据读取的兼容性。
分布式事务
数据库的一大重要功能就是对事务的保证,利用事务模型的诸多保证(ACID),可以大大减小用户侧使用数据库的复杂度。当然,这通常是以损失性能为代价的,在分布式数据库中这点尤为明显。
如何保证分布式事务间的原子性和隔离性,业界有诸多方案。最基本的框架是两阶段提交配合全局时钟(有物理时钟、逻辑时钟、混合时钟和 TSO 等多种解决方案,又是一个比较大的话题),比较经典的是有谷歌的 Percolator 模型。
其他模块
除了能直接归到读写流程相关的组件,还有一些其他存储层交互比较频繁的模块和一些后台运行的常驻进程。
Schema 管理
如何划分命名空间,组织不同的 Schema,就涉及到 Schema 的逻辑管理,如使用树形组织。
另外,还需要维护 Schema 和数据的对应关系,但在分布式系统中,如何非阻塞的修改 Schema, 而不影响并发的数据写入,是一个非常费劲的事情。常见的解决方案有谷歌 F1 的 online DDL。
集群元信息
集群元信息主要分两大块:
- 逻辑上。逻辑上的数据集组织与划分,比如 Database、Table。即以合适的粒度,对数据集按命名空间进行划分,进而针对不同的数据集进行不同的配置以及相应的多租户隔离和权限控制。
- 物理上。物理上的节点的组织与划分,比如 Zone,Node。即以合适的容错阈,对不同节点进行物理组织,进而在不同节点和容错阈间处理宕机、均衡数据。
管理逻辑数据到物理节点的映射,即是分布式系统中最重要的一个方面:调度。
调度通常发生在两个大时刻,一是数据集创建时,一是副本再均衡时(rebalancing,包括机器宕机、新增节点引起的数据再均衡)。
我们会依据节点的不同属性(容错阈、剩余容量)等对数据集的不同分片进行调度。在进行数据移动时,会涉及分片的多个副本的增删,为了保证一致性,也需要通过共识协议来完成。
数据导入导出
数据库最重要的周边工具就是支持数据以丰富的格式、较高的速度进行导入和导出。
这又可以细分为几类:
- 数据备份与恢复。即数据生产者和消费者都是本数据库,此时不用考虑支持不同的的数据格式(即可以自定义编码,只需要自己认识即可,因此可以怎么高效怎么来),而是要考虑支持不同的数据后端:本地、云上、共享文件系统中等等。同时,也要考虑同时支持全量备份和增量备份。
- 其他系统导入。需要考虑支持多种数据源以及不同数据格式,最好能使用一些计算框架(如 Spark、Flink、Kafka)分布式的导入;也最好能够支持主流的数据库接入,比如 MySQL、Postgres 等等。
- 数据导出。将数据导出为多种通用的数据格式,如 csv、json、sql 语句 等等。