作者:常冰琳,StarRocks Committer(本文为作者在 StarRocks Summit Asia 2022 上的分享)
本次分享将围绕如下框架展开:
1. StarRocks 存储引擎现状,包括实时更新主键模型在实时分析场景中面临的新需求和挑战。
2. 主键模型基础上的最新功能和优化,包括主键索引持久化、部分列更新、条件更新、高频导入的优化、DML功能添加。
3. 存储引擎相关的工作规划,包括模型统一、易用性提升、物化视图的支持等。
1、主键模型
大概在一年前,我们在 1.9 版本中发布了新的存储引擎,支持实时更新,主要用来满足实时分析动态数据场景需求,在支持实时更新的同时保持查询的高性能。它是基于 Delete + Insert 方式或 merge-on-read 的方式来实现更新的。相比原来 merge-on-read 的 uniq 模型,在导入性能几乎不受影响的前提下,查询性能提升了 3-10 倍。
虽然仅支持整行的 upsert 和 delete,但它非常适合 TP 到 AP 实时同步数据并加速查询的场景。一般通过 Apache Flink(以下简称 Flink) CDC 工具将 TP 业务系统,比如 MySQL 的数据直接同步到 StarRocks 系统中加速查询,极大地简化实时分析的数据流。它非常简单易用,目前已有很多个用户在线上系统中采用,是目前实时数据分析的典型范式。
2、局限
虽然使用广泛,在最初设计和实际使用的过程中,我们也注意到主键模型和使用范式的局限性。
首先,由于开发资源的问题,本着实现简单、性能高效的初衷,我们使用了基于全内存的主键索引,它的性能非常好,对写入性能影响非常小。但最大的问题是内存消耗非常高,这直接限制了使用场景:场景一是总行数相对可控的业务场景。比如用户状态表或用户画像表,总用户数量级不大(比较小的维度表)等。场景二是数据冷热特征业务。把数据按照时间,比如按天或者按月分区,这样需要保证最近的热数据会被修改,老的冷数据不会再被修改,整个数据仅有一小部分的热数据索引才会被加载,从而使得使用内存得到一定程度的控制。
另一个比较常见的问题是高频导入。StarRocks 是一个批量(微批)系统,从事务系统到存储引擎的设计都是以处理不太频繁的大事务为前提,这与传统的 TP 系统或近年出现的 HTAP 系统追求高并发大量小事务是非常不一样的,和按行准实时处理的流式系统也是非常不一样的。随着用户对 StarRocks 实时分析的需求越来越高,微批的规模就会越来越小,以满足越来越高的实时性的需求。带来的挑战是它的写入频率会非常高,事务数量非常大,事务也越来越小。特别是引入 Flink CDC 等流式 ETL 的数据流之后,为了提高性能,通常会开启多个并发导入事务,使得事务数量成倍增多,这对现有的设计是一个很大的挑战。我们有些客户经常会遇到 too many versions、too many pending versions 之类的问题,本质上就是我们系统最初的设计并不能很好地解决高频导入这一问题。
最后一个问题是功能性和易用性是待完善的。首先是对部分列更新和条件更新的支持。目前 ETL 数据流强依赖这种导入方式。然后是相关的 DML 需要补足,比如 delete、update、merge 语句的缺失,这些语句对从 ETL 到 ERT 的衍进是非常重要的。最后是主键模型和 uniq 模型类似,还不支持创建更通用的物化视图。由于数据会发生更新变化,无法及时维护,相比没有更新的 duplicate 和聚合表来说,实现它的资源消耗会多很多,困难也会多很多,当然这也是 StarRocks 实现不可能的一个机遇。
1、持久化主键索引
持久化主键索引用来解决主键索引被吐槽最多的内存问题。我们原来的主键索引是基于全内存哈希表的,新的持久化索引同样使用了基于哈希的设计,并且使用了类似 LSM 的多层设计。为了保证性能,目前只有两层:第一层还是哈希为内存的哈希表,它是 mutable 可修改的,同时会写 WL 来保证持久化。第二层是基于磁盘的哈希表结构,immutable 不可修改。为了节约存储空间,我们同样使用了原来全内存索引表下的 balance 设计。测试结果显示它的索引更新性能相比全内存索引下降了一些,但对于一般的写入吞吐来说是够用的。另外由于索引本质上是一个大量的随机读的 IO 操作,我们推荐使用 SSD 或者 NVMe 磁盘来开启持久化索引。
内存占用方面,相比原来的全内存,内存占用只有约原来的 1/10。下图是我们导入测试时的内存对比,左边是 BE 进程的内存总占用,右边是索引的内存总占用。在全内存索引模式下,可以看到随着数据的持续导入,总内存最高可以达到 120GB,索引内存最高达到 60GB。在开启持久化索引之后,随着数据的持续导入,总内存最高约 70-80GB,索引内存最高到 3-4GB,内存的使用下降是非常可观的。
2、部分列更新
原来的主键模型表导入时只支持整行的 upsert 和 delete 操作,upsert 的操作需要提供所有列的值。有了部分列更新的功能之后,就可以仅仅提供需要更新的列,其他没有更新列就不再需要提供。常见的使用场景是我们在 StarRocks 中创建一张大宽表,逻辑上来说它是由多个表 join 而成,上游的每个模块仅负责大宽表中的一部分列,对于其他不负责的列,是无法知道当前值的。
另外一种数据流建模方式是在 StarRocks 中按照模块去创建多个表,然后查询时对这多个表进行 join。虽然 StarRocks 在多表查询方面,性能优化已经非常好了,但是在一些极端场景下,用户还是期望使用大宽表方式来做查询,带来极速查询性能。
目前如果想要实现效果,有几个不太优雅的妥协方案。方案一是在上游的数据流中插入 join 模块或者 join 算子,通常用 Flink 做流失的 join。当多个模块的数据流实时做 join 拼成整行后,再导入到 StarRocks 中。缺点是需要提供一个额外的流计算模块和配套的状态存储。方案二是使用 TP 系统构造一个大宽表,上游模块一部分以列更新的方式写入 TP 系统,再通过 TP 系统同步给 AP 系统,这样的话就需要额外搭建一套 TP 模块和 CDC 同步模块。方案三是分模块导入 AP 系统,在 AP 系统中通过 DML 定期地去做 join,刷新到大宽表中,但会牺牲一部分实时性。这三种模式都需要引入新的模块,增加了系统的复杂度,不是一个完美的方案。如果 StarRocks 能够支持部分列更新,就可以很好地解决问题,极大的简化和构建实时交易数据流的难度。为了实现简单和已有设计的兼容和传承,目前 2.3 版本已经实现了部分类更新的功能,以现有的 Delete + Insert 模式为基础。基本流程可以参考下图。
假设一个表有 4 列,第 1 列是主键,1 个部分列更新,更新主键为 3 的行,第四列为 Y,其他列保持不变。那我们就需要先找到主键为 3 的这一行的一个位置,找到之后读取第二三列的当前的值,填充进原来这个部分列更新的行,使它变成一个全列的 upsert 的行,剩下的操作就和原来的整行 fullrow upsert 类似了。这里有个特别需要注意的问题,原来的 follow upsert 是一个纯写入的事务,事务冲突处理就会相对简单。而部分列更新增加了对当前版本数据先读,再覆盖写入的一个过程,本质上是一个读写事务,需要检查冲突并解决这个冲突。在 StarRocks 中,我们使用了类似乐观事务的冲突检查和处理重试机制,即使发生冲突也会尽量减少重做的一个工作量。
采用 Delete + Insert 的方式来实现部分列更新会带来一个非常严重的局限,即读写放大问题。特别是在大宽表场景下,仅更新很少一部分列的情况。比如一个表有 1000 列,只更新其中的1列,需要先读取其余的 900 多列,然后再写入全部的 1000 列,相当于读写各放大了 1000 倍。特别是读取阶段,由于是列存,所有的列都是分开存储的,意味着会带来非常多的随机 IO。所以目前推荐部分列更新仅仅在一些列不是特别多的场景下使用,比说小于 500 列的情况下,并且尽量在 SSD 磁盘上使用这个功能。随着列数的增加,我们写入的性能会逐渐衰减。为了部分解决这个问题,目前规划中的解决方案是引入行存,这样能够解决一部分读放大的问题,但是写放大的问题依旧没有解决。要解决这个问题,估计需要在存储引擎上有重大的架构重构。
3、条件更新
我们在导入时添加了条件的功能,只有条件被满足时才会进行这个更新。目前仅支持固定的条件。当导入的数据是乱序或者由于并发导入导致数据乱序,为了防止乱序导的数据把新的数据给覆盖掉,数据中一般会有一个时间戳字段代表该行的修改时间。这样在更新时可以指定一个条件,当这个时间戳大于当前时间时才进行这个更新操作,否则就忽略该更新行。假设导入的时记录是 src,要覆盖的目的是 dest。上面提到条件可以用src.ts > dest.ts 来表示。当前仅实现了这一种条件表达式,但后面我们会添加更加通用的表达式支持,让它变成更加通用的条件更新。
4、高频导入优化
针对高频导入导致的各种问题,比如 too many versions、too many pending versions 的报错导致整个导入失败。我们对 publish、compaction 做了大量的优化,比如对 publish 的 task 并行执行和 rocks DB 元数据提交的并行执行,可以大大缩短 publish 的延迟,从而提升整个事务的吞吐能力。另外针对 compaction,我们添加了劣势的 vertical group 的 compaction 来提升 compaction 的性能。我们还设计了新的 compaction 机制来解决 BE 中当 tablet 数量特别大时 compaction 的调度问题。
针对 Flink CDC 同步任务并发导致的事务数量成倍增加的问题,我们新增加了基于 streamload 的事务导入接口。原来每个 Flink Sink 的 task 会单独对应一个导入事务,导致事务数量成倍增加。使用新的事务接口之后,可以将多个导入任务合并成一个事务。在定期 Sink 开始前,开启这个事务,然后并行导入写入数据,最后全部 task 完成数据的传输后,整体再提交这个事务。在上面这个例子中,总事务数就可以从 4 个减少到 1 个。高频导入的问题,本质上是事务数量多的问题,通过降低事务数量,可以避免高频导入带来的一系列问题。
5、DML
原来的 duplicate key、aggregate key、uniq key 表模型对 delete 是有支持的,但是它只支持简单的表达式,具体是通过在一个版本中记录 delete 相关的元数据,然后在运行时重新进行过滤的方式来实现的,这样对查询性能有很大的影响,特别是多次使用 delete 时。主键模型新添加了对 update 和 delete 语句的支持,并且提供了更加完整的功能。语句中可以包含更加复杂的表达式和子查询等。当然它距离目前商业数据库 update delete 的功能还有一些距离,我们还在持续地完善中。
在事务层面,DML 本质上和纯写入事务的导入和纯读取事务的查询都不太相同。DML 是一个读写事务,在事务处理层面上,比导入和查询要复杂一些。目前 StarRocks 中导入和查询的事务隔离级别是最高的。主键模型引入 update 和 delete 语句之后,目前这两个语句隔离级别只能达到 read committed 的级别。在 AP 的大部分场景中可以基本满足需求。对事务隔离级别有强需求的一些应用,我们后期也计划提供支持,具体可能通过添加表锁的方式,但这种方式对事务的并发能力有一些影响。
随着 update 和 delete 功能的完善和存储引擎功能的增多,未来还会考虑添加 merge into 语法的支持。我们认为 DML 功能的完善是实时数据分析从 ETL 模式向 ERT 模式演化的一个重要的前提。在 ERT 模式下,原始数据可以直接导入数仓或者目前更加流行的互仓,通过 DML 和物化视图在数仓内构建处理数据流,可以极大地简化数据流的构建难度和成本。
1、主键与排序键分离
一个表的 key 代表了两方面的属性,一是数据的组织方式(存储顺序)。不同的排序方式,意味着不同的查询类型的加速作用。二是数据集的唯一约束。通过唯一约束保证导入数据的唯一性,不丢不重,以及可以通过主键定位到某一行来进行更新和删除操作。还有一个额外的作用是分布式数据库一般是有很多个分片的,需要通过 key 来将数据分布到不同的分片,所以 key 必须是不可变的。
在目前的主键模型中,可以认为 sort key 和 primary key 是统一在一起的,实现会更简单。数据的排序方式可以加速查询,但是如果按照主键排序,就丧失了这对这些查询的优化的机会。比如图中的表主键是 id,如果按照 id 排序,对 city 过滤的查询就需要去扫描全表或者依赖其他一些二级索引加速过滤。如果把 sort key 和 primary key 拆分开,创建表时可以指定 sort key 和 primary key 不一样,id 作为主键负责更新的唯一约束。city 作为 sort key 负责数据的存储顺序,可以加速查询。
目前 StarRocks 各种的表模型,包括 suplicate key、aggregate key、uniq key 都是标准 SQL 表模型的几种特殊表现形式。统一到一种语法表达之后,对用户来说,我们整个系统只有一种表,其概念和数据模型是和标准的数据库模型是非常类似的,这样是最容易理解的,并且可以进一步提升整个 StarRocks 的易用性。
2、部分列更新与导入接口
目前部分列更新只支持固定列的更新,同一个导入事务中所有行的更新的列必须完全的一致,这就对上游数据造成了很大的一个限制。因为很多应用场景并不是简单的一个多表交应,比如说图中表达的两种这个部分列更新的一个模式。左边是完全静态的部分列更新的支持方式,目前我们是支持的。但是右边的这种任意列更新的方式目前还是不支持的。原因是目前整个导入数据流程都是以向量化的方式去实现的,向量化要求数据是整齐的列存储的方式的表达。那如果强行使用列存的方式组织任意列更新的导入,就会有很多额外的开销,并不高效。
比如一个 1000 列的大宽表,如果每行仅更新其中的一列,如果使用列存组织,这个空间膨胀会非常大。最合适的方式还是回归行存的方式组织,这样就需要对整个导入流程增加一个行存的数据流,工程量会大一些。
比较有趣的是,最初我们最初导入部分的数据组方式就是行存的,但是我们为了优化性能,专门把它改成了列存方式,行存的方式逐渐就废弃掉了。那没想到就是随着这个需求和产品的演进,行存方式重新变得重要起来了。
3、物化视图
目前主键模型并不支持 rollup 和物化视图,而 rollup 功能也非常有限,其本质上是一种聚合类的,不需要中间状态的物化视图。StarRocks 新一代的物化视图架构会支持一些更高级的功能,包括透明查询加速、离线的全量构建和实时的增量构建等等。
我们主要关注的就是物化视图对存储引擎层面的一些新需求。一是增量物化视图的构建需要能够提供表的一个增量修改数据。那么我们认为它是一种类似 binlog 的概念,对表的所有写操作都要生成一份日志,里面记录了哪些行被删除掉,哪些行被添加,类似 MySQL 中的 binlog。另外一个是维护物化视图的算子,通常是需要一个状态存储的,并且这个状态存储是需要可持久化的,那也需要存储引擎提供状态存储的支持。其他的查询加速对存储运行也会有新的需求。
4、行存
最后一个方向是存储引擎中行存的引入。前面介绍过的超宽表部分类更新带来的读写放大问题,可能通过行存可以部分解决读放大的问题。还有物化识图的 binlog 生成和状态维护,原有的列存,在底层原理上都是有先天不足的。而行存目前更适合这些需求。所以近期我们也在探索在存储引擎中引入行存,未来很可能是行列混存,并且是云原生基础上的架构。
关于 StarRocks
StarRocks 创立两年多来,一直专注打造世界顶级的新一代极速全场景 MPP 数据库,帮助企业建立“极速统一”的数据分析新范式,助力企业全面数字化经营。
当前已经帮助腾讯、携程、顺丰、Airbnb 、滴滴、京东、众安保险等超过 170 家大型用户构建了全新的数据分析能力,生产环境中稳定运行的 StarRocks 服务器数目达数千台。
2021 年 9 月,StarRocks 源代码开放,在 GitHub 上的星数已超过 3500 个。StarRocks 的全球社区飞速成长,至今已有超百位贡献者,社群用户突破 7000 人,吸引几十家国内外行业头部企业参与共建。