作者:阿里云数据库OLAP产品部 仁劼
前言
随着云服务平台的IaaS能力不断发展升级,用户数据规模的不断膨胀,主流的数据库产品都在往云化、服务化发展。云原生这个词已经和数据库紧密结合在了一起,云服务厂商们都在致力于打造自己的云原生数据库产品矩阵。
阿里云数据库ClickHouse自从上线以来,已经走过了两年多的时间,期间我们积淀了大量的云上客户案例,在长期的客户服务支持中也对ClickHouse数据库生态有了非常深刻的认知。在充分了解现有产品的生态和客户实践使用痛点后, 我们对开源ClickHouse数据库做了全新的架构升级,接下来将为用户带来全新的云原生版本ClickHouse数据库。
云原生ClickHouse架构
虽然云原生数据库这个词已经耳熟能详,但是很多用户对数据库产品是否"云原生"还没有构建起清晰的衡量标准。以往的云托管模式数据库,对用户的体感就是:数据库实例从本地的物理机器搬到了云上的ECS,本地磁盘变成了可靠的云盘存储,云数据库产品同时会提供稳定易用的运维、监控手段。
云托管模式把用户从手动部署运维的时代中解放了出来。而云原生数据库面向用户最核心的一个体感就是要提供更好的资源弹性能力、数据共享能力。从数据库引擎内核层面来看,云原生数据库需要更好地去拥抱利用云平台上的基础设施能力,一些传统的数据库架构模式是基于本地不可靠存储设备、有限计算资源来设计的,在云平台这个大前提下将不再是最优选择。我们将推出的云原生ClickHouse数据库对用户来说主要有以下三方面的形态变化(它们也是云原生ClickHouse数据库内核能力持续演进的方向):
- 首先是存储资源和计算资源的解绑,存储资源可以做到按使用量来计费,不再需要因为磁盘存储空间不足而被迫去进行整体扩容;
- 其次是计算资源的极致弹性,可以做到分钟级的计算资源拉起销毁以及扩缩容,用户在预判计算组负载的情况下可以进行分时调整规格,做到最小资源闲置;
- 最后是单个数据库实例下的多计算组资源模式,计算组之间CPU/Memory资源相互隔离但是共享整个集群的存储数据,用户可基于此做不同类型的业务负载隔离以及读写负载隔离;
相同用户形态的产品在业界也有很多case,说明大家对云原生的认知是相同的,都在往极致弹性、计算服务化、存储按量的方向演进。只是计算资源弹性能力的差异,让不同产品在用户使用上会有不同感受,分钟级的计算资源拉起对绝大部分用户来说都需要进行预先拉起并且资源常驻,这个计算资源"拉起"如果足够快,就可以省去预先拉起的动作,对用户而言计算资源就完全是服务化了,随用随有,用完释放。
云原生ClickHouse数据库的架构图如下:
其中每个计算组对用户暴露的都是一个独立的链接串,用户通过不同的链接串就可以登录对应的计算组。计算组这个概念从用户视角就相当于原来云托管模式的一个"集群",计算组之间的机器资源完全隔离,计算组收到的查询请求只会在自己组内的节点上执行。持久化存储层从计算组资源中剥离开,它不附属于某个单一的计算组,多计算组需要共用一套持久化存储层。云原生ClickHouse以分布式对象存储作为持久化存储层,加上分布式KV系统提供集群所有元数据的存储服务,来达到计算组间数据相互可见互通。
单个计算组由多个节点组成,每个节点都会拉起clickhouse-server作为主进程来提供查询服务。同时每个节点都会挂载ESSD云盘作为本地缓存盘,这个缓存盘当前有两个作用:热数据的读缓存、实时数据的写入暂存(实时写入并不会写穿到持久化存储)。所以这里的缓存盘相当于是有状态的,需要在节点迁移销毁的过程中进行处理。最后如图所见,我们对数据做了shard处理,但是节点的数量和shard并不是完全相等只是保证了shard能够均匀分配到节点。下一章将对云原生ClickHouse的架构选择做更细致的分析解读。
Key Choice
关于shard
传统的Share Nothing架构MPP数据库中,数据一般都是根据个别主键列进行sharding(分析类场景一般使用hash分区),不光对数据进行了sharding,也把计算节点绑定到了对应的数据shard上,单个计算节点只会负责某个数据shard的读写处理,当前开源ClickHouse的分布式部署模式就是如此。这种计算节点和数据shard的一一映射强绑定关系,给计算节点的水平扩缩容带来了极大的限制,用户的ClickHouse集群一旦需要加减节点,就需要对全量数据进行re-sharding。Re-sharding是一个完全逻辑数据搬迁的过程,相当于把老集群的数据select出来,再insert回新集群中,clickhouse-copier就是社区为此提供的一个数据逻辑搬迁工具。纵观所有的分布式数据库产品,数据的sharding或多或少都会对集群的扩展能力、弹性能力产生一定制约。数据sharding之所以对分布式数据库产品如此重要,是因为如果没有数据sharding,基于主键的记录写入变更就缺少了最重要的并行能力。
目前只有在完全放弃主键语义的Hive大数据生态或者一些云数仓产品中,数据才可以不用进行强制sharding。ClickHouse生态当前虽然没有实时主键能力,但是作者认为云原生版ClickHouse仍然不能抛弃掉数据本身的sharding,因为ClickHouse的Merge存储还是存在部分主键语义的(其实就是它的Order By Key),主要表现在ReplacingMergeTree/AggregatingMergeTree等变种引擎中。ClickHouse提供了主键最终一致性(optimize partition)的能力,也有主键读时一致性(final scan)的能力。
如果数据本身没有进行sharding,那这两个能力的性能或者代价将出现数量级倒退,打破一些场景下的用户使用模式。另外一个重要原因是ClickHouse当前还不具备一个非常完备的MPP计算引擎,它强依赖于数据sharding来达到分布式并行的能力。而在计算引擎层面,我们希望云原生版ClickHouse是能够不断跟随社区的,始终保持和开源版本的SQL能力一致,提供和开源ClickHouse一样的使用方法。
最终我们在云原生ClickHouse中保留了数据本身的sharding设计,但改造了ClickHouse的单机引擎,使得单个节点能够负责任意多个数据shard。有了这个能力,数据的shard数量就和集群的节点数解绑开了。设定一个较大的初始数据shard数量,可以保障节点数量在一定范围内自由变换。当计算节点数变化时只要数据shard还是能够被均匀分配,就不需要进行非常重的全量数据re-sharding操作,只需按照shard进行本地状态数据的物理搬迁(这里的本地状态数据主要指实时写入的本地暂存数据),然后计算节点重新分配挂载shard就可以了。
关于多计算组:一写多读 vs 多写多读
在云原生ClickHouse的架构设计中,多个计算组对集群的数据是相互可见并共享的,对一个单表的具体某个数据分片来说,多计算组是一写多读还是多写多读就决定了多计算组之间数据共享的方式。ClickHouse核心的MergeTree引擎是完全可以做到多写多读的,它最初的设计就是多写无冲突的模式。但是一些其他非主流的表引擎则不一定能做到。另一个重要的点是MergeTree在数据写入初期会进行多次数据文件合并,这种短时间内反复写入删除的操作对持久化层的对象存储是非常不友好的,而且当前云原生ClickHouse底层使用的对象存储写入带宽也非常宝贵。所以云原生ClickHouse当前选择了一写多读的模式,对具体某个单表的实时数据写入只有一个主计算组会接受请求然后持久化到本地的ESSD缓存盘,等待实时写入数据在后台充分合并后再往对象存储上搬迁。
其他计算组收到这个表的写入请求时,最多只能是进行请求转发。当前云原生ClickHouse的设定下,用户创建表时请求的计算组就作为该表的写计算组,用户可以通过此方式来指定规划不同表对应到不同的写计算组。大家应该注意到了云原生ClickHouse是表级的一写多读模式,它可以把不同表的写入负载映射到不同计算组上,而不是简单的单个计算组负责所有的写。这种一写多读模式的好处就是把对象存储的写入压力最小化了,但引入了额外的实时数据状态,导致在跨计算组读、计算组节点销毁迁移的过程中都需要进行额外的处理。
在代码架构设计或者用户使用体验上,无疑是多写多读(实时数据写穿到共享存储)模式更优,而且多写多读对于MergeTree存储来说也完全不困难。一写多读模式是我们基于当前产品化能力的一个综合考量设计,后续持久化对象存储层的写入带宽有进一步的提升,ESSD云盘演进出类似共享挂载等能力后,云原生ClickHouse可以进一步升级演进成多写多读模式。
核心技术解析
单节点多shard
熟悉ClickHouse的用户应该知道,ClickHouse的存储能力都是以库引擎、表引擎的方式暴露出来的。这种插件式的存储模块设计,使得ClickHouse中提供的表引擎十分多样化。针对单个MergeTree表引擎去实现数据分片逻辑是无法保证实例级别的数据sharding的。另一个问题是ClickHouse中关于数据sharding的信息也并不是在原有的存储表引擎中定义的,而是在单独的Distributed表引擎(Proxy表)中,也就是正常的存储表引擎中完全没有sharding信息的,只有用户通过Proxy表访问时才会附加sharding规则进行读写路由。
以上的问题导致在单机ClickHouse原有的存储设计上叠加shard概念非常困难同时也会打破原有的使用方式 ,最后我们通过在Catalog层面进行了改造,让单个节点看起来真的变成了"多个节点",这才做到了和ClickHouse的MPP计算引擎完全兼容。
如下图所示,云原生版ClickHouse在整个实例状态层面插入了Catalog Context。Catalog Context会统一管理暴露给用户的库表元数据信息,Catalog Context之间的库表元数据管理完全独立。相当于把原来单个节点的部分元数据移到了Catalog Context内部。Catalog Context内当前管理的对象主要就是存储层的库和表对象,不同Catalog Context之间的库表对象相互不影响。我们通过把多个数据shard绑定到节点唯一的Catalog Context上实现了单节点服务多个数据shard的能力。节点之间的链接请求通过设定catalog参数,就可以访问指定的数据shard。
元数据中心化
开源版本ClickHouse集群中的所有数据都是本地化无中心的一个状态,这里并不是说ClickHouse已经具备了元数据去中心化的能力,而是元数据还没有进入到分布式一致性的阶段。这就导致了用户在自己运维部署的时候经常出现一些诡异的问题:本地配置文件不一致、节点上的库表结构未同步等等。云原生ClickHouse引入了分布式事务KV系统构建整个集群的元数据底座,以此将所有的集群元数据进行了中心化管理。云原生ClickHouse中的元数据主要包括以下几类:
- 参数配置文件,config.xml users.xml等;
- Access Control信息,覆盖集群的账户、角色、权限、Quota、Profile等数据;
- 分布式DDL任务队列;
- Shard粒度的库表元数据信息(DDL);
- MergeTree表引擎特有的元数据,Data Part信息,异步Mutation任务信息;
像参数配置、账户权限数据、分布式DDL任务等,都是可以由单个节点向Controller节点发起数据变更请求,Controller节点再操作底层KV系统进行持久化。所有其它节点都会通过和Controller节点的心跳来感知元数据的版本变化,不断对齐内存中的元数据状态。这里可能存在一个最基本的多节点写写冲突问题,对不同的元数据类型系统有不同的解决方式。在底层分布式事务KV系统中用户的读写都是可串行化的隔离级别,而Controller节点层面对不同类型的元数据修改可能采取的策略包括:覆盖写、拒绝掉版本落后的修改提交(节点提交的修改版本落后于KV系统里的版本)、自动Sequential写(DDL任务队列)。
库和表的DDL信息是云原生ClickHouse中最核心的元数据信息,这部分元数据在多节点间无法对齐对用户造成的困扰也最大。ClickHouse的去中心化,单层架构模式,使得它没有传统分布式数据库中的"Master"节点,用户看到的直接就是底下的"Worker"节点,而每个"Worker"节点必然会有自己本地的一个元数据版本。用户在进行Alter Table的时候会基于当前节点的元数据生成发布对应的分布式DDL任务,然后等待所有节点异步完成该任务。这就导致ClickHouse在执行分布式Alter Table的时候多个节点会有短暂不一致的现象。
为此ClickHouse社区版本已经在做对应的分布式DDL任务并行处理优化,通过DDL任务的并行处理提升效率,可以大大减少单个任务引起的全队列阻塞,避免节点间元数据长时间的不一致。云原生版ClickHouse经过单节点多shard能力改造后,库表元数据的管理粒度已经从节点级别变成了shard级别。实例所有数据shard中的库表元数据都会保存到中心化的分布式KV系统中,不同数据shard间的元数据相互独立,使数据shard可以动态挂载到任意节点中。节点挂载某个数据shard后,在Catalog Context中会有异步的DDLWorker线程监控执行对应计算组中发布的DDL任务,进而执行shard级别的元数据变更。
在单计算组多节点场景下,有可能存在多节点的Alter Table冲突。ClickHouse对这类写写冲突的处理方法非常简单:所有分布式DDL任务都在同一个队列里面,并且一定会被可串行化执行,这样一来和前序任务有冲突的任务一定都会失败,相当于延判冲突到最后才把冲突错误返回。而在多计算组场景下,集群进一步引入了读写同步问题:某个计算组修改表的元数据后其他计算组如何感知这个元数据变化。
上一章中介绍了云原生ClickHouse一写多读的架构设计,在库表的元数据管理上系统也是按照一写多读模式来工作,每个用户表都只会有一个Owner的计算组,用户的Alter Table操作都只能路由到对应的Owner计算组节点中。Owner计算组通过分布式DDL任务队列来完成所有shard分片的元数据变更。当这部分元数据持久化到分布式KV系统后,非Owner计算组就可以通过和Controller节点的心跳来感知表的元数据版本变化。
非Owner计算组在对待只读表的时候,会根据他们的引擎类型进行转换来提供查询服务,例如Mysql外表引擎、Distributed表引擎原本就没有本地状态,可以用ReadOnly SnapShot的方式进行构建管理,另一些数据链路类的Kafka表引擎等则不需要跨计算组可见,其中最复杂还是MergeTree表引擎的跨计算组只读能力构建,下一节中将详细展开。
MergeTree数据共享
ClickHouse的MergeTree存储引擎本身就具备异地更新,无内存状态的特性,非常适合做基于共享存储的一写多读。熟悉ClickHouse的同学应该清楚MergeTree表引擎的数据状态可以由DataPart集合来表示,DataPart都是Immutable的对象,对应到存储上就是一个独立的文件夹(提交后不会再修改),而MergeTree的异步变更状态则由Mutation集合来表示,DataPart和Mutation的名字里都引用了commit id,可以表示出它们commit的"时间"范围,以及彼此的可见性。
为了对MergeTree做一写多读改造,我们首先把DataPart元数据和Mutation记录这两部分信息放到了分布式KV存储中,让多计算组都可见。而DataPart的变更和Mutation的执行只会由具有写权限的单个节点来执行。只读节点有了DataPart的元数据集合,就可以轻易地从共享存储中构造出当前MergeTree表引擎的只读对象。这里主要存在以下三个核心难点:
首先是只读计算组如何实时获取维护DataPart的集合,第一种方式是在定时从Controller进行全量同步,第二种则是全量同步+后台增量同步,系统会根据只读表使用的频率来择优选择同步的方式。为了达到增量同步的目的,我们在分布式KV中不光记录了DataPart的瞬时集合,也记录了DataPart的变更日志,只读节点通过不断同步变更日志就可以完成增量同步。
在现有的一写多读模式下,部分DataPart的存储数据可能还处在Owner计算组节点的本地缓存盘中,对于这部分数据只读节点无法通过共享存储访问,转而只能通过内部查询的方式进行数据读取,这里我们对原有的MergeTree引擎读链路也进行了改造。
原有的MergeTree表引擎是启动时进行索引数据强加载的模式,在动态多计算组模式下这种强加载行为在很多场景下并不成立,用户会在特定的计算组中倾向使用特定的业务表,所以我们把DataPart的加载模式改造成了Lazy Load的模式,大大减少了计算组之间的非必要影响。
对象存储加速
在使用对象存储的数据类型产品中,一定会有两类加速优化伴随:第一是对象存储的元数据读写操作加速;第二是热点数据的本地磁盘缓存加速。ClickHouse的MergeTree存储引擎非常依赖底层文件系统接口的操作性能,rename、hardlink、listDir、rmDir等都是MergeTree存储引擎中的常用操作,而对象存储本身并不提供类fs的API,直接用对象存储来模拟以上的fs操作在性能上也不可接受。开源ClickHouse中S3对象存储功能使用了本地磁盘文件作为S3 Object的引用句柄,对这个本地"句柄文件"的读写删除都会进一步操作S3 Object。在云原生ClickHouse中,我们把这部分对象存储的引用句柄也保存到了分布式KV系统中。
我们在Controller节点中借用了POSIX文件系统的抽象,来组织管理对象存储文件的引用信息,具体的引用信息都被保存到inode中,通过这种方式云原生ClickHouse的分布式共享存储具备很高的目录操作性能,同时还兼容hardlink等高级使用方法。操作常规的目录接口时,ClickHouse内核会请求Controller节点来完成对应操作,而在性能关键的读写链路上,ClickHouse内核则只会和Controller交互必要的引用信息,然后旁路掉Controller节点直接读写实际的对象存储文件。
ClickHouse在读取对象存储时,可以使用本地磁盘构建数据缓存,提升热点数据的扫描吞吐。云原生ClickHouse在这里并没有选择使用类似alluxio的分布式共享文件缓存,主要的原因是ClickHouse属于long running的MPP架构范畴节点和数据之间有明确的归属绑定,我们致力于把单机维度的缓存能力做到极致,在ClickHouse内核中构建这个数据缓存能力是最高效的,同时也最能理解系统负载。
结语
云原生ClickHouse目前正在全网公测阶段,当前只开放单计算组形态,多计算组形态仍正在内部测试优化阶段,后面会进一步放开。单计算组产品形态下最大的使用优化点是计算资源的平滑变配能力,水平扩容模式对比开源集群有数量级的效率提升。整体扩容时间和集群总存储量解耦,只和本地缓存盘中的实时数据量有关。欢迎大家来体验使用云原生ClickHouse,给予宝贵的反馈意见。