Doris分区裁剪失效排查:从8秒到40毫秒的查询加速实践
2026/9/15 3:10:49 网站建设 项目流程

我维护着一套Doris集群,承载公司核心交易数据的实时分析。前阵子某条看板SQL从平均30毫秒涨到8秒,现象非常诡异:表结构没变,数据量涨幅也平稳,但响应时间实实在在退化了几十倍。排查到最后,问题出在分区键被函数包裹,Doris的分区裁剪彻底失效,本该读1个分区,实际把90天的分区全扫了一遍。这也让我下定决心把Doris分区裁剪从原理到实战完整梳理一遍,标题里的三个关键词——Doris、分区裁剪、查询加速,本质上是同一件事:你是否真正理解并控制了自己SQL的扫描范围。这篇文章适合正在用Doris做报表、数据看板或实时数仓的工程师,也适合那些刚接触Doris、想知道为什么同一张表有的人查得快有的人查得慢的同学。

1. 从一次8秒的慢查询说起:分区裁剪失效的现场还原

先说当时的具体情况。表结构大概是这样的:

CREATE TABLE dwd_trade_order_di ( order_id BIGINT, shop_id BIGINT, amount DECIMAL(18,2), dt DATE ) DUPLICATE KEY(order_id) PARTITION BY RANGE(dt) () DISTRIBUTED BY HASH(order_id) BUCKETS 16 PROPERTIES ( "dynamic_partition.enable" = "true", "dynamic_partition.time_unit" = "DAY", "dynamic_partition.start" = "-90", "dynamic_partition.end" = "3" );

日增约3000万行,保留90天分区,总数据量在20亿行以上。出问题的SQL原本长这样:

-- 慢查询版本,跑了8秒 SELECT shop_id, SUM(amount) FROM dwd_trade_order_di WHERE date_format(dt, '%Y-%m') = '2024-11' GROUP BY shop_id;

而改成下面这个等价写法之后,耗时回到40毫秒左右:

-- 快查询版本,40毫秒 SELECT shop_id, SUM(amount) FROM dwd_trade_order_di WHERE dt >= '2024-11-01' AND dt < '2024-12-01' GROUP BY shop_id;

两条SQL的语义完全一致,结果集也一致,但执行时间差了200倍。原因就是第一条SQL的WHERE条件里,分区键dtdate_format()函数包裹,FE无法从函数表达式反向推导出它对应哪些分区,干脆放弃裁剪,把全表所有分区都交给了BE去扫。

1.1 分区裁剪在Doris查询链路中的位置

Doris收到一条查询SQL后,先由FE(Frontend)做SQL解析、生成执行计划。FE在生成OlapScanNode时,会拿WHERE条件里的谓词去和表的元数据做匹配,确定要访问哪个分区的哪些tablet。只有落到候选分区集合外的tablet会被跳过。这个判断发生在查询真正去BE扫描数据之前,所以它是Doris查询加速的第一道闸门,也是最省成本的一道过滤。

一个直观类比:去仓库取货时,分区裁剪相当于先看货架标签,直接走向对应区域,而不是把整个仓库的所有货架翻一遍。问题是,很多人以为Doris会自动完成这一步,实际上它对SQL写法有严格要求,稍不留意就绕过这道闸门。

1.2 为什么这个坑特别容易踩

我观察了团队里大量SQL之后发现,分区裁剪失效很少是Doris本身的问题,绝大多数是开发者在写SQL时没有意识到"分区键必须保持裸列参与条件"。常见做法包括:用date_formatsubstrto_date处理分区键,或者把分区键跟一个类型不一致的字段做隐式比较。这类问题在数据量小的时候完全无感,等表涨到上亿行、几十个分区后,直接变成慢查询炸弹。这也是我决定把这块单独写成一篇实践笔记的原因——你不需要调参,不需要加索引,光是修正SQL写法,就能获得数量级上的查询加速收益。

2. FE侧的分区裁剪机制拆解:谓词如何变成分区列表

理解分区裁剪,不能只停在"写了分区字段就会裁剪"这个粗浅层面。我建议把FE的判断逻辑拆成三层看,这样排查问题时才知道该看哪。第一层是操作符识别,第二层是分区范围匹配,第三层是候选tablet下推。

2.1 操作符识别:哪些谓词能被裁剪

Doris的查询优化器在生成执行计划前,会提取WHERE条件中所有涉及分区键的谓词。能被安全用于裁剪的操作符主要是等值比较、范围比较、IN列表、BETWEEN以及IS NULL这几类。

-- 支持裁剪的典型写法 WHERE dt = '2024-11-20' WHERE dt IN ('2024-11-20', '2024-11-21') WHERE dt >= '2024-11-01' AND dt < '2024-12-01' WHERE dt BETWEEN '2024-11-01' AND '2024-11-30'

反过来,NOT INNOT LIKE这类否定条件下,Doris通常会退化成全分区扫描,因为要证明某个分区一定不满足条件,比证明它满足条件要难得多。OR连接的条件也要特别小心,除非OR的每个分支都能裁剪,否则整个条件会被判定为不可裁剪。

2.2 从谓词到分区Range的匹配过程

Doris的Range分区在元数据里维护了每个分区的范围边界,比如[2024-11-01, 2024-11-02)代表一天的数据。FE拿到谓词后,会遍历分区列表,逐个判断分区范围与谓词区间是否存在交集。这里有个细节:Doris的RANGE是左闭右开,所以写条件时用dt >= '2024-11-01' AND dt < '2024-12-01'最贴合语义,用dt BETWEEN '2024-11-01' AND '2024-11-30'也能覆盖到30号的完整数据。

匹配完成后,FE在EXPLAIN的输出里会明确告诉你本次查询覆盖了多少分区。我最常用的排查手段就是先跑一条EXPLAIN:

EXPLAIN SELECT shop_id, SUM(amount) FROM dwd_trade_order_di WHERE dt >= '2024-11-01' AND dt < '2024-12-01' GROUP BY shop_id;

输出里OlapScanNode节点会显示类似partitions=30/93的信息。如果看到分母是表里全部分区数、分子接近分母,或者直接是partitions=93/93,不用怀疑,裁剪没生效,直接检查SQL写法。

2.3 裁剪之后的第二道关卡:Tablet和Rowset

很多资料讲到分区裁剪就结束了,但实际查询性能还和更细粒度的过滤有关。即使只扫1个分区,如果这个分区有16个tablet,每个tablet又积累了上百个rowset版本,BE在扫描时依然要做大量文件级别的筛选。Doris的tablet内部会有rowset级别的Min/Max索引,BE可以通过版本范围和key范围把无关的rowset跳过。这部分不叫分区裁剪,但它的前提是"分区列表已经收窄",否则几十个分区的rowset元数据堆在一起,BE的光谱扫描也很吃力。

我在调优时有一条原则:分区裁剪负责把"扫多少分区"这个数量级降下来,Compaction负责把"每个分区内扫多少文件"这个数量级降下来,两者配合才是完整的查询加速。只做前者不管后者,单分区查询仍然可能因为版本堆积而变慢,这个后面专门展开。

3. 让裁剪真正生效的分区设计:字段选择、粒度与数据分布

SQL写法正确只是底线。想让分区裁剪长期稳定地发挥作用,核心是把表结构设计好。很多团队建表的时候比较随意,导致后续想裁剪都找不到合适的条件。我的经验是,在Doris里设计分区时要回答三个问题:用哪个字段分区、按什么粒度分区、数据是否均匀落进每个分区。

3.1 分区字段选择的底层逻辑

分区字段应当从查询模式里来。先翻慢查询日志和报表SQL,找出出现频率最高、过滤性最强的字段。对绝大多数业务来说,这个字段都是时间,因为报表天生按天看、按月看、按活动周期看。选择分区字段的另一个要求是稳定性,分区字段一旦确定,业务上的写入和查询都围绕它展开,后续改动成本极高。

常见误区是把分区字段当成"用来分桶的字段"。分桶字段解决的是数据分布问题,分区字段解决的是数据裁剪问题。我在一个订单场景里见过有人用shop_id做分桶后,又试图用它做分区,结果每个分区的数据量差异巨大,热点商户所在分区扫描压力爆炸。正确做法是用dt做分区控制扫描范围,用shop_id做分桶保证数据均匀分布,两者职责完全不同。

3.2 分区粒度:天、小时还是月

分区粒度取决于数据量和查询窗口。我提供一个经验判断表,可以直接套用:

数据模型日增数据量推荐粒度理由
中小规模明细百万级以下月分区或天分区分区数可控,元数据开销小
常见交易明细千万到亿级天分区按天裁剪粒度适中,保留90天约90个分区
超大吞吐埋点日志亿级以上小时分区单分区数据量受限,裁剪可精确到小时
冷热分明历史归档不定月分区+动态归档减少FE元数据压力,老数据裁剪粒度粗一点无妨

小时分区看起来最灵活,但会带来分区数量膨胀。90天按小时分区就是2160个分区,FE的元数据管理、BE的tablet数量都会跟着涨,小查询也可能因为元数据扫描变慢。我一般建议:只有当单个天分区的数据量大到BE扫描明显吃力时,才拆小时分区,否则天分区是最优解。

3.3 表达式分区与多列分区的使用

Doris 2.1之后支持表达式分区,这是解决"函数包裹分区键导致裁剪失效"的正规手段。比如分区键是event_time日期时间字段,你希望按天裁剪,可以让分区表达式直接落在时间戳上:

CREATE TABLE log_event ( event_time DATETIME, event_type VARCHAR(32), content STRING ) PARTITION BY RANGE(event_time) ()

只要查询条件里用的是event_time >= '2024-11-20 00:00:00' AND event_time < '2024-11-21 00:00:00',即使字段是DATETIME类型,FE也能通过范围判断精确裁剪。表达式分区还能配合date_trunc这类函数定义分区边界,让建表语义更贴近业务,同时对优化器友好。

3.4 Flink SQL写入场景下的分区设计注意点

近年实时链路常把Flink SQL清洗后的数据写入Doris,这块的高频坑集中在Unique Key模型表(也有平台叫Union Key模型,本质接近)。Flink写入通常按分区分桶提交,如果分区字段和去重键耦合设计不当,高频更新会压在最近几个分区上,导致这些小分区版本数量暴涨,查询端就算裁剪到了目标分区,也要在合并大量版本后返回结果。

我的习惯是:Unique Key模型表的分区字段只承担时间裁剪职责,去重键只承担行级别去重职责,两者不要互相绑定。同时,对Flink写入的分区设置合理的compaction_policy,避免小文件堆积。否则你用分区裁剪把扫描范围缩小到了1个分区,但那1个分区里有上千个rowset,查询照样快不起来。

4. 实测对比:点查、范围报表和全分区扫描的差距

理论讲再多,不如一组实测数据直观。我在测试环境复现了慢查询场景,环境是一只普通8核16G的Doris实例,用Docker在Windows开发机上起的单FE单BE,造了一张约1.5亿行的天分区表,保留45天分区,分区键dt为DATE类型。这个环境够用,Windows上跑Doris用Docker是最省事的方式,直接拉apache/doris镜像,避免原生部署在Windows上的各种兼容问题。

4.1 点查场景:精确命中单个分区

SELECT count(*) FROM dwd_trade_order_di WHERE dt = '2024-10-15';

EXPLAIN显示该SQL只扫描了1/45的分区。实测扫描行数从全表的1.5亿降到约333万,查询耗时0.8秒左右,而全分区扫描基线是6.2秒。点查是分区裁剪收益最明显的场景,因为裁剪前后扫描行数相差一个数量级。

4.2 范围报表场景:按周和按月裁剪

模拟看板SQL,把条件改成:

SELECT shop_id, SUM(amount) FROM dwd_trade_order_di WHERE dt >= '2024-10-01' AND dt < '2024-11-01' GROUP BY shop_id;

这表45个分区,10月对应31个分区,按理应该扫31/45。但测试结果让我注意到一个细节:因为动态分区只保留了最近45天的分区,10月部分分区已经过期被删掉了,实际只扫了29个分区,耗时3.4秒。相比全分区扫描节省了约35%的IO,但相比单点查询已经不占优势。这也验证了一个结论:范围查询的耗时基本和扫描分区数线性相关,想让报表更快,就得把范围收窄到业务真正需要的天数,而不是图省事传整个自然月。

4.3 不落分区键的查询:全分区扫描的代价

拿热门搜索词里那条典型的"未命中分区键"SQL做对照:

SELECT shop_id, SUM(amount) FROM dwd_trade_order_di WHERE date_format(dt, '%Y-%m') = '2024-10' GROUP BY shop_id;

这条SQL的结果和4.2完全一样,但EXPLAIN显示它扫了45/45分区。原因是date_format(dt, '%Y-%m')这个表达式破坏了分区键的裸列状态,FE无法从'2024-10'反推出具体范围,只能保守地全扫。实测6.1秒,比范围写法慢了接近一倍,而且后续如果加更多条件,它也无法和其他索引产生配合。

实测下来,我的判断是:分区裁剪的收益不是线性而是指数级的。你不做裁剪时,即使只是读取数据做简单count,也会随着总数据量增长而线性劣化;而一旦裁剪生效,查询成本只和被命中的那部分数据相关,跟你表里堆积了多少历史数据基本无关。这也解释了为什么同样的Doris集群,有人能把报表响应压到百毫秒级,有人却始终在几秒到几十秒徘徊。

5. 分区裁剪失效的典型坑与系统性排查方法

说了这么多,实战里最怕的不是不知道原理,而是SQL慢了你不知道是分区裁剪失效了,还在忙着调并行度、加缓存。我梳理了几个高频的失效场景和排查路径,基本覆盖我踩过的大部分坑。

5.1 函数包裹分区键

这是出现频率最高的坑。DATE_FORMATSUBSTRDATE()CAST套在分区键上,FE无法反向推导。排查时直接看WHERE条件里分区键周围有没有函数,有就改写成范围条件。有一个小例外:Doris表达式分区建的表,如果查询条件也用了同样表达式,优化器有概率识别出来,但这个能力不是所有版本都保证,我仍然推荐裸列范围写法。

5.2 隐式类型转换与Join衍生条件

分区键是DATE类型,查询条件传字符串'2024-11-20'通常没问题,Doris会做类型转换。但有些写法把DATE类型和BIGINT时间戳做比较,比如WHERE dt = 1732003200,这种类型不匹配会让谓词推导失效,退化成全分区扫描。另一种是Join场景:子查询先算出日期列表,外层表再做WHERE dt IN (SELECT ...),如果子查询里的日期列类型不明确,裁剪效果会大打折扣。遇到这种,我建议提前物化日期列表或改成JOIN写法,确保传给分区键的谓词类型干净。

5.3 应用侧超时设置掩盖了问题本质

热搜词里有一条是"doris springboot连接数据库设置超时"。我对这个太有体会了。很多应用连Doris时用的是MySQL协议,SpringBoot的HikariCP连接池会配置connectionTimeoutsocketTimeout。如果socketTimeout设得太短,比如10秒,而一条本应300毫秒的查询因为裁剪失效跑了8秒,应用侧先报超时,Doris后台查询却还在执行,造成连接占用和线程堆积。这种问题表面上和分区裁剪无关,但会让调优陷入误区:你以为是连接池不够,疯狂加连接数,实际上是查询扫描范围失控。

我的建议是:先把应用侧socketTimeout调大到能覆盖合理慢查询的阈值,比如60秒,再回到Doris侧用慢查询日志排查真实执行时间,不要反过来本末倒置。

5.4 用EXPLAIN和Profile确认是否真的裁剪成功

判断一个SQL有没有用上分区裁剪,最直接的就是EXPLAIN加Profile双确认。EXPLAIN看计划里的partitions,Profile看实际扫描。我通常这样操作:

set enable_profile = true; SELECT shop_id, SUM(amount) FROM dwd_trade_order_di WHERE dt >= '2024-11-01' AND dt < '2024-12-01' GROUP BY shop_id; SHOW PROFILE;

Profile的OlapScanOperator部分会列出SelectedPartitionsTotalPartitions。如果SelectedPartitions接近TotalPartitions,说明裁剪没有产生效果,回到SQL检查谓词;如果SelectedPartitions远小于TotalPartitions,但查询仍然慢,那问题就在Compaction、tablet分布或网络IO上,别再怀疑分区裁剪了。这一招能帮你快速定位问题层级,节省大量瞎调优时间。

5.5 建立慢查询审计治理习惯

Doris有审计日志插件,开启后会把所有查询写入doris_audit_db__.doris_audit_tbl__。我每周会跑一次统计,找出扫描行数和返回行数比值特别大的SQL,这类SQL十有八九没走分区裁剪。

SELECT query_id, user, query_time, scan_bytes, return_rows, scan_rows FROM doris_audit_db__.doris_audit_tbl__ WHERE query_time > 1000 ORDER BY scan_rows DESC LIMIT 50;

把这份名单里的SQL逐条用EXPLAIN过一遍,标记出"本可以裁剪但没裁剪"的,再推给对应的SQL负责人整改。这个流程跑两三个月后,集群的整体查询延迟会有非常明显下降,而且你手里会攒出一份属于自己业务的"坏SQL清单",比任何参数调优都管用。

6. 让裁剪优势持续发挥的运维细节:合并、动态分区与资源隔离

分区裁剪不是一劳永逸的。就算SQL都规范了、表结构都合理了,日常运维里还有几个变量会侵蚀裁剪带来的收益。这些细节不做,你可能会发现裁剪一直生效,但查询还是越来越慢。

6.1 分区内版本堆积与手动触发合并

Doris的LSM-Tree存储结构决定了数据写入会不断生成新的rowset版本。分区裁剪把查询收窄到1个分区后,如果这个分区里有几十个待合并的rowset,BE扫描时要读取的元数据、文件句柄、甚至磁盘IO都会成倍增加。动态分区最近的天会持续接收实时写入,最容易出这个问题。

Doris支持手动触发Compaction,热门搜索词里的"doris手动触发对表的合并"说的就是这个操作:

-- 触发整个表的合并 ALTER TABLE dwd_trade_order_di COMPACT; -- 只合并指定分区 ALTER TABLE dwd_trade_order_di PARTITION(p20241120) COMPACT; -- 查看合并进度 SHOW ALTER TABLE COMPACT;

我一般在两类时刻手动触发:一是大促或批量导入后,二是发现某个查询的Profile里numRowsnumRowsets比例异常时。合并命令本身不阻塞读写,但会占用一些IO资源,注意不要在业务高峰对超大分区反复触发。

6.2 动态分区:裁剪之外的生命周期管理价值

动态分区的好处不只是自动建分区。它还有一个容易被忽略的价值:自动删除过期分区,让FE的候选分区列表始终保持精炼。分区裁剪虽然只对命中条件的分区做扫描,但FE在匹配谓词时仍然要遍历一次分区元数据。分区数量从几千个降到几百个,对高并发小查询的收益非常明显。

我这里有一个配置参考:

ALTER TABLE dwd_trade_order_di SET ( "dynamic_partition.enable" = "true", "dynamic_partition.time_unit" = "DAY", "dynamic_partition.start" = "-90", "dynamic_partition.end" = "3", "dynamic_partition.prefix" = "p", "dynamic_partition.buckets" = "16" );

时间范围别留太长,90天对大多数分析场景够了,太老的数据要么归档到冷存储,要么直接删掉。分区裁剪能省扫描IO,但省不了元数据管理成本,控制分区总量本身就是一种优化。

6.3 慢查询治理闭环与Workload Group隔离

最后是治理视角。我会把慢查询治理做成一个闭环:审计日志发现问题、EXPLAIN确认是否裁剪失效、SQL改写修复、Profile验证效果、周会同步案例。这个过程没有太高深的技巧,难的是坚持。只要坚持几个迭代,集群里因为分区裁剪失效导致的慢查询会越来越少。

对剩余那些确实需要扫描大量分区的查询(比如跨月分析、全链路明细回溯),我会给它们划分Workload Group,限制并发和内存,防止它们把BE资源打满,影响到走裁剪加速的短查询:

CREATE WORKLOAD GROUP rg_pending_olap PROPERTIES ( "memory_limit" = "30%", "cpu_share" = "10" );

通过资源隔离,把分区裁剪能解决的短查询和无法裁剪的长查询分开,互不干扰。这样,即使偶尔有一条没优化的SQL漏网,也不至于拖垮整体查询体验。从我维护集群的经验来看,分区裁剪是一张入场券,但真正决定集群能不能持续稳定输出性能的,是分区设计、版本治理和资源隔离这一整套组合拳。把这几件事都理顺了,Doris的查询加速就不是靠运气,而是靠一套可以复制的工程方法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询