StarRocks I/O 模型揭秘(一):查询是如何被拆解与调度的?

0 阅读19分钟

0.PNG

作者:丁凯 StarRocks TSC Member

导读:在存算分离架构下,查询性能的稳定性越来越依赖底层 I/O 设计。本文作为 StarRocks I/O 模型揭秘系列首篇,将围绕 Tablet、Fragment、Pipeline、Morsel、Scan Operator 等核心概念,梳理一次查询执行过程中底层 I/O 链路的基本运行机制

StarRocks 是一款面向高并发、低延迟分析场景的高性能 MPP 数据库。围绕其数据组织方式与底层 I/O 流程,社区中一直有不少关注和讨论。尤其在引入存算分离架构之后,查询链路对 I/O 的依赖进一步增强:相较于传统本地磁盘访问,对象存储在高带宽之外,也伴随着更高访问延迟等特征,这也给查询性能稳定性带来了新的挑战。如何在这样的架构基础上,持续保障 StarRocks 的亚秒级响应能力,成为系统架构与 I/O 层设计中的关键问题。

本系列文章将围绕 StarRocks I/O 层展开,系统梳理其核心设计与运行机制。作为第一篇,本文将聚焦 StarRocks 存算分离架构下,一次查询执行过程中与底层 I/O 相关的关键概念与机制。通过 Tablet、Fragment、Pipeline、Cache、ScanOperator、Morsel 等核心对象,可以更清晰地理解数据如何从底层存储系统被读取,并逐步进入计算引擎完成处理,也有助于进一步理解 StarRocks 在延迟隐藏、并发调度以及冷热数据管理等方面的设计思路。

希望通过本文,帮助大家从底层视角建立对 StarRocks I/O 模型的整体认知,也为后续进一步理解其性能优化机制打下基础。

基本概念

Tablet

Tablet 是 StarRocks 中数据存储的基本物理单元,可以将其理解为一张表数据在物理层面的水平分片。作为 MPP 数据库,StarRocks 在建表时会按照既定规则将表数据划分为多个 Tablet,每个 Tablet 对应其中一部分数据。通过将这些 Tablet 尽可能均匀地分布在不同的 CN 节点上,StarRocks 实现了数据的分布式存储与管理。

在 StarRocks 中,Tablet 的数量由建表时设置的分桶(Bucket)数决定,二者一一对应。建表时,用户可以通过 DISTRIBUTED BY 子句指定分桶键以及分桶数量。例如,创建一张订单表,按照 user_id 进行哈希分桶,并设置 10 个分桶:

CREATE TABLE orders (order_id BIGINT,  user_id BIGINT,  amount DECIMAL(10,2) ) DISTRIBUTED BY HASH(user_id) BUCKETS 10;

这条建表语句会创建 10 个 Tablet,表中数据会按照 user_id 的哈希结果分布到这 10 个 Tablet 中。作为 StarRocks 中的基础物理单元,Tablet 在查询执行、数据管理和系统扩展等方面都承担着重要作用。

  1. 实现并行计算,提升查询性能。

在查询执行过程中,StarRocks 可以并行扫描多个 Tablet,以提升整体查询效率。以一张包含 10 个 Tablet 的表为例,查询引擎可以将扫描与计算任务拆分到多个并发执行单元中,分别处理不同 Tablet 上的数据,最终再对结果进行汇总。基于 Tablet 的并行处理机制,StarRocks 能够更充分地利用多核 CPU 和分布式集群资源,从而提升查询性能。

  1. 作为数据管理的基本单位。

在 StarRocks 中,许多数据管理操作都以 Tablet 为基本粒度展开。例如,数据写入会落到具体的 Tablet 上;Compaction 等数据整理操作也是围绕单个 Tablet 内部的数据组织进行;在集群负载均衡场景下,Tablet 也是节点间数据迁移和调度的基本单位。以 Tablet 为粒度进行管理,使系统在数据组织和运维调度上具备更高的灵活性。

  1. 支持系统的水平扩展。

在集群扩容时,StarRocks 可以通过 Tablet 级别的调度,将部分计算任务或数据访问压力更均衡地分散到新增节点上,从而提升整体资源利用率。在存算分离架构下,这种能力进一步增强,系统可以更灵活地实现横向扩展与负载均衡。

总体来看,Tablet 是贯穿 StarRocks 数据存储、查询执行与系统扩展的核心概念之一。合理设计分桶策略与分桶数量,对于系统性能和资源利用率都有重要影响。

Fragment

在 StarRocks 中,FE 负责生成查询对应的物理执行计划。用户提交的 SQL 会依次经过 Parse、Analyze、Rewrite、Optimize 等阶段,最终形成一棵由物理算子组成的执行树。

Plan Fragment 是物理执行计划拆分后的基本组成单元,也是实现分布式并行执行的重要基础。FE 会将完整的物理执行计划拆分为多个 Plan Fragment,并分发到不同节点上执行,从而支持多机并行处理。每个 Plan Fragment 本身同样由一组物理算子构成。

除了算子本身,Plan Fragment 还包含 DataSink,用于将当前 Fragment 的输出发送给下游 Fragment。具体来说,上游 Fragment 会通过 DataSink 将数据传递给下游 Fragment 中的 Exchange 算子。

在 Pipeline 执行引擎中,BE 还会在 Plan Fragment 的基础上进一步拆分出多个 Pipeline。

Pipeline

Pipeline 是由一组算子串联而成的执行链路。数据从 Source Operator 进入,经过中间多个 Operator 的处理后,由 Sink Operator 输出到下一个执行阶段。

从结构上看,一个 Pipeline 通常由三部分组成:Source Operator、中间 Operator 以及 Sink Operator。

Source Operator 是 Pipeline 的起点,主要有以下几类数据来源:

  • 从本地文件或外部数据源读取数据,例如 Scan Operator;

  • 接收上游 Fragment Instance 的输出数据,例如 ExchangeSourceOperator;

  • 接收上游 Pipeline 的 Sink Operator 输出结果,例如 LocalExchangeSourceOperator。

位于 Pipeline 中间位置的算子会消费上游输入,并将处理后的结果继续传递给下游算子。

Sink Operator 位于 Pipeline 末端,负责将当前 Pipeline 的结果输出到下游执行单元。

在 Pipeline 内部,各算子之间以 Chunk 作为数据交换的基本单位。Chunk 通常表示一批固定行数的数据。执行过程中,Source Operator 先产生原始输入 Chunk,并交给下游算子处理;后续每个算子都会基于输入 Chunk 生成新的输出 Chunk,再继续向下游传递。整个过程以流式方式不断推进,直到结果最终输出到 Sink Operator。

从执行机制上看,对于一对相邻算子,执行线程通常会先调用前一个算子的 pull_chunk 接口获取数据,再调用后一个算子的 push_chunk 接口完成数据传递。

关于查询如何被拆解为 Fragment 和 Pipeline,可参考: zhuanlan.zhihu.com/p/718817400,本文不再展开。

ScanOperator

Scan Operator 是 StarRocks Pipeline 执行引擎中负责数据读取的算子,也是查询执行链路中数据进入计算引擎的起点。它的主要职责是从存储层读取数据,并将数据以 Chunk 的形式输出给下游算子进行后续处理。

在一次查询执行过程中,后续的过滤、聚合、排序等操作,通常都建立在 Scan Operator 提供的原始数据之上。因此,Scan Operator 不仅承担着数据读取职责,也直接影响整个查询链路的执行效率。

在 StarRocks 的 Pipeline 执行模型中,一个查询通常会被拆分为多个可并行执行的 Pipeline。相应地,系统中也会存在多个 Scan Operator 实例并发工作,共同完成对目标数据的扫描与读取。

如下图所示,在一个典型查询对应的 Pipeline 中,位于数据流上游的 OLAP_SCAN 节点,所代表的就是负责读取底层数据的 Scan Operator。

Morsel

Morsel 是 StarRocks Pipeline 执行引擎中的基础调度单元,可以将其理解为一次可独立分发和执行的扫描任务。在查询执行过程中,Tablet 中的数据会被进一步拆分为多个 Morsel,并由 Scan Operator 从 Morsel 队列中持续获取待处理任务,再创建对应的 ChunkSource 完成具体的数据扫描。

从执行机制上看,Morsel 的引入使扫描任务能够以更细粒度进行调度,而不必始终以整个 Tablet 作为最小处理单位。这样一来,系统可以根据线程空闲情况动态分配新的扫描任务,从而提升整体并发度,并改善不同执行线程之间的负载均衡。

在较简单的场景下,一个 Tablet 可以对应一个 Morsel;而在启用 Tablet 内并行扫描后,一个 Tablet 也可以被进一步拆分为多个更小的 Morsel,以提供更细粒度的并行处理能力。

在 StarRocks 中引入 Morsel,主要有两个目的:其一是支持 Tablet 内并行扫描,提升 Scan 阶段的执行效率;其二

是增强系统对数据倾斜场景的适应能力。

为便于理解,下面通过一个简单示例进行说明。假设有一张用户行为日志表,用于记录用户访问行为:

CREATE TABLE user_logs (log_id BIGINT, user_id BIGINT, action VARCHAR(50), log_time DATETIME ) DISTRIBUTED BY HASH(user_id) BUCKETS 4;

这张表共包含 4 个 Tablet,每个 Tablet 存储了约 500 万行数据,总数据量约为 2000 万行,现在,执行如下查询:

SET pipeline_dop = 8; SELECT COUNT(*) FROM user_logs WHERE action = 'click';

优势 1:实现 Tablet 内并行,提升 I/O 效率

在未启用 Tablet 内并行时,一个 Tablet 仅对应一个 Morsel,因此这张表总共只会生成 4 个 Morsel。即使系统为当前查询创建了 8 个 Scan Operator 实例,真正能够参与执行的也仍然只有 4 个,因为可调度的 Morsel 数量本身只有 4 个。

此时,执行过程大致如下:

ScanOperator 0 获取 Morsel 0(对应 Tablet 0),开始扫描 500 万行数据;

ScanOperator 1 获取 Morsel 1(对应 Tablet 1),开始扫描 500 万行数据;

ScanOperator 2 获取 Morsel 2(对应 Tablet 2),开始扫描 500 万行数据;

ScanOperator 3 获取 Morsel 3(对应 Tablet 3),开始扫描 500 万行数据;

ScanOperator 4-7 处于空闲状态,无法分配到可执行的 Morsel。

在该示例下,扫描阶段的实际并行度仅为 4,其余 Scan Operator 处于空闲状态,系统可用的 CPU 资源未能被充分利用。

如果启用了 Tablet 内并行扫描能力,假设系统根据规则将每个 Morsel 的目标大小控制在约 256K 行。对于每个包含约 500 万行数据的 Tablet,系统会将其进一步拆分为约 20 个 Morsel(5000000 / 256000 ≈ 20);因此,4 个 Tablet 总计会生成约 80 个 Morsel。

在执行初始阶段,8 个 Scan Operator 可以同时从共享的 Morsel 队列中获取待处理任务,例如:

  • Scan Operator 0 获取 Morsel 0(Tablet 0 的第 1 个分片,行号 0–256K);
  • Scan Operator 1 获取 Morsel 1(Tablet 0 的第 2 个分片,行号 256K–512K);
  • Scan Operator 2 获取 Morsel 2(Tablet 0 的第 3 个分片,行号 512K–768K);
  • ……
  • Scan Operator 7 获取某个来自 Tablet 1 的 Morsel。

当某个 Scan Operator 完成当前 Morsel 的扫描后,会立即从队列中继续获取下一个 Morsel,直到所有 Morsel 全部处理完成。

例如,假设每个 Morsel 平均处理耗时约为 2 秒,则:

  • 未启用 Tablet 内并行时:总共只有 4 个大粒度 Morsel,实际并行度为 4。若每个 Tablet 的扫描耗时约为 40 秒,则整体扫描阶段耗时约为 40 秒;

  • 启用 Tablet 内并行后:总共生成约 80 个小粒度 Morsel,在 8 路并行下,整体耗时约为 80 × 2 / 8 = 20 秒。

可以看到,在启用 Tablet 内并行之后,扫描阶段的实际并行度由 4 提升到 8,系统能够更充分地利用 Scan Operator 和 CPU 资源,从而显著缩短查询执行时间。

优势 2:更好地处理数据倾斜

假设一张表包含 4 个 Tablet,且数据分布并不均衡:

Tablet 0:100 万行

Tablet 1:300 万行

Tablet 2:500 万行

Tablet 3:1100 万行

在未启用 Tablet 内并行时,每个 Tablet 仅对应一个 Morsel,则执行过程可能如下:

ScanOperator 0 处理 Tablet 0,耗时约 8 秒;

ScanOperator 1 处理 Tablet 1,耗时约 24 秒;

ScanOperator 2 处理 Tablet 2,耗时约 40 秒;

ScanOperator 3 处理 Tablet 3,耗时约 88 秒。

在这种情况下,整个扫描阶段的总耗时将由最慢的任务决定,也就是约 88 秒。其余 Scan Operator 在较早完成各自任务后,将进入空闲状态,导致系统资源无法被持续利用。

如果启用 Tablet 内并行,则每个 Tablet 会被进一步拆分为多个更小粒度的 Morsel。例如:

Tablet 0 拆分为 4 个 Morsel

Tablet 1 拆分为 12 个 Morsel

Tablet 2 拆分为 20 个 Morsel

Tablet 3 拆分为 44 个 Morsel

此时,系统共生成约 80 个 Morsel,8 个 Scan Operator 会从共享队列中动态获取待处理任务。即使 Tablet 3 的数据量显著大于其他 Tablet,它也会因为被拆分为更多 Morsel,而由多个 Scan Operator 共同分担处理。这样一来,原本先完成小 Tablet 扫描的执行线程,也能够继续处理来自大 Tablet 的剩余任务,从而实现更均衡的负载分配。

若仍假设每个 Morsel 的平均处理耗时约为 2 秒,则整体耗时约为 80 × 2 / 8 = 20 秒。与未拆分场景下约 88 秒的执行时间相比,扫描阶段的整体效率将获得提升 4 倍+

从这个例子可以看到,Morsel 机制通过更细粒度的任务拆分与动态调度,不仅提升了系统可实现的并行度,也有效缓解了数据倾斜问题,是 StarRocks 实现高性能并行扫描的重要机制之一。

在真正开始数据扫描之前,系统会先将 Tablet 中的数据拆分为多个 Morsel,并放入 Morsel Queue。随后,Scan Operator 会持续从 Morsel Queue 中获取待处理任务,并将其交由底层执行与 I/O 处理流程继续完成扫描。

ChunkSource

ChunkSource 是 StarRocks Pipeline 执行引擎中负责执行实际扫描工作的组件,可以将其理解为面向单个 Morsel 的数据读取执行单元。每个 ChunkSource 会绑定一个 Morsel,负责从存储层读取该 Morsel 对应的数据,并将读取结果按 Chunk 的形式组织起来,供上层 Scan Operator 消费。

在执行过程中,Scan Operator 会根据调度需要创建并管理多个 ChunkSource。每个 ChunkSource 获取一个 Morsel 后,负责完成对应范围内的数据读取;读取完成后,生成的 Chunk 会被交由 Scan Operator 继续处理,随后该 ChunkSource 还可以被复用于后续的扫描任务。可以将 ChunkSource 理解为 Scan Operator 在具体扫描阶段所调度的执行载体:前者负责真正读取数据,后者负责统一组织这些读取任务。

从职责划分上看,Scan Operator 更侧重于扫描任务的组织与调度,而 ChunkSource 则负责具体的数据读取执行。

ChunkSource 的工作方式是异步的。当 ChunkSource 开始读取数据时,会先将读取任务封装为 ScanTask,并提交到全局 I/O 线程池中执行,而不是阻塞当前 Scan Operator 的执行线程。借助这一机制,多个 ChunkSource 可以并发执行 I/O 操作,从而更充分地利用系统的并发处理能力。

在实现上,每个 Scan Operator 内部都会维护一个固定大小的 ChunkSource 数组,其大小决定了该 Scan Operator 最多可以同时管理多少个 ChunkSource,也即最多可以并发处理多少个 Morsel。

从整体链路来看,ChunkSource 位于调度层与执行层之间,承担着连接两者的重要作用:上游承接 Morsel 调度与 Scan Operator 的任务分发,下游对接 I/O 线程池与底层存储系统。ChunkSource 的作用主要体现在以下几个方面:

  • 执行具体的数据读取操作。 ChunkSource 是直接与存储层交互的组件。它会根据 Morsel 中携带的信息,例如 Tablet ID、扫描范围以及底层数据位置等,调用相应的存储接口执行数据读取。
  • 支持异步 I/O 和批量处理。 ChunkSource 会将读取任务提交到全局 IO 线程池中异步执行,从而避免阻塞 Pipeline 的执行线程。同时,读取过程通常不是按单行进行,而是以批量方式生成多个 Chunk,以降低任务调度与 I/O 调用的额外开销,提升整体扫描效率。读取完成后的 Chunk 会暂存在 ChunkSource 的内部缓冲区中,供 Scan Operator 后续消费。
  • 支持精细的并发控制。 一个 Scan Operator 可以同时管理多个 ChunkSource,这些 ChunkSource 可以并发处理不同的 Morsel。例如,当一个 Scan Operator 配置了 16 个 ChunkSource 槽位时,它就可以同时驱动多个数据读取任务并发执行,从而提升整体 I/O 吞吐能力。与此同时,系统也会结合资源使用情况,对活跃 ChunkSource 的数量进行控制,避免内存溢出。

ScanTask

ScanTask 是 StarRocks 中提交到 I/O 线程池执行的具体任务单元,可以将其理解为一次被异步派发的数据读取任务。当 ChunkSource 需要从存储层读取数据时,并不会直接以阻塞方式完成读取,而是先将对应操作封装为 ScanTask,再提交到全局 I/O 线程池中异步执行。随后,I/O 线程池中的工作线程会从任务队列中获取 ScanTask,并完成实际的数据读取。

从实现形式上看,ScanTask 的核心通常是一个可执行对象,用于封装具体的数据读取逻辑。除此之外,ScanTask 还会携带与调度和运行状态相关的上下文信息,例如任务优先级、所属 WorkGroup 以及部分性能统计信息等,以支持任务调度与执行过程中的监控分析。

在整个查询执行链路中,ScanTask 是连接 ChunkSource 与 I/O 线程池的重要载体,也是实现高并发数据扫描的重要机制之一,主要体现在以下几个方面:

1)实现异步 I/O 和非阻塞执行

ScanTask 最核心的作用,是将 IO 操作从 Pipeline 的执行线程中解耦出来。在 StarRocks 的 Pipeline 执行模型中,执行线程负责驱动算子链路持续向前推进;如果在该线程中直接执行 I/O 操作,就可能因为等待数据返回而长时间阻塞,进而影响整个执行链路的推进效率。通过 ScanTask 机制,执行线程只负责提交读取任务和消费结果,实际的数据读取则交由独立的 I/O 线程池完成,从而提升系统整体的并发执行能力。

2)支撑大规模并发 I/O

全局 I/O 线程池通常包含较多工作线程,这些线程可以同时执行大量 ScanTask,从而实现高并发的数据读取能力。在访问远程存储时,这一点尤为关键。以 S3、HDFS 等存储系统为例,单次 I/O 请求的访问延迟通常较高,往往在几十到几百毫秒之间。

例如,假设单个 IO 请求的平均延迟为 100 ms,那么单路请求的吞吐能力大约只有 10 次/秒;如果将并发请求数提升到 100 路,整体吞吐能力就可以达到约 1000 次/秒。

也就是说,在高延迟存储场景下,只有建立足够高的并发度,系统才能获得更理想的 I/O 吞吐表现。

ChunkBuffer

从执行机制上看,ChunkBuffer 的核心作用在于解耦数据的生产与消费过程。前者对应底层 I/O 线程执行的数据读取,后者对应 Pipeline 执行线程驱动的数据处理。通过 ChunkBuffer,系统能够以生产者—消费者的方式组织两类线程之间的协作:IO 线程持续将读取到的数据写入缓冲区,执行线程则从缓冲区中获取可用 Chunk 并推进后续算子处理,从而减少二者之间的直接互相等待,提高整体执行效率。

例如,在下图所示的执行链路中,Scan Operator 读取到的 Chunk 会先写入 ChunkBuffer,随后再由下游算子继续消费和处理,例如进一步交由 ACC Operator 执行后续计算

总结

从整体视角来看,StarRocks 的 I/O 执行链路通过 Morsel、Scan Operator、ChunkSource、ScanTask 与 ChunkBuffer 等组件的协同配合,构建了一套从数据调度、任务拆分到异步执行与结果消费的完整闭环。数据被以更细粒度拆分为可调度单元,通过动态队列实现负载均衡;扫描任务以异步方式下沉到 IO 线程池执行;读取结果再通过缓冲机制与计算链路解耦,从而实现高并发、低阻塞的数据处理流程。