背景与前情
什么是数据分区,为什么需要它
数据分区(Partition)并不是对一个数据库天然的需求,商业数据库里面出现分区表功能要晚到 1997 年的 Oracle8。并且即使是在今天,它也没有变成一个数据管理系统的必选项,无论是现在消费级单节点的部署形式就已经足够撑起相当规模的数据量,还是分区机制也已经下沉到诸如文件系统或并行计算框架等更底层的系统抽象,从而使得数据库本身也不一定再需要基于有限资源的假设来进行设计。我们期望一个数据库提供数据分区能力,通常不仅仅是单纯希望一个数据库系统能处理更大的数据量,而且也暗含了一些运行环境的期望,比如单节点规格不要太大、没有昂贵的高端硬件提供、最好整体成本也能再低一点等等,同时也能够连带着将容灾调度、负载均衡、动态扩缩容等额外的运维需求也处理好(题外话 )。套用一句话来说,分区不会消失,只会转移。由数据库提供分区能力,那么就对上不需要业务系统做很多事情,对下也不需要特殊的基础设施,在当今算是一个比较甜点的区域。
简单来说驱动数据分区出现和发展最直接的原因就是大家的数据量越来越大,单机、单库、单表的维护成本和使用成本都越来越大,并且拓展规模上限的边际成本越来越高。所以大家就想以某种方式“切开”数据变成多个分区,通过将一个个好处理的小分区在后期合成一个大的对用户的抽象来解决拓展性的问题。同时这些物理上的小分区还能够方便地提供其他额外的功能,比如分块删除、分散热点、限定数据范围(当然这个是不得已而为之)等等。
具体怎么切分数据,各家也有不同的做法。这里讨论的 GreptimeDB 可以是一个时序数据库,就以这个细分领域的内容举例子。所谓时序数据,就是数据基本都和时间相关,其实很简单,这篇博客有一些不错的动图帮助理解,但在本文中也不会涉及到太多领域知识。如果将时间戳丢到主键列里面去,那么你就有了一个时序的数据库。面对这种一定会有时间戳的情况,一个很直觉的切分方式就是将数据按不同的时间切开,例如每天的时间戳是一个分区,随着时间的前进不断地创建新的分区,这样理论上就有了一个无限的表。实际上有的系统就是这样实现的,比如很常见的 Prometheus,默认按 2 小时的时间切分一次,每个分区(Prometheus 内叫做 Block)有独立的数据块、元数据和索引等内容。基于这种分区方式实现的数据生命周期(TTL,Time To Live)管理也很方便,等一个分区内所有的数据都过期了之后直接把整个分区的物理内容全部一起删掉就行。这样启动一个实例之后一直往里面写就行了,永远不用担心什么时候会被被写满。
分区不仅是为了存更多的数据
Prometheus 这样是分区了,但大家还是把 Prometheus 叫单机数据库,可拓展性更是没有人提,想要接入更多的写入量只能垂直扩容使用更大规格的机器。因为这样子进行数据分区只是单独解决了总共能存多少数据的问题,并没有提供更多的拓展性。对于时序数据来说,大部分数据的产生都是在物理时间上相对集中的,如果只按照时间分区,那同一时间内活跃的分区永远是最新的几个,所以并不能把不同的数据分区放到不同的节点上进行处理,或者是说给一个分区分配更多的资源。
所以为了能够真正地同时处理更多的数据,也就是增大系统吞读量,除了切分数据本身之外,分区还需要能够解决与数据关联的计算负载。便于接下来的讨论,这里可以将负载简单区分为写入负载和查询负载。Prometheus 的分区方式对写入和查询负载都没有分开,所以只能在存储量方面有提升。
从这个例子中我们知道,单单进行时间上的分区是不够的,它只能解决长期历史数据的问题。从提升吞吐量的角度看,对于时序数据库来说,只在时间戳列上进行分区实际上约等于没有分区。所以我们需要继续从其他的维度对数据进行进一步地切分,按照前面对时序数据的简单定义“主键中包含时间戳列”,除了时间戳之外的其他主键列就是我们进一步切分的目标。其实讲到这里,如果把时间戳摘掉的话,所谓时序数据就和普通的数据库所处理的数据没什么区别了。这也是 GreptimeDB 的做法,将时间戳单独拎出来定义,剩下的部分就是一个普通的数据库表,分区就直接在主键列上定义。对于摘出来的时间戳部分的分区,GreptimeDB 将它做成了隐式并且默认的行为。(另一张图,和上一张对比,多一个维度进行分区)这样不仅解决了数据量上能一直增加新数据的需求,也从主键层面进行切分,将与数据关联的负载分散开给系统整体增加了可以横向扩容的基础。每一个主键分区上的数据也是独立并且自包含的。接下来为了简化讨论,所有的分区都特指在主键列上定义的分区,时间戳上的分区就忽略掉了。
分区管理的方式
在选择好从主键上进行分区之后,就需要关注系统具体是怎么处理和管理每一个分区的。GreptimeDB 中将一个数据分区叫做 Region,处理各个 Region 的节点角色就叫 Region Server。大体架构的示意图如下:(todo: 简化版架构图)
可以看到,这样子理论上就拥有了无限的扩容能力,同时需要处理的数据变多了就增加更多的分区,增加更多的节点,从而保证每一个分区所能分配到的资源与所需要处理的负载或它们的比值能始终维持在一个相对稳定的值。
从这个图里也能看出 GreptimeDB 对部署环境以及适用场景做出的一些假设:有状态的 Region Server 上持有每个 Region 的写入缓存 (MemTable)、各层读缓存以及 SST (Sorted String Table)文件的磁盘缓存,是层数很少的 LSM-Tree 架构,适合部署在内存比例高且带有本地盘的节点上;相对来说 Frontend 节点状态更轻,主要前置处理读写请求,如编解码、聚合等,计算型负载比例更高,作为 Region Server 的前置路由层;这些组件都依赖 Kubernetes 提供部署能力;数据真正的持久化层则交由 Object Store 提供的可靠及大容量抽象来负责,并且是数据的唯一可信源(Single Source of Truth);所有的元数据管理与存储代理给其他提供高可靠 KV 接口的组件,各种 RDS 也包含在内。整体算一个比较常见的框架。
看上去对于这样的一个系统来说,分区的迁移和重分区等操作应该是比较简单的,基本上只会有路由元数据的修改和缓存预热相关的操作,不涉及到物理数据的搬迁或分布式状态的共识等等。但实际运行中我们在这上面碰到了许多的问题,也促使我们做出了一些调整。
轻重失调的职责
一个分区应该对应什么
在早期以及后来的很长一段时间,分区(Region)是 GreptimeDB 中唯一的且原子的最小数据管理抽象,随着各种或计划中或计划外的功能一点点加入,分区这个粒度的概念所对应的职责也慢慢变多。它后来同时是查询的路由单元、写入的路由单元、未持久化以及持久化数据文件的管理单元、元数据的操作单元以及缓存的管理单元等等。在早期的时候这样多位一体也许是合理的,整体的开发流程都是围绕着同样的一个东西来做,之后再从它上面封装出各个功能和面向用户的概念,维护的心智负担比较低。但是到了后面各方面的问题就开始逐渐显露出来。
比如前面说到,分区定义了物理数据在逻辑上的归属,每个分区对应一组数据集合和它全生命周期的管理。这已经变成了一个不变量体现在系统的设计和元数据格式中,但显然忽略了一个问题,一组分区切分规则(Partition Rule)只是一个短暂的状态,并不是一成不变的,当需要调整分区规则(Repartition)时,会发现每个分区从设计上就完全无法访问到其他分区的文件和数据。好在系统整体是以对象存储作为持久化存储层的假设来开发的,补救起来也还算简单,只要调整一下文件管理的逻辑和对元数据做一些可以兼容掉的改动就行,不需要引入昂贵的数据物理搬迁之类的过程。
除了概念层面的混乱导致的错误抽象之外,一个分区内部状态的管理也很复杂。作为唯一的数据管理单元,它自然也需要负责这个分区内所有数据相关的操作,包含完整写入与查询操作,以及修改(Alter)、压实(Compact)、注册元数据、版本控制等管理性的任务。而写入根据实现还能再细分为写预写日志(WAL)、内存缓冲、排序去重、刷写成文件(Flush)并上传对象存储、生成索引等;查询也能细分为加载元数据与索引、兼容默认值与类型、扫描过滤、读时去重、计算下推算子等。各种内容都全部绑定在了一起,很快我们就发现即使有分区还是难以拓展,一个分区的概念最终实现得非常“重型”,经常不好说哪里就会先遇见瓶颈,并且调整分区的时候也会要一块调整,不管是手工还是自动调参都是很难选择合适参数的情况。而且负载高的时候一个分区内的各个任务还会互相影响,搅得事情更加混沌。
展开讨论的话会有些散乱,具体的细节留待以后单独写,简单来说目前存在一些过度设计的情况,感兴趣的伙伴可以看看 GreptimeDB 中 mito2 和状态相关的部分,上面是找 ChatGPT 老师画的状态转移图可以简单感受。要解决状态的问题还是会要回到这个小节标题上,先捋清楚一个分区真正需要负责的东西并将无关的内容调理好就行。在这篇中我们只看和分区相关的内容。接下来以重分区为例讨论目前碰到的问题。
重分区要调整哪些东西
按前面说的来看重分区在理想情况下只需要调整分区切分的映射规则就行,看起来很简单,可实际情况并非如此。修改元数据时,需要同步更改五组元数据,中间再涉及到各个步骤的回滚策略等等,元数据修改完之后,还要处理重分区中各个涉及到的分区状态的改变,比如用一次刷写清理掉当前的内存缓冲区,重新分配已经存在的数据文件,中间还要继续处理新的写入请求尽量保证写入服务不中断等等。为此重分区的流程(Procedure)也变得比较复杂,还通常涉及到集群内从所有组件多个节点的相互调用,具体的重分区过程解析会放到另一篇单独的文档中说明。
除了上述在线的修改流程,在目前的重分区还会带来一些可以在后台进行的整理任务,比如根据新的分区切分规则重新压实历史数据文件以提高查询的局部性,以及将重分区过程中暂停的压实任务重新启动等等,在在线写入流量很高的时候,会对系统整体的工作量带来巨大的波动,无论压实操作是否单独派发给专门的节点执行,最后表现在集群整体的监控水位上都会有一个下跌再上升的过程。加上预期内的机器资源申请和释放,重分区的整体流程使得它本身的执行代价难以被忽略,从而不得不作为运维类型的重操作来看待。
读还是写
分区为了什么负载服务
除了重分区过程中遇到的问题,分区功能本身的定位也不是很清晰,在相当长的一段时间内都是错位的。前面提到,分区同时决定了负载的归属,但是什么负载都需要交由分区负责吗?我们现在的答案是否定的,分区在大部分时候只应该为了查询负载而服务,也就是说基于查询负载来调整分区。但是 GreptimeDB 实际运行过程中,分区功能更多的是为了满足写入负载,也就是前面说的木桶的情况,无论是因为测试使用顺序还是实际负载,写入部分都是最先达到分区上限的,而我们也自然地希望通过调整分区来满足写入负载。不过这实际上是错误的操作,回顾写入流程我们能看到许多前置的任务,比如客户端协议到内部表示的转换、数据校验与填充、包括后续的编码成文件并上传到对象存储等,这些是显然可以独立处理且无状态能随意扩容的任务,不管这部分写入负载是否与分区切分相绑定,都是可以独立执行的,而不需要固定占用某个分区对应的资源。对于写入任务分区真正切分和独占的并非完整的写入负载,而是最终写入的数据的所有权。
为了解决这个问题,我们开发了“批量写入”的新写入路径,所有写入的数据先在路由节点(Frontend)上完成所有能独立完成的工作,并将文件上传到对象存储,上传完成之后再拿着文件的标识符去给对应的分区处理,新的路径上对于写入请求一个分区所需要使用资源处理的负载被大幅减小为登记一个已经写好的文件即可。这种方案能够将分区真正需要处理的写入负载缩减到约等于没有,解决了错位的问题,也使得后续继续迭代分区相关的能力能够专注到查询负载上。当然这样的改动还涉及到一些可见性和失败模型的小问题,留到以后单独说新架构改动的时候一起讨论。
这样虽然解决了写入的问题,但是也给查询带来了新的麻烦,以前数据是先进入分区的内存缓冲再到对象存储上,而现在这个过程反了过来,为了查询性能,缓存加载相关的逻辑也需要做些调整,分区需要主动去从对象存储上读新生成的数据文件,并按情况提前加载到本地磁盘和内存中,避免查询时触发对象存储的首次查询延时。不过这样也暗合了最开始对 GreptimeDB 中各个节点的假设与定义,Region Server 正好是适合大内存的机型,能够接受一定程度的无效缓存。况且相比于之前一定会先占用内存缓冲的写入方式,在某些场景下反而存在降低内存开销的可能。
只查询,然后呢
在进一步将分区的负载缩小到查询之后,我们会发现现在的“分区”整件事情快变得不重要了。目前的层级上每个分区持有一组数据及其关联的某些负载的所有权,通过写入增量更新的配置文件(Manifest)来确认文件的存在并定序,查询的时候按照记录的文件目录去读。可能由于历史重分区的存在导致一个文件的数据分段被不同的分区持有,但影响也不大。
往下看,数据的最小物理单位是一个个文件,分区是将这些文件或者里面的数据进行逻辑上的划分,如果从拆分查询负载的角度出发来审视现在的分区方式,会发现它还是太重了。很简单直接的一个问题就是,为什么不同的查询需要应用同样一组(相对)固定的切分规则呢?即使不关心是否是时序场景下具体的查询,显然在不同的查询下各个文件最优的分配方式会有区别。这时候在文件之上再有一个分区概念就显得有些多余,或者说,每一个数据文件天然就是一个分区,通过合适的规则切分好文件带来的效果可能已经足够。以这种新的模型我们仍然能够套用现存的比如缓存管理机制,并且能够以更精细的方式进行调度。同时去掉的中间这一层多余的抽象也能大大简化很多元数据管理的压力和复杂度。
到这里,你的注意力可能会注意到这和最开始提到的 Prometheus 的处理方式比较接近了。没错,除了数据文件直接存到对象存储,以及数据文件之间在主键有分区之外,剩下大体上都是相似的。架构增增删删删删也算正常发展,这一部分的内容还不在 GreptimeDB 的路线图上,只是个人的想法,知来者之可追。
怎么分区,怎么自动分区
友好的方式
最后,绕了一大圈弯子我们又重新把问题规约回一个简单的“如何在主键上进行切分”讨论上。这也是一个比较成熟的领域,从简单到复杂,常见的可能有这么几种。比如说随机打散,写入时将请求随机路由到一个地方,查询的时候全部查出来再合并;或者稍微控制一些随机的程度,进行一致性哈希,节点增删起来会比较方便,同时也能减少一点读放大。这两种都相对更适合计算资源和存储资源绑定的场景,由哪里写入的数据只能由哪里读取。再往上稍微分离一下可以有分区处理,将值域塌缩成一维的并进行范围切分,老的 MySQL 到相对新的 TiKV 都提供这种方式,这样节点和负载之间就可以随意调度,并且处理热点时也能比较精细的进行再切分与合并。
不过我们以上几种都不是,切分范围相对是最适合的,但是它用起来不够友好,在多个主键列塌缩到一个维度上的时候,一定会需要对各个列之间的顺序进行定序,然后使用多条件排序的方式进行多个列之间的定序,列顺序会显著影响过滤效果,需要仔细考量顺序,对于使用方来说多少都是限制。于是我们稍微拓展了一下,选择保留每个列原本的维度,通过划分多维空间来定义分区范围,每个列之间仍然保序,这样对查询的时候比较友好,以分区列中的任何一个列做过滤都能有一样的最佳效果,另外规则本身从人类看起来也容易理解,不需要进行多条件的比较排序。
另外这样子的方式对机器也是比较友好的,可以很方便地从历史查询中推导出热点区域,并进行动态的调整。只需要根据查询时附带的过滤条件(Filter Predicate)和真实读到的数据的统计信息比如最大最小值,就能方便地推导出当前的热点。反过来说,独立的范围表达式也能很方便地附加到用户查询上,从查询来到查询去,贯彻了上面“分区只为查询负载服务”的观点。
无需干预的乌托邦
当然理想与现实常常存在着巨大的差距,在多维表达式的功能开发完之后,经常被吐槽说列表太长了人没法写。这是事实,不过这个事实本身也是由另一个差距带来的。上面设想机器能根据负载动态维护分区规则列表,但这个功能最近才开发完成,离最开始分区功能的实现已经过去了近三年,才逐渐凑齐一整套工具。
以用户友好的角度来看,既然最终数据的可持久化存储是一个接近无限的对象存储抽象,足够有钱的话节点资源池也能当成一个无限的东西,那无限加无限的事情怎么会需要人来关心具体发生在“无限”的哪一部分呢?所以分区功能的目标就是用户,至少是普通用户,不需要知道分区功能的存在,机器自己就能知道怎么安排最方便好用。
这个目标现在还差很远,既有前面提到的(1)分区绑定了太多东西,一个分区的状态太重操作太多,(2)重分区涉及到的状态与步骤太多,无法作为轻量自动化操作,(3)分区仍然被用来解决写入的问题,查询是二等公民等原因,还有目前集群自己的负载反馈流程不够完善,还不具备从查询负载推导出热点的能力。
总体来看,早期为了降低实现复杂度顺着很大的惯性在开发,目前经过了一些简化,但还存在许多的过度设计的部分没有修改,也缺失一些必要的功能需要继续开发。希望回顾历史的进程能在未来的什么地方少走一些弯路。