
昨晚线上的告警群又响了。一张每天30亿行写入的订单明细表例行报表查询从平时的1秒出头暴涨到37秒。登录BE节点一看目标分区的Tablet版本数已经堆到2800多个CompactionScore直接冲到四位数。这种场景我相信很多Doris用户都不陌生——写入侧一直很猛Compaction跟不上节奏最后所有代价都结结实实打在查询侧。Apache Doris的存储引擎在高层抽象上一直在做一件事用Compaction机制把LSM-Tree这套增量写入方式的“账”一点点平掉。不管你是刚按官网文档完成Doris安装部署、正在研究参数调优还是已经在生产环境跑了一两年只要你的表还有写入、有更新、有删除就绕不开Compaction。这篇文章我会从LSM-Tree为什么非要合并讲起拆开Doris两级CompactionBase和Cumulative的分工逻辑再落到参数、指标、手动触发合并和Compaction失败排查上。全文以实操视角为主尽量把原理讲成人话。1. LSM-Tree的账本逻辑写入越快欠的账越多1.1 一次导入在Doris里到底发生了什么先用大白话还原Doris的数据写入路径。Doris的一张表在建表时要指定分桶Bucket每个分桶是一个TabletTablet是底层存储管理和数据复制的最小单位。你执行一次导入Stream Load、Broker Load、Routine Load或者Insert Into数据先进入内存中的MemTableMemTable写满或者达到时间阈值后Flush成一落不可变的Segment文件。若干个Segment组成一个Rowset这个Rowset会带着一个新的Version追加到Tablet的版本链上。这里的关键点是“追加”而不是“覆盖”。在Unique模型下如果你重复导入相同Key的数据Doris不会去改之前那个Segment文件而是生成一个新Version的Rowset里面记录最新数据。查询时引擎要把多个Rowset拉到一起做归并按Key去重后返回给用户。这个过程本质上就是多路归并排序类似把N份有序链表合并成一份。账本类比是这样的LSM-Tree的写入就像记账员每天把流水写在新的一页纸上页面越来越多整本账越来越厚但从不回头修改旧页。Compaction就是定期重新装订账本把很多页内容合并整理成少数几页。1.2 版本堆积的代价查询为什么要做多余的合并如果只有写入没有合并会发生什么最直接的后果就是读放大的雪崩。假设一个Tablet上有2800个Rowset你要查一条主键数据理论上最坏情况要遍历2800份Segment的索引再对2800份结果做归并去重。哪怕每一份都很小2800这个数量级也足以让CPU打满、IO毛刺严重。很多Doris慢查询优化案例查到最后都发现SQL没毛病、索引没毛病、内存也没毛病毛病出在这个Tablet积累了几百上千个版本。第二个代价是空间放大。Unique模型下旧Version的Rowset里可能保存着已经被新版本替代的旧数据这些数据在Compaction之前不会物理删除。如果你频繁更新一张大表又不让它及时Compaction磁盘占用会明显高于表内有效数据量。我见过一个极端案例逻辑上只有10亿行的表因为长期不合并物理存储膨胀到30亿行还多。第三个代价是扫描放大。全表扫描或大范围扫描时每个版本的文件头、索引都要读一遍合并后的返回结果只有一份但中间读到的临时数据是好几份。这直接拉低Scan节点的吞吐量。1.3 为什么不能用B树那套原地更新的思路很多从MySQL转过来的朋友会问Doris为什么不能像InnoDB那样找到老数据所在的页直接改掉原因在于两种引擎的目标不同。InnoDB面向随机读写用B树维护有序索引定位到具体页然后原地更新这个机制在低并发、小事务场景下很优雅但随机写盘是它的天敌。而Doris的定位是海量数据的批量导入和OLAP分析写入模式基本是持续的大文件追加顺序写盘的效率比随机写盘高出一个数量级。为了保顺序写LSM-Tree选择把“维护有序性”这个负担后置到Compaction阶段。所以Compaction并不是一个可以省掉的“优化项”而是LSM形态存储引擎的必需品。它本质上是在用后台异步的、批量的合并操作去偿还写入阶段欠下的“排序账”和“去重账”。理解这一点再看Doris官方把Compaction健康度列为重点监控项就完全不奇怪了。2. Doris的两级Compaction机制Base与Cumulative的分工2.1 先认识Rowset、Version与Cumulative Point在深入两级合并之前必须把三个概念对齐。Rowset是Doris存储中一组数据文件的集合导入生成的多个Segment组成一个Rowset它在Tablet内部对应一个数据版本。Version是一个自增的版本号每次导入、Compaction完成都会产生新的Version。Cumulative Point是Doris Tablet元数据里一条逻辑分界线分界线之前的Rowset视为已经整理得差不多的“老数据”分界线之后的Rowset是需要合并的“新数据”。用装订档案来理解Cumulative Point很形象档案管理员有一个工作台Cumulative Point工作台后面是已经装订成册的合订本Base层工作台上堆着最近送来的散页文件Cumulative层。日常工作是不断把散页合并成小册子当小册子足够厚或者散页足够多时再把它往后归档和旧合订本合并成更大的合订本。Doris默认的Compaction运行流程就是在不断围绕Cumulative Point做“小整”和“大扫除”。2.2 Cumulative Compaction日常的“小整”Cumulative Compaction负责合并Cumulative Point之后的新Rowset。它的目标是控制Tablet上的Rowset数量让版本堆积速度不失控。触发它不需要等所有版本都攒到最后而是检测到新Rowset数量达到阈值由参数控制就会挑一批连续的Rowset做归并产出一个较大的Rowset。Cumulative策略有两个阶段早期版本用NUM_BASED后来演进到SIZE_BASED。NUM_BASED逻辑比较简单不看单个Rowset多大只数数量攒够N个就合并前N个。这个策略在Rowset大小不均匀时效率不好——一个大文件加一堆小文件按数量合并会把大文件反复读写IO浪费严重。SIZE_BASED策略是当前主流思路把Rowset按大小分入不同“层级”同类层级的Rowset优先合并合并后如果体积上升到下一个层级再参与下一轮。这其实就是借鉴了RocksDB/LevelDB的Tiered Compaction思想核心是让每次参与合并的文件大小尽量同量级避免大小文件混着合。生产环境里如果发现Cumulative Compaction一天到晚在跑、但版本数仍然压不下去大概率是策略参数配置和写入模型不匹配。2.3 Base Compaction定期的“大扫除”Base Compaction负责的是Cumulative Point之前的全部老版本数据。它会把Tablet内所有的Rowset包括已经被Cumulative合并出来的成果统一归并成一个大版本并且真正处理删除标记、回收被更新覆盖的旧数据空间。Base Compaction的频率低通常由版本数、累计大小等条件触发。单次成本很高因为它面对的是这个Tablet几乎全部的数据量需要额外的内存、磁盘IO和临时空间。但它不可或缺Cumulative层再怎么合并也只是把散页装订成小册子真正把历史旧账彻底抹平、把磁盘空间释放出来的只有Base Compaction。这里注意一个误区Cumulative合并的产物并不是直接并入Base层而是移动到Cumulative Point之后、被视为“待归档”的中间区域。Cumulative Point本身会前移把合并产物划入老数据区然后等待下一次Base Compaction把它们和更老的数据融合。2.4 用一张表对比两者的定位维度Cumulative CompactionBase Compaction合并范围Cumulative Point之后的新RowsetTablet内全部Rowset触发频率高版本数/数据量达到较小阈值即触发低全量数据达到较大规模才触发单次代价低参与文件少、大小相对均衡高涉及全部数据消耗大量IO和临时空间核心作用控制版本数量压制读放大清理删除标记回收空间彻底归并类比把散页装订成小册子把旧档案全部重新装订成合订本实际运维中这两级合并是协同工作的。只跑Cumulative版本数能压住但删除的空间永远释放不了只跑Base合并成本完全不可控。健康的状态应该是Cumulative高频低代价地维护版本链Base低频但必然执行最终把Tablet带到收敛状态。3. 从参数到视图如何判断Compaction正在拉胯3.1 CompactionScore最直接的健康指标Doris在Tablet元数据里维护了一个CompactionScore这个值可以直观理解为“这台Tablet欠了多少合并账”。查看方式很简单用SHOW TABLET FROM语句。SHOW TABLET FROM your_db.your_table;输出结果里每一行对应一个Tablet其中CompactionScore字段就是该Tablet当前的合并紧迫度。不同版本字段名可能有差异老版本叫CompactionScore新版还有CumulativeCompactionScore和BaseCompactionScore分开的展示但读法一致分数越高说明Rowset数量越多、合并越滞后。正常情况下一个写入平稳的TabletCompactionScore维持在小几十左右。如果长期徘徊在100以上说明Compaction已经跟不上写入速度如果冲到几百上千那基本就到了我开头说的那种慢查询雪崩状态。监控系统里建议直接采集这个指标超过阈值就告警。查询是否真的因版本堆积变慢最简单的验证方法是对比查看同一个查询在Compaction完成前后的耗时差异或者用EXPLAIN ANALYZE查看Profile中各个节点的耗时。如果时间大量耗在多个Segment的归并Merge上那基本就是版本堆积导致的读放大。3.2 常用参数与合理范围Doris Compaction的参数较多不同版本默认值略有差异生产环境操作前记得先用SHOW VARIABLES或ADMIN SHOW CONFIG核对。以下是我在实际项目中经常要动的几个参数整理如下。参数名影响方向作用我的建议cumulative_compaction_num_singleton_deltasCumulative触发水位Cumulative Point之后Rowset数量达到多少时开始合并保持默认写入量大可适当调低让它更积极cumulative_compaction_budgeted_min_rowset_numCumulative触发水位参与合并的最小Rowset数量门槛不必轻易改数据量小且版本堆积快时可调低base_compaction_num_singleton_deltasBase触发水位版本数量达到多少时触发Base合并保持默认compaction_task_num_per_disk并发度每块磁盘上允许并发执行的Compaction任务数默认2即可磁盘性能强可调到3~4过高会抢查询的IOcumulative_size_based_promotion_size_mbytesSIZE_BASED晋级大小合并产物超过该大小后提升层级默认值通常够用大字段表可根据Rowset均值调整max_tablet_rowset_num保护阈值Tablet上最大允许的Rowset数量超过后导入会被拒绝或延迟不建议调大这属于安全阀而非性能调优手段Compaction是典型的“既要又要”难题合并太频繁后台IO抢占查询资源合并太慢查询读放大严重。我个人的调优习惯是优先保障compaction_task_num_per_disk不过载然后用CompactionScore作为反馈指标观察几次写入波峰和Compaction滞后时间再决定是否调整Cumulative触发参数。大多数情况下默认参数在正常导入节奏下都能跟上真正需要调参的往往是批量导模型。3.3 慢查询和Compaction有多大关系排查慢查询时Compaction是最容易被忽略又最容易被冤枉的因素。容易被忽略是因为SQL执行计划可能看起来很完美容易被冤枉是因为有些慢查询其实和Compaction没有任何关系。判断方法看三处。第一看Tablet的Rowset数量和CompactionScore是否异常ADMIN SHOW REPLICA STATUS可以批量筛出异常副本。第二看查询Profile中Scan节点是否出现多个Segment的Merge耗时如果几个Segment的索引扫描加起来没多少时间反而Merge耗费巨大基本坐实了版本堆积。第三看Compaction任务日志是否有大量失败重试这种情况说明不只是慢而是合并已经处于不健康状态。如果CompactionScore正常但查询仍然慢就要把注意力放在其他方向比如Key分布是否均匀、分桶数是否过小导致单个Tablet数据量过大、SQL是否存在无法下推的表达式、表是否长时间未做分区裁剪。把这些因素排除干净再回去看Compaction才不会南辕北辙。4. 数据整理实战手动触发合并与Compaction失败排查4.1 手动触发合并的三种操作路径自动Compaction有时会滞后尤其在大批量导入、Schema Change或节点重启之后需要手动介入整理数据。Doris手动触发合并有几种方式按可用性排序。第一种是SQL命令。Doris 2.1及以上版本支持直接对表或分区触发合并-- 对整个表的所有分区触发Compaction ALTER TABLE your_db.your_table COMPACT; -- 指定分区触发Compaction ALTER TABLE your_db.your_table PARTITION(p20240101) COMPACT;这个命令提交的是一个Compaction请求并不是同步等它合并完。要查看执行情况可以用SHOW PROC /compactions;这个Proc节点会返回当前节点上Compaction任务的状态能看到任务的TabletId、类型、状态、开始时间等信息。第二种是HTTP API方式。老版本没有SQL命令时通过BE的Web端口操作。比如查看某个Tablet的状态curl -X POST http://be_host:8040/api/compaction/run?tablet_id10001schema_hash123456返回内容里会有status、compaction_type、message等字段。这种方式适合精准指定某个问题Tablet脚本化处理异常时非常有用。第三种是等待窗口法。本质上不算主动触发但值得提Doris在低峰期本身合并会更积极。如果只是临时性版本堆积且当前查询还能接受可以选择在业务低峰期不干预让自动Compaction先把积压消化掉。手动触发适合的场景是大批量一次性导入后、例行大扫除、或者自动合并已经明显滞后、CompactionScore飙升时。4.2 排查链路automatic compaction failed的完整定位过程线上经常出现的情况是查询偶尔抖动BE日志里刷出一行automatic compaction failed。我第一次遇到时也慌后来总结出一条比较稳定的排查链路分享出来。第一步先在日志里锁定失败对象。BE节点的日志通常分布在log/be.INFO里搜关键字grep -i compaction failed be.INFO | tail -20日志行里一般包含TabletId和失败原因有可能是版本缺失、Rowset文件打开失败、磁盘空间不足、内存分配失败等。把这行完整日志保存下来它是后续定位的核心线索。第二步拿到TabletId后确认它当前的状态。通过SQL查SHOW TABLET FROM your_db.your_table WHERE TABLET_ID xxx;重点看CompactionScore、VersionCount和状态字段。如果VersionCount异常高说明自动Compaction从某个时间点开始就一直在失败Tablet一直在接收新版本但从未被合并过。第三步分析根因。我遇到的Compaction失败基本逃不出下面几类磁盘空间不足Compaction需要临时空间写合并产物如果BE数据目录所在磁盘使用率超过安全水位任务是起不来的。处理方式是扩容或清理数据这是最常见也最好解决的原因。文件一致性异常Rowset对应的Segment文件缺失、校验和不匹配多发生在磁盘坏道或非正常宕机之后。这种情况需要考虑从其他副本恢复必要时通过副本修复机制重建。内存压力Compaction任务被调度后如果BE内存吃紧可能分配不到足够的内存缓冲就失败。低配机器上尤其容易出现需要评估是否要降低Compaction并发。版本过期/冲突频繁的导入和Compaction交错时可能出现版本区间重叠导致合并条件不满足。这类报错一般会在后续自动重试时自然恢复重点观察是否持续失败。第四步处理问题。确认根因后对症下药磁盘空间不足就清理或扩容文件异常就先通过SHOW REPLICA STATUS找到异常副本必要时触发副本修复内存压力就降并发。如果根因暂时不明确最稳妥的兜底操作是先手动对单个Tablet触发一次Compaction看能否让元数据状态自愈curl -X POST http://be_host:8040/api/compaction/run?tablet_idxxxschema_hashxxx如果手动触发仍然失败日志里会给出更具体的错误码这时候不要硬刚优先检查Tablet副本是否需要修复、是否需要扩容磁盘。整个过程不要慌Compaction失败是自愈性较强的故障多数情况下不会直接丢数据因为查询走的是版本链上的原始文件。4.3 实战中容易踩的坑坑一在导入高峰期大批量手动触发合并。手动触发是把后台任务立刻排上队如果同时提交几十张表几千个TabletCompaction线程池会被瞬间占满直接和高频导入抢IO反而把延迟打上去。我的做法是只在低峰期手动触发且用脚本限制同时触发的Tablet数量。坑二忽略磁盘临时空间。Base Compaction在合并期间需要额外的磁盘空间存放中间产物如果一个磁盘分区已经用了85%以上看似还能跑但一个大型Base Compaction可能直接把分区写满导致导入失败甚至BE短暂下线。对大表做强制整理前先确认剩余空间足够装下一个合并产物体量。坑三单桶数据倾斜导致局部版本堆积。实践中经常出现整个集群Compaction健康只有某几个Tablet版本数爆炸的情况根源往往是分桶字段选得不好数据都怼到个别桶里。合并再勤也压不住这种极不均衡的写入分布。遇到这种问题别只调Compaction参数要回到建表语句重新设计分桶。坑四Unique模型高频更新叠加Compaction滞后。Unique模型每一次更新都会产生新版本如果更新频率极高Compaction压力天然就大。这类场景建议关注新版本的Merge-on-Write实现它把部分去重代价前移到了写入阶段虽然写路径变重但Compaction压力会明显下降读多写少场景下收益非常大。还有一个值得一提的坑手动触发合并后误以为它“立即执行”。ALTER TABLE COMPACT只是在元数据里注册了一个任务请求真正执行靠后台调度器。所以执行完命令后发现CompactionScore没立刻下降是非常正常的别急着重复提交先通过SHOW PROC /compactions看任务是否已经进入运行状态。写在最后Compaction监控和运维的小习惯Compaction问题不像CPU打满、进程宕掉那样直观它是一点一点积累出来的慢性病。我现在在团队内部养成了几个习惯每个BE节点采集CompactionScore和Compaction任务失败次数的指标超过阈值直接告警每周定点检查核心表的Rowset数量TOP10提前发现倾斜和堆积趋势每次大版本批量导入之后在下一个低峰期主动对受影响的分区做一次手动合并不让版本积压过夜。最后分享一个小技巧如果你不确定某个参数的默认值在当前版本是什么别靠记忆直接执行SHOW VARIABLES LIKE %compaction%以线上输出为准。Doris版本迭代很快不同小版本之间参数名和默认值都可能调整社区文档永远比我的经验更新鲜。Compaction调优没有一劳永逸的配置结合自己的写入模型和查询特征去观察、调整、验证才是治本的路子。