Doris存储层深度解析:Rowset写入与删除机制实战

📅 发布时间:2026/7/5 12:48:20 👁️ 浏览次数:
Doris存储层深度解析:Rowset写入与删除机制实战
1. 从零开始理解Doris存储层的核心拼图如果你刚开始接触Doris可能会被一堆名词搞晕Tablet、Rowset、Segment、Version……它们听起来都挺重要但又不知道具体是干嘛的。别急咱们今天不聊虚的就从一个最实际的问题入手当你用INSERT或者Stream Load往Doris里灌数据时这些数据到底是怎么一步步从内存落到磁盘并且能被你立刻查到的反过来当你执行一个DELETE语句删掉几百万行数据时Doris又是怎么处理的难道真的去磁盘上把数据块擦掉吗弄明白这两个流程你基本上就对Doris的存储引擎有了七成的把握。我自己在刚开始用Doris做实时数仓的时候最头疼的就是数据导入偶尔会变慢或者磁盘空间长得比预期快。后来花了些时间啃源码、做测试才发现问题的根子大多出在Rowset的写入和删除机制上。这东西就像数据库的“消化系统”数据进来要经过咀嚼MemTable、消化刷盘、吸收发布废物删除数据还得及时清理。这个系统如果运转不畅整个集群的性能和稳定性都会受影响。所以这篇文章我就把自己踩过的坑、调试的经验和源码里看到的设计揉碎了讲给你听。咱们的目标很明确不光要知道Rowset是什么更要搞清楚它怎么工作以及你怎么能利用这个知识去优化自己的业务。我会尽量避开枯燥的理论堆砌多用实际的配置例子、操作命令和性能对比图来说明。咱们先从最基本的名词开始把这些“积木块”认全了再来看它们是怎么搭建成一个高效存储引擎的。2. 庖丁解牛Rowset与版本控制到底在玩什么游戏2.1 Rowset数据变更的“最小逻辑单元”你可以把Doris中的一个Tablet想象成一张大表被切分后的一个独立数据块它是数据移动、副本复制和Compaction的基本单位。而Rowset则是这个Tablet内部记录一次数据导入或删除操作所产生的数据集合。这非常关键它意味着每一次成功的导入比如一个Stream Load作业都会为这个Tablet生成一个全新的Rowset。每一次删除操作也会生成一个特殊的Rowset这个Rowset里不存真实数据只存删除条件。Rowset是一个逻辑概念它可能对应磁盘上的一个或多个物理文件Segment。为什么这么设计我打个比方。假设你的Tablet是一本不断更新的账本。传统数据库可能是在原账本上直接涂改。而Doris的做法是今天的所有新交易记录我单独写在一张新的活页纸上这就是一个Rowset。明天又有新交易我再拿一张新活页纸记录。这本账本Tablet就是由一沓按时间顺序排列的活页纸Rowset装订而成的。这种“追加写”的方式避免了在原有数据上随机修改使得数据写入速度非常快特别适合高吞吐的导入场景。2.2 Version给每一次变更贴上“时间邮票”光有活页纸还不行我们得知道哪张纸是最新的以及这些纸之间的顺序。这就是Version版本的作用。每个Rowset都带有一个版本号区间表示为[start_version, end_version]。对于一次纯粹的数据导入Insert生成的Rowset版本号是“自闭”的比如[7,7]。这表示这个Rowset包含了在版本7这个时间点导入的数据。 版本号是全局递增的。FE前端节点充当了“发号器”的角色确保每次数据变更都能拿到一个唯一且递增的版本号。当你要查询数据时Doris会找到当前最新的、已发布的版本号然后只读取版本号小于等于这个版本的所有Rowset从而给你提供一个一致性的数据视图。2.3 SegmentRowset的物理存储分身一个Rowset在物理磁盘上是由一个或多个Segment文件组成的。Segment是Doris中物理存储和索引生成的最小单元。默认情况下当一个Rowset的数据量超过256MB时就会被切分成多个Segment文件。Segment文件.dat后缀内部是精致的列式存储结构。每一列的数据都被单独组织、编码和压缩并且拥有独立的索引比如前缀索引、ZoneMap索引等。这种设计让Doris在分析查询时可以只读取查询涉及的列并利用索引快速跳过大量不相关的数据块这是它查询速度快如闪电的基石。它们三者的关系我用一个实际的目录结构来展示你会一目了然# BE数据存储目录示例 ${storage_root_path}/data/57/10001/ # 57是分桶ID10001是Tablet ID ├── 0200000000000001a69d5c8a6b3d4f5c_0.dat # Segment文件属于某个Rowset ├── 0200000000000001a69d5c8a6b3d4f5c_1.dat ├── 0200000000000002b79e6d9b7c4e5f6d_0.dat # 另一个Rowset的Segment文件 └── 0200000000000003c87f7e0c8d5f607e_0.dat文件名通常包含了Rowset ID和Segment序号。看到这里你应该对Doris存储层的核心组件有了立体化的认识。接下来我们就让数据“动”起来看看一次完整的写入是如何流经这些组件的。3. 数据写入全链路实战从HTTP请求到持久化文件3.1 宏观视角一条数据如何走进Doris我们以最常用的Stream LoadHTTP导入为例勾勒出数据写入的全局图景。这个过程涉及到FE和BE的协同工作。客户端发起请求你通过curl或者程序向Doris的FE或直接指定某个BE发送一个HTTP PUT请求携带要导入的数据如CSV、JSON格式。FE规划与调度FE接收到请求后会进行SQL解析、权限校验并生成一个分布式的导入执行计划。它会选择一个BE作为本次导入任务的Coordinator协调者。数据分发与写入Coordinator BE负责将接收到的数据根据表的分桶规则分发到包含目标Tablet副本的所有BE节点上。这里有个关键点Doris默认采用多副本机制一份数据通常写两个副本就返回成功由参数write_quorum控制第三个副本会通过副本同步机制异步补齐这保证了高可用和写入性能的平衡。存储层接手数据到达目标BE后真正的“消化”过程才开始。这就是我们接下来要深入剖析的BE存储层写入流程。3.2 微观解密MemTable与DeltaWriter的协奏曲数据到了BE第一个落脚点是MemTable。你可以把它理解为一个在内存中的、临时存放数据的缓冲区。它的底层数据结构是跳表SkipList数据按照建表时指定的排序列DUPLICATE/UNIQUE/AGG模型中的Key列有序存放。为什么用跳表因为跳表支持高效的范围查询和顺序插入这对于保证写入过程中数据的有序性至关重要。有序的数据是后续高效查询、快速Compaction的基础。负责管理MemTable写入的核心组件是DeltaWriter。每个正在被写入的Tablet都会对应一个DeltaWriter。它的工作流程非常清晰接收批量数据从上游的TabletsChannel接收分好批的数据包。写入MemTable将数据插入到MemTable的跳表中。如果是AGG模型此时还会对相同Key的数据进行预聚合减少后续处理的数据量。MemTable刷盘Flush当MemTable的大小达到阈值由参数write_buffer_size控制默认100MB时它就会被标记为“已满”。一个后台的MemtableFlushExecutor线程池会异步地将这个MemTable的数据转换并写入磁盘生成一个新的Segment文件属于某个Rowset。与此同时DeltaWriter会立刻创建一个新的、空的MemTable来承接后续的写入确保导入流水线不会阻塞。这个“内存缓冲 异步刷盘”的模式是LSM-TreeLog-Structured Merge-Tree的经典思想在Doris中得到了很好的应用。它通过将随机的写IO转化为顺序的写IO极大地提升了写入吞吐。3.3 物理写入深潜RowsetWriter与Segment的诞生记当MemTable刷盘时就进入了物理文件生成的精密环节。这个过程由RowsetWriter主导。一次导入作业LoadJob最终会生成一个Rowset而RowsetWriter就是负责构建这个Rowset的“总工程师”。它的内部是一个层次化的写入器结构RowsetWriter管理整个Rowset的写入生命周期。SegmentWriterRowset可能包含多个Segment每个Segment由一个SegmentWriter负责。它负责组织这个Segment的全局信息比如生成文件Footer。ColumnWriter每个列都有一个对应的ColumnWriter。这是列式存储的体现每个列独立进行数据编码如Bit-Packing、Run-Length Encoding、压缩如LZ4、Zstd并写入数据页Page。IndexBuilder在写入数据的同时各种索引如前缀索引ShortKeyIndex、每列的OrdinalIndex、ZoneMap索引也在同步构建并作为独立的页Page写入Segment文件。PageBuilder负责将数据组织成固定大小的Page这是IO和缓存的基本单位。具体的刷盘流程可以概括为MemTable的数据已排序被逐行喂给RowsetWriter - RowsetWriter根据当前Segment大小决定是写入现有Segment还是创建新的SegmentWriter - 数据被分发到各个ColumnWriter - ColumnWriter将数据编码压缩后写入Page并通知IndexBuilder生成索引 - 当Segment大小达到阈值或MemTable数据写完时触发Flush将所有内存中的Page和索引落盘形成.dat文件。这个过程里所有的数据转换、编码、压缩、索引构建都是在内存中完成最后批量顺序写盘效率非常高。你可以通过SHOW TABLET FROM your_table命令在观察到导入期间对应Tablet的磁盘空间会逐步增长这就是Segment文件在持续生成。3.4 临门一脚Rowset的发布与可见性数据写入磁盘生成Segment文件后是不是立刻就能查到了还不是。这里有一个关键的“发布”动作。当一次导入的所有数据都成功写入磁盘即所有MemTable都刷盘完毕后DeltaWriter会做最后几件事收集元数据统计这个新Rowset的行数、数据量、Segment数量、时间范围等信息封装成RowsetMeta。提交事务将RowsetMeta提交给FE报告“我这个Tablet的导入任务完成了生成了一个新版本的数据”。FE发布版本FE确认所有相关副本都完成后会下发一个“发布版本”的指令。这个指令包含了新的版本号比如从7发布到8。BE生效数据BE收到指令后才会将这个新Rowset的版本状态设置为“可见”Visible。之后所有新的查询就能读到这个Rowset里的数据了。这个“发布”机制是Doris实现原子性导入和一致性查询的核心。它保证了在发布那一刻之前客户端是看不到部分导入的数据的看到的永远是完整版本的数据快照。4. 删除操作的魔法标记删除与Compaction清理删除DELETE操作是Doris里非常有意思的一部分。如果你以为它像传统数据库那样直接找到数据行然后抹掉那就错了。Doris的删除是“标记删除”Delete Mark。4.1 删除的本质生成一个特殊的删除Rowset当你执行DELETE FROM table WHERE ...时会发生以下事情FE解析删除条件并将删除任务下发给相关的BE。BE启动一个EngineBatchLoadTask本质上和导入任务类似但这次不是导入数据而是生成一个特殊的Rowset。这个Rowset里不包含任何实际的数据行只包含一个“删除条件”的元信息比如WHERE column 100。这个Rowset同样拥有一个递增的版本号。FE发布这个删除Rowset的版本使其生效。此时磁盘上的原始数据文件没有任何变化。这个删除操作的成本极低速度极快因为它只写入了很少的元数据。那么查询时如何生效呢4.2 查询如何过滤已删除数据当执行查询时Doris的查询引擎Scanner会做两件事读取所有版本号小于等于查询版本号的、包含真实数据的Rowset我们叫它Data Rowset。读取所有版本号小于等于查询版本号的、删除类型的RowsetDelete Rowset。在内存中进行数据合并时应用删除条件将符合条件的数据行过滤掉不返回给用户。所以删除操作在第一时间只是打了个“标签”告诉系统“这些数据逻辑上没了”物理删除要留到后续的Compaction阶段。4.3 Compaction真正的空间回收工标记删除虽然快但副作用是数据文件并没有变小磁盘空间没有释放并且查询时需要额外进行过滤计算影响性能。这时候就需要Compaction压缩合并出场了。Doris的Compaction分为两种Cumulative Compaction负责合并近期写入的多个小尺寸的Rowset包括数据Rowset和删除Rowset形成一个稍大的Rowset。这个过程会应用删除标记在合并后的新文件中被删除的数据就不会再写入了。Base Compaction负责将Cumulative Compaction产生的中等大小Rowset与历史基线数据Base Rowset进行合并形成更大的、更有序的文件。这是深度清理和优化查询性能的关键步骤。只有在Base Compaction完成后被标记删除的数据所占用的物理磁盘空间才会被真正回收。Compaction策略由多个参数控制例如cumulative_compaction_num_singleton_deltas: 触发Cumulative Compaction的小文件数量阈值。base_compaction_num_cumulative_deltas: 触发Base Compaction的Cumulative文件数量阈值。compaction_task_num_per_disk: 每个磁盘上并发执行的Compaction任务数。如果删除数据后发现磁盘空间下降不明显或者查询速度变慢十有八九是Compaction没有及时发生。这时候你需要监控BE日志或者通过SHOW PROC /compaction命令查看Compaction队列和状态。5. 实战调优让你的写入与删除又快又稳理解了原理我们就能针对性地调优。下面是我在项目中总结的几个关键点和实操命令。5.1 写入性能优化点MemTable调优write_buffer_size单个MemTable的大小阈值。调大可以减少刷盘频率提高吞吐但会增加内存消耗和单个导入任务的延迟。需要根据BE内存和导入批次大小权衡。memtable_flush_executor_numMemTable刷盘线程数。如果写入非常频繁可以适当增加此值如从默认的10调到20避免刷盘队列堆积。导入并发与批次对于Stream Load/Routine Load不要用极小的批次频繁写入。适当调大单个批次的数据量如从默认的100MB调到500MB可以减少Rowset的数量减轻Compaction压力。可以在导入命令中设置-H max_filter_ratio:0.5和调整curl的缓冲区但更关键的是在业务端进行合理的批次聚合。观察SHOW PROC /cluster_balance/tablet_sched确保Tablet在各个BE上分布均匀避免写入热点。Segment文件大小segment_writer_flush_size控制单个Segment文件的目标大小默认256MB。在HDD磁盘上过小的文件会产生大量随机IO在SSD上可以适当调小。一般保持默认即可。5.2 删除操作与Compaction调优避免高频小批量删除频繁的DELETE会产生大量小的删除Rowset严重加剧Compaction负担。尽量将删除操作合并比如按天或按小时进行批量删除。监控Compaction状态-- 查看各个BE的Compaction情况 SHOW PROC /compaction;关注CumulativeScore和BaseScore如果分数持续很高比如大于1000说明Compaction严重落后需要考虑调整参数或增加资源。调整Compaction参数如果Cumulative Compaction跟不上可以尝试调低cumulative_compaction_num_singleton_deltas让它更敏感地触发。如果Base Compaction太久不触发导致版本链过长可以调低base_compaction_num_cumulative_deltas和base_compaction_interval_seconds_since_last_operation。注意调低阈值会增加Compaction频率消耗更多CPU和IO资源需要在后台任务和查询性能之间取得平衡。使用分区与时间函数管理数据生命周期对于时间序列数据最推荐的做法是使用分区表并结合DROP PARTITION或TRUNCATE PARTITION来删除旧数据。这是物理删除瞬间释放空间没有Compaction开销效率远高于DELETE。5.3 问题排查锦囊写入变慢首先检查BE日志看是否有“MemTable flush too slow”或“waiting for flush...”之类的警告。可能是刷盘线程池满了或者磁盘IO瓶颈。其次用top和iostat命令查看BE进程的CPU和磁盘使用率。磁盘空间不释放执行了DELETE但空间没变化。用SHOW PROC /compaction看Compaction是否积压。用ADMIN SHOW REPLICA DISTRIBUTION FROM your_table查看数据分布是否均匀。有时需要手动触发Compaction通过调整参数或重启BE触发。查询变慢版本数过多执行SHOW TABLET FROM your_table找到对应的Tablet ID然后去BE的http://be_ip:8040/api/compaction/run_status?tablet_idxxx查看该Tablet的版本数。如果版本数Rowset数量长期很高比如上百就是Compaction跟不上写入需要按上述方法优化。存储层的调优是个细致活没有放之四海而皆准的参数。最好的方法是结合监控系统如PrometheusGrafana监控Doris的Metrics观察写入速率、Compaction分数、版本数量、磁盘IO等指标的变化趋势进行针对性的调整。记住一个核心原则追求写入吞吐、查询性能和后台资源消耗三者之间的动态平衡。当你真正理解了Rowset的写入和删除机制这些调优操作就不再是黑盒魔法而是有理有据的工程决策了。