接手过几个从传统关系型数据库或Hive迁移到Doris的报表项目之后,我慢慢发现一个规律:大多数人迁移时的第一反应总说“Doris查询跑得慢”,可绝大多数慢查询根本不是引擎的问题,而是表设计没跟上。Doris在大数据处理上的灵活性,恰恰就体现在这一层——同样的数据,表设计不同,查询性能可能相差一个数量级,后期改起来也完全不是一个成本。
这篇内容围绕Doris的表设计核心技巧来展开,适合刚接触Doris、正在做表结构评审、或者已经被线上慢查询折磨了一轮的读者。我会把数据模型、分区、分桶、排序键、索引这些构成“灵活性”的底层机制讲清楚,再给出一套可以直接参考的建表示例和避坑经验。
1. 先搞明白“灵活性”到底指什么
1.1 三个数据模型,对应三类业务场景
Doris建表第一步就是选数据模型,这一步决定了表后面的所有行为。很多人在这一步犯迷糊,是因为把Doris当成了普通的MySQL表来设计——能插能更新能删,但实际上Doris每个模型的语义和限制都不一样。
Doris提供三种模型:
- Duplicate 明细模型:所有导入的数据原样保留,不进行任何合并,适合存放日志、事件流、原始事实数据。可以做任意字段的分析,但需要自己处理去重或聚合逻辑。
- Aggregate 聚合模型:建表时指定Value列的聚合方式(SUM、MAX、MIN、REPLACE等),导入的数据按Key列分组后自动聚合。适合报表统计场景,预聚合能大大减少存储和查询成本。
- Unique 唯一键模型:保证Key列唯一,后导入的数据覆盖先导入的数据,适合用户画像、最新状态这类“需要更新”的场景。在Doris 2.0之后,默认的merge-on-write(写时合并)模式让查询更实时,也绕开了早期merge-on-read模式下的读放大问题。
我常用的选型方式是:先看数据是“只会追加”还是“会被更新”。如果是埋点、订单流水,走Duplicate;如果是每天要刷新某些指标,走Aggregate;如果是用户维度的状态信息,走Unique。先把模型定下来,才能谈分区、排序键和索引,否则后面的所有技巧都是在错误框架上打补丁。
1.2 排序键、分区、分桶:从三个尺度控制数据布局
数据模型解决“数据怎么合并”的问题,而分区、分桶、排序键解决“数据从哪里读”的问题。这三者是Doris灵活性的第二个层次,也是优化查询性能的最主要手段。
可以把Doris的表数据想象成一个书架:
- 分区是按大类把书分成几个区域,比如按时间分成“2024年”和“2025年”。查询只需一个季度的数据时,跳过其他分区,这就是分区裁剪。
- 分桶是每个区域内再细分格子,Doris按分桶键的哈希值把数据打到不同桶里。这是物理存储粒度,也是并行扫描的最小单元。
- 排序键决定每个格子里的书按什么顺序排列。Doris在每个数据分片内按排序键组织数据,并用排序键的前缀列建稀疏索引,查询条件命中的话能快速跳到对应位置。
这三层组合起来,查询就可以一层一层“裁剪”数据,而不是全表扫描。灵活性的本质,是你能够根据实际查询模式,调整每一层的组织方式,让数据尽量按你的查询习惯“排列”好。
1.3 索引与辅助结构,为查询再加速
除了排序键自带的前缀索引,Doris还有几个辅助结构,也属于表设计的一部分:
- ZoneMap索引:每个数据分片内自动维护各列的MIN/MAX统计信息,查询时先结合谓词判断能否跳过整个数据块。这个不用配置,但要清楚它的存在,尽量让过滤条件下推到存储层。
- BloomFilter索引:适合高基数列的等值查询,配置在表属性里,稍后细说。
- Bitmap索引:适合低基数的枚举列,比如状态码、类型字段,但会占用额外空间,需要权衡。
很多人建表时只关注数据模型,忽略了这些辅助结构,结果就是SQL很复杂,EXPLAIN出来全是扫全表。Doris的表设计从来不是“建完就完事”,而是要让数据结构和查询特征互相匹配。
2. 表设计的关键拆解:每个选择背后的为什么
2.1 数据模型选错,后面全是坑
先讲三种模型各自的适用条件和注意事项。
Aggregate模型的坑在于“口径被焊死”。比如你选了一个SUM的聚合列,后续想改成求平均数,或者想看某个时间点的原始值,会比较麻烦。明细数据在导入时已经被合并,想还原就得重新导一份全量数据。所以在选Aggregate之前,一定要想清楚这张表的核心指标口径是不是长期稳定。
Unique模型的坑在于“列越多,写放大越明显”。尤其是merge-on-write模式下,每次导入都要检查Key列的唯一性,如果一张表有几十个Value列,导入性能会受到明显影响。我的建议是:不要把一天要更新很多次的字段和极少更新的字段混在同一张Unique表里。前者放更新频繁的表,后者放配置维表,分表维护会轻松很多。
Duplicate模型相对安全,但代价是查询时需要自己控制聚合。它保留全量明细数据,适合探索式分析和审计溯源场景。但同一份明细被多个业务读时,每个查询都现算聚合,如果数据量又大,效率会很低。因此看到一些团队把大维度的明细数据全部塞进Duplicate表跑固定报表,我一般会建议他们建一张Aggregate或物化视图来兜底。
模型选型对灵活性的影响,不只是“能不能满足业务”,更体现在后续“想改变口径有多痛”。所以建表前先列清楚:这张表的写入方式是什么?更新频率是分钟级还是天级?查询是看明细还是看汇总?回答完这几个问题,模型基本就定了。
2.2 分区和分桶怎么定才不浪费
分区最常用的维度是时间。Doris支持RANGE分区和LIST分区,前者适合按日期、数值范围切分,后者适合按枚举值切分,比如地域、业务线。日常场景里RANGE分区最普遍,因为大数据处理90%的查询过滤都带时间条件。
分区的核心价值在于管理和裁剪。管理上,可以按分区直接删除过期数据,比如动态分区自动创建和清理,不需要写Delete语句去慢慢删。裁剪上,查询只要带上分区字段的条件,Doris能快速跳过无关数据。
分桶的选择更有讲究。Doris建表时可以不建分区,但必须指定分桶。分桶键建议选区分度高的自然键,例如用户ID、订单ID。有两个常见错误:
- 选择区分度低的列作为分桶键,比如性别、省份,会导致少数几个桶数据特别多,其他桶数据很少,扫描时出现“热点桶”。
- 分桶数设置不合理。官方建议每个分桶的数据量控制在1GB到10GB之间,但这不是硬性上限,也要结合集群规模考虑。分桶数太少,并行度不够;分桶数太多,小文件碎片化,元数据压力和调度开销反而变大。
一个我常给团队的估算方法:目标分桶数约等于BE节点数的整数倍,同时让每个桶的数据量落在推荐区间。假设集群有6台BE,单表日增数据约2GB,保留30天,总数据量约60GB,那么20到60个分桶都不会太离谱。用一个基础桶数(比如24或48),观察一段时间的查询和导入表现再微调,远好过一次性拍脑袋。
分区分桶的一个额外价值是分桶裁剪。查询条件里如果能命中分桶键的等值条件,Doris可以直接定位到特定桶,连其他桶都不用扫描。所以分桶键的选择,也要尽量匹配高频等值查询列。
2.3 排序键和索引搭配的技巧
分区分桶管的是“读哪些文件”,排序键管的是“文件内部怎么读”。Doris会按排序键的前缀列建立稀疏索引,但由于索引前缀有字节长度限制,不是所有排序键列都能进入前缀索引。
实际建表时,列顺序不要照抄业务字段顺序,要按查询条件来排。原则是:高频过滤的列放在前面,等值条件优先于范围条件。比如一张订单明细表,业务字段可能是订单ID在最前,但查询经常按“日期+用户ID+订单类型”过滤,那排序键就应该调整成这种顺序。否则前缀索引用不上,查询扫描范围变大。
BloomFilter是另一个容易被忽略的工具。它适合高基数的等值查询列。典型场景就是订单ID、设备ID这类列,基数很高,前缀索引又未必覆盖得到,加一个BloomFilter能快速跳过不匹配的数据块。配置方法很简单,在表属性里加一行:
PROPERTIES ( "bloom_filter_columns" = "order_id, device_id" )注意几个要点:
- 低基数列不要加BloomFilter,比如性别、状态值,反而浪费内存和CPU。
- 排序键的列通常不需要加,因为前缀索引已经能处理。
- BloomFilter只对等值查询有效,范围查询不会走。
Bitmap索引适合低基数的枚举列,比如“稿件类型”“终端类型”,配合BitmapCount、BitmapUnion这类函数做精确去重和圈选很好用。但每个Bitmap索引都有存储成本和支持的算子限制,别一张表建十个八个,要挑最核心的列建。
另一个容易忽略的是ZoneMap。它对数值和时间范围的过滤非常有效,而且默认开启。但是ZoneMap依赖每个数据分片内的MIN/MAX统计信息,如果某个数据块写得过宽,裁剪收益就变小。所以导入时尽量让每个分片的数据量稳定,不要出现特别大的写放大分片。整体来说,Doris的索引体系是层层递进的,建表时就把索引方案定清楚,查询侧再配合谓词下推,效果才会好。
3. 实际案例:一个内容社区用户行为分析平台的三张表设计
前面讲了一堆概念,现在用一套实际表结构把它们串起来。假设我们在做某个内容社区的用户行为分析平台,需要同时支持三类典型需求:
- 记录用户点击、曝光、点赞等行为明细,供运营临时取数和算法团队离线分析;
- 维护一个用户画像维表,保存用户的最新基础属性和标签;
- 提供每日内容维度的PV/UV报表,要求查询秒级返回。
这个场景非常典型,一张表搞不定,三张表刚好对应三种模型。
3.1 明细事件表:Duplicate模型 + 时间分区
行为明细数据天然是“追加写、不改动”的,适合Duplicate模型。建表时排序键尽量放用户ID和事件类型,因为分析查询常按用户和事件过滤,而日期已经通过分区裁剪掉了,不需要排在排序键的最前面。
CREATE TABLE event_log_local ( user_id BIGINT, event_type VARCHAR(32), event_time DATETIME, page_id BIGINT, platform VARCHAR(16), os_version VARCHAR(16), extra JSON ) ENGINE = OLAP DUPLICATE KEY(user_id, event_type) PARTITION BY RANGE(event_time)() DISTRIBUTED BY HASH(user_id) BUCKETS 24 PROPERTIES ( "replication_num" = "3", "dynamic_partition.enable" = "true", "dynamic_partition.time_unit" = "DAY", "dynamic_partition.start" = "-7", "dynamic_partition.end" = "2", "dynamic_partition.prefix" = "p", "dynamic_partition.buckets" = "24", "bloom_filter_columns" = "user_id" );这里有一个细节:event_time既是分区字段又是时间字段,写在Value侧数据模型不会把它当作Key列参与排序。如果查询经常按时间段过滤,可以把event_time也放进排序键靠后的位置,让同一用户在同一时段内的数据物理相邻。
这个表用JSON类型存业务自定义字段,是刻意为之。行为事件的字段往往后面还会增加,比如灰度标识、实验分组、推荐位等,JSON字段可以避免频繁做Schema变更。虽然JSON查询在过滤上有一定性能损耗,但作为明细流水的灵活性收益远超这点损失。
3.2 用户画像表:Unique模型 + 分桶优化
用户画像表是典型的“更新”场景:用户的基础资料、标签、活跃状态每天甚至实时变化,查询侧需要看到最新值。
CREATE TABLE user_profile_local ( user_id BIGINT, user_name VARCHAR(64), gender TINYINT, register_time DATETIME, level INT, tag JSON, updated_at DATETIME ) ENGINE = OLAP UNIQUE KEY(user_id) DISTRIBUTED BY HASH(user_id) BUCKETS 32 PROPERTIES ( "replication_num" = "3", "enable_light_schema_change" = "true" );这张表的分桶键就是主键user_id,查询按用户ID过滤时能精确命中分桶。BUCKETS取32,是因为用户总量大且更新频繁,需要足够的并行度去分摊写入压力。
Unique模型在Merge-on-Write模式下可以比较优雅地处理“部分列更新”。如果某天只更新用户的tag字段,可以只导入这一列,配合partial_update实现局部更新。这个特性在用户标签经常变动的场景下非常实用,不用每次把整行拉出来覆盖一遍。
3.3 聚合统计表:Aggregate模型 + 动态分区
每日报表属于“固定口径的预聚合”,适合Aggregate模型。指标列自动聚合,查询直接读结果,不需要每次现算,这是Doris在报表场景下最大的杀手锏。
CREATE TABLE content_daily_agg_local ( event_date DATE, content_id BIGINT, channel VARCHAR(32), pv BIGINT SUM, uv BITMAP BITMAP_UNION, max_rank INT MAX, user_id BIGINT REPLACE_IF_NOT_NULL ) ENGINE = OLAP AGGREGATE KEY(event_date, content_id, channel) PARTITION BY RANGE(event_date)() DISTRIBUTED BY HASH(content_id) BUCKETS 16 PROPERTIES ( "replication_num" = "2", "dynamic_partition.enable" = "true", "dynamic_partition.time_unit" = "DAY", "dynamic_partition.start" = "-30", "dynamic_partition.end" = "1", "dynamic_partition.prefix" = "p", "dynamic_partition.buckets" = "16" );几个值得说的设计点:
- PV用SUM:每行导入的是单日单内容单渠道的计数,聚合后就是累加值。
- UV用BITMAP_UNION:UV本质是去重计数,Bitmap的集合求并操作能保留精确去重能力,同时查询侧用
select uv_count from table加BITMAP_COUNT就能快速拿到结果。 - 聚合模型不会省掉所有粒度:它省的是“按Key相同的多行”合并。因此导入的粒度尽量是“最细需要的粒度”,否则后续想按更小的维度拆就做不到了。
3.4 表结构变更:提前设计可扩展
很多人在Doris里改表结构,第一反应是“这不就是ALTER TABLE吗”。实际上Doris的Schema Change是异步执行的,而且不同操作开销差别非常大。添加值列(Value侧列)相对轻量,很多场景下走轻量Schema Change,几乎不影响在线导入和查询。但调整排序键、修改分区分桶方式这类操作会涉及数据重写,数据量大时会非常耗资源。
所以我建表时有几条习惯:
- 能为JSON字段解决的扩展性,不优先增加实列;
- 高频变化的指标列提前建好,即使暂时只导入部分值;
- 模型和Key列在评审阶段多花半小时确定,不要指望上线后再改。
这个案例里,三张表从模型选择到分桶策略都围绕“查询怎么方便怎么来”设计。表设计不是写一堆DDL交差,而是把自己的查询模式映射到Doris的存储结构上。
4. 常见问题与排查技巧实录
4.1 模型选型失误的症状与补救
症状1:Aggregate表查不到明细。用户要求看单笔订单的金额明细,但表已经按Key聚合过了。补救方案只有两个:一是新建或切换Duplicate模型表,重新灌明细数据;二是如果只是偶尔需要,通过查询日志去反推明细,但这条路基本走不通。最现实的办法是,如果是早期可以重建表;如果是上线很久的状态,只能尽量在明细表上多保留一份数据。
症状2:Unique表更新性能差。排查后发现Value列太多,且每次导入都更新全部列。建议改用partial_update只导入变更列,或拆成主表+扩展表。Doris的灵活恰恰在于可以通过调整模型结构来改善,但别把什么信息都塞进一张表。
4.2 分桶数不合理导致的数据倾斜
有一类查询异常是:明明只是过滤一个用户的数据,看EXPLAIN却发现扫描了全表大部分分桶。这种时候看分桶键是不是选得太“偏”。比如用城市作为分桶键,大城市的数据会占很多桶。表现是部分BE节点CPU跑满,部分节点空闲,查询时间被拖长。
解决思路比较简单:
- 分桶键换成高基数的自然键,比如user_id或order_id;
- 如果确实要按城市过滤,可以在查询里加上城市条件的同时,再叠加一个高基数列做分桶,利用分桶裁剪;
- 观察一段时间后,手动调整分桶数。但注意这个操作会触发数据重分布,需要做好窗口期规划。
4.3 查询慢:先从剪枝和索引入手
我排查慢查询的顺序一般是:
- 先看是否做了分区裁剪。EXPLAIN里如果发现扫描了所有分区,通常是查询语句没带分区字段,或者对分区字段做了函数运算,比如
to_date(event_time)导致无法裁剪。 - 再看分桶裁剪。查询条件有没有命中分桶键的等值条件。
- 然后看前缀索引。如果排序键第一列没有出现在过滤条件里,基本就是全分片扫。
- 最后检查是否有合适的高基数列加BloomFilter。
很多慢查询并不是Doris慢,而是表设计时没把访问模式考虑进去。一个常见案例是,底层表按日期分区分桶,但报表BI工具生成的SQL是select * from wide_table where user_id = ...,完全不带日期条件,结果分区裁剪失效。这种情况我一般建议要么引导业务侧加时间范围,要么把user_id也设计进排序键第一位并在分桶上做配合。
4.4 容易误用的查询细节
Doris的SQL语法兼容MySQL,但有些特性要注意。
日期条件最好写成范围形式,比如event_date >= '2025-01-01' and event_date < '2025-01-08',而不是date_format(event_date,'%Y-%m-%d') = '2025-01-01',后者会让分区裁剪失效。
对高基数字段的模糊查询(LIKE '%xxx%')即使有索引也基本退化扫描。这种场景建议用倒排索引或提前做标签化处理,而不是把希望寄托在表结构上。
还有就是导入时多注意分片大小。有的同学建表时分桶数很大,但实际数据量很小,每个分片只有几十MB,ZoneMap的裁剪效果也会变差。数据量大到一定程度,分片统计信息才有意义。
5. 额外经验积累
5.1 冷热数据分层可以放进表设计
Doris支持把部分分区放在SSD,其他分区放在HDD,或者通过存储策略做迁移。这虽然不是传统意义的“表设计”,但对灵活性影响很大。比如日志表可以只保留最近7天的热数据在SSD上,历史数据自动落到HDD,成本下降的同时查询性能不受影响。
建表时可以在分区属性里指定存储介质:
PARTITION p20250101 VALUES LESS THAN ("2025-01-02") PROPERTIES("storage_medium" = "SSD", "storage_cooldown_time" = "2025-02-01")或者在表属性层面配置默认存储策略。日常使用中,我用得最多的是“新写入的数据进SSD,7天后冷却到HDD”,这样既能保证近期热点查询的响应速度,又不会让存储成本无限上涨。
5.2 物化视图和Rollup也是灵活性的一部分
比较早期的Doris版本里,Rollup对聚合模型的优化非常明显,相当于预计算多级汇总。比如明细表的原始粒度是“日期+内容+渠道”,但报表经常只按“日期+内容”看汇总,建立一个Rollup后查询会自动命中更小粒度的数据。
虽然后续版本推荐用Async物化视图来承载复杂的透明改写,但核心思想没变:不要重复建表,不要靠应用层多维护一份数据,而是利用Doris的物化能力,在底层存储和查询引擎之间自动匹配。这比手工维护宽表省心得多。
5.3 分布式Join也好用,但表结构要配合
Doris的Colocate Join能把两张大表按照同样的分桶键和分桶数进行数据分布,使得Join过程中本地的数据直接匹配,不需要跨节点传输。这个功能很强大,前提是两张表的DISTRIBUTED BY列完全一致,分桶数一致。所以在设计一对多或多对多关联的维表、事实表时,我会提前把分桶键统一好,后续就不用为Join性能头疼了。
5.4 不要为了“规范”过度设计
有些同学看到官方文档说“分桶建议1GB到10GB”,就把所有表都按照10GB一个桶来建,结果数据增长很慢,每个桶又小又碎。也有些同学把三四种索引全加到一张表上,导入性能反而被拖垮。
Doris的表设计确实能带来极大的灵活性,但灵活性是建立在“了解自己的数据”之上的。我更倾向于先按最小可用方案建表,跑一段时间看EXPLAIN和顶点统计,再逐步加索引、调分桶。早期少加一个BloomFilter并不会致命,但选错模型往往很难回头。
写在最后
在我实际接触的项目里,Doris表设计中最值得花时间研究的永远是“查询模式”和“更新模式”,而不是堆砌各种新特性。有一句话我常跟团队讲:Doris的灵活性给的是选择空间,但在“灵活”背后,任何选择都伴随着代价。你选了快速聚合,就要接受明细的丢失;你选了写时合并,就要接受部分写入的开销;你选了多级索引,就要接受存储和导入成本的上升。
把这些代价想清楚之后,剩下的建表、分区、调索引就都是顺水推舟的事。如果你正在为一个新业务设计Doris表,我的建议是从一张最小可用的表开始,记录下业务侧最常跑的Top 10查询,再看这些查询在EXPLAIN里的裁剪情况,有针对地去做迭代。这样调出来的表结构,往往比一开始就追求“大而全”的设计更经得住线上环境的考验。