接手 Doris 数据表建模的时候,很多开发者第一反应就是问:这三种存储模型到底该选哪个?我见过不少团队因为一开始把表模型定错,后面数据量上来之后,查询要么慢得离谱,要么结果数对不上,只能删表重导。Doris 的三种存储模型——明细模型、聚合模型、主键模型,看起来只是建表时的一行关键字,实际上决定了数据从导入、合并到查询的整条链路上怎么处理重复行和聚合逻辑。这篇文章我就按自己的理解,把三者的原理、适用场景、建表方式和踩过的坑一次说清楚,适合正在做 Doris 建模或者准备迁移数据仓库的读者。
1. 三种存储模型到底是什么
1.1 明细模型(Duplicate Key Model):全量保留,不去重
明细模型是 Doris 默认使用也最好理解的模型。它的语义就是:来一条存一条,不管排序键是否相同,所有导入的行都原样保留。对应到使用场景,最常见的就是日志、订单流水、用户行为埋点这类“每一条都要保留,且几乎不会被更新”的数据。
我一开始用明细模型的时候,觉得它和 MySQL 里普通的 append-only 表差不多。但实际上它底层是按排序键有序组织的,同一批次导入的多行会按照 Key 列排好,不同批次的数据在 Compaction 时也会继续按 Key 排序。这样做的直接好处是查询时能把相同前缀的数据放在相近的位置,配合 Doris 的列式存储和前缀索引,扫描效率会高很多。
注意:明细模型虽然不去重,但它仍然存在“Key”这一概念。你可以指定 DUPLICATE KEY 列,也可以不指定。如果不指定,Doris 会默认所有列都参与排序。指定时通常把最有查询过滤价值、占用空间小的列放在前面。
1.2 聚合模型(Aggregate Model):导入时先算一步
聚合模型是在明细模型基础上多了一层“合并预聚合”的语义。建表时,非 Key 列必须指定一个聚合函数,比如 SUM、MAX、MIN、REPLACE。当导入的数据有相同 Key 时,Doris 会按照这个聚合函数把新数据合并到旧数据上,而不是把所有行都保存下来。
这个设计最直接的价值就是降低存储成本、加快聚合查询。比如我要统计每个城市每天的订单金额,如果只用明细模型,每次查询都要扫描全天的订单明细再 SUM,数据量一大就慢。而聚合模型在导入时就把相同“日期 + 城市”的订单金额累加好了,查询时只需要读结果行。
1.3 主键模型(Unique Key Model):按键去重,后到覆盖前到
主键模型解决的是“按主键更新”的问题。它的语义和聚合模型里的 REPLACE 很像:同一主键的数据,后导入的覆盖先导入的,最终每个键只保留最新一条。
在实际业务里,这就是典型的维表场景,比如用户资料表、订单状态表、库存表。每一次更新都可能只改几条记录,但查询时要能快速拿到每个主键的当前最新值。主键模型就是为了这类“点查 + 低频更新 + 去重”设计的。
1.4 三种模型的核心差异对照
为了便于对比,我把三种模型的关键差异整理成一张表。
| 维度 | 明细模型 | 聚合模型 | 主键模型 |
|---|---|---|---|
| 核心语义 | 所有行原样保存 | 相同 Key 预聚合后保存 | 相同主键保留最新一条 |
| 去重能力 | 不去重 | 按聚合函数合并 | 按键去重 |
| 更新行为 | 不支持更新语义 | 通过 REPLACE/REPLACE_IF_NOT_NULL 模拟更新 | 后写入覆盖旧值 |
| 存储开销 | 最高 | 通常最低 | 中等 |
| 典型场景 | 日志、流水、行为明细 | 报表、指标统计 | 维表、状态快照 |
| 查询特点 | 明细粒度查询灵活 | 聚合查询快,但改明细难 | 主键点查快,范围扫描看实现 |
这张表里最值得关注的是“去重能力”和“更新行为”两行。很多人选型时只看业务里是否需要去重,却忽略了聚合模型和主键模型在实现机制上的差别,结果建出来的表查询行为和自己预期完全不一致。
2. 深入原理解读:排序、合并与主键索引
2.1 存储层按 Key 排序排序的意义
Doris 是一个列式存储的 MPP 数据库,这点很多资料都会提,但列式存储和“按 Key 排序”听起来是有点冲突的。实际上两者并不矛盾:数据在物理上按列拆开存放,但每一行的行号是有序的,所有列都按同一个行号顺序排列。也就是说,如果 Key 列排好序,那么其他列也会跟着这个顺序对齐存放。
排序的价值主要有两个。第一个是为前缀索引提供基础。Doris 会截取 Key 列的前 36 字节作为稀疏索引,范围扫描时能快速定位到对应的数据块,避免全表扫描。第二个是提高压缩率。相同或者相近的 Key 排在一起,经过列式压缩后冗余更少,存储占用会更低。
提示:排序不是简单的“选一列排序”就够了。前缀索引只覆盖 Key 列的前 36 字节,如果你把一列高基数且长度很长的字段放在第一个 Key 位置,索引可能只覆盖了该字段的前缀,过滤效果会大打折扣。
2.2 聚合模型如何在导入时做预聚合
聚合模型的预聚合不是只在导入那一刻发生的,而是分为两个阶段:实时导入阶段和后台 Compaction 阶段。
实时导入时,Doris 会把数据先写入内存中的 MemTable,同一批次内如果遇到相同 Key,会直接按照表定义的聚合函数做一次本地合并。之后数据落盘为不可变的 Rowset 文件。因为每次导入都会生成新的 Rowset,所以不同批次之间相同 Key 的数据可能散落在不同文件里。当后台 Compaction 把这些 Rowset 合并时,会再次按照相同的聚合规则把跨批次的数据合并到一起。这就是为什么聚合模型即使导入了很多小批次,最终存储上的行数也会收敛到 Key 的粒度。
这个机制意味着:聚合模型查出来的结果并不一定等于“导入时最终合并后的结果”,查询引擎读取多个 Rowset 时还会临时做一次合并计算。所以不必担心单个批次还没被 Compaction,查询结果就不对。
2.3 主键模型的两种实现路径:MOW 与旧版
主键模型在 Doris 历史上有过两种实现方式,理解它们有助于解释很多性能问题。
第一种是旧的 Merge-on-Read 模式。导入新数据时不会立刻删除旧主键的数据,而是同时保留旧版本和新版本,查询的时候再根据主键合并版本,只返回最新值。这种方式写路径简单,但读路径很吃亏,尤其是大量点查加频繁更新会放大读放大。
第二种是 Merge-on-Write 模式,在建表时通过enable_unique_key_merge_on_write属性开启。写入新数据时,会先标记旧主键数据为删除,然后把新数据写入,真正做到了“写时合并”。点查性能大幅提升,但写入时需要额外处理删除标记和索引,写放大反而更大。
现在的 Doris 版本里 MOW 已经很成熟,新集群我建议直接用 MOW。特别是需要对主键做高频更新的场景,旧模式慢得让人怀疑人生。
3. 选型与实操:什么时候用哪种模型
3.1 明细模型的适用场景与限制
明细模型最合适的场景是原始事实数据,比如点击流日志、订单明细、操作审计。这些数据的共同特点是:每条记录本身是一个不可拆分的事实,用户可能随时针对任意维度做切片查询,且几乎不会对已有行做修改。
但明细模型有个容易被忽略的限制:它没有“覆盖更新”能力。如果你导入两行完全相同的订单,这两行都会保留。业务上如果要把某条明细标记为取消,正确做法不是 UPDATE,而是再写一条状态为“取消”的明细,查询时取最新状态。说白了,明细模型要求你在业务层面接受“数据只增不改”的约束。
3.2 聚合模型的最佳实践
聚合模型不是简单地把明细数据汇总一下,而是要把聚合维度想清楚。因为建表时你选择了 Key 列,后续所有预聚合都只能精确到这一组维度。如果业务后来又需要按新的维度聚合,要么重新建表导入,要么用明细模型在查询时现算。
我建议按以下步骤来确定聚合模型:
- 梳理业务上最常用的统计维度,比如时间、地区、渠道、商品。
- 明确统计指标,比如金额总和、次数计数、最大值、最新值。
- 把这些维度设为 Key 列,指标列分别指定 SUM、MAX、MIN、REPLACE。
- 如果某个指标需要“保留最新但不是累加”,就用 REPLACE。
比如订单金额用 SUM,用户最新等级用 REPLACE,最大并发数用 MAX,这样一张聚合表就能同时服务多种查询。
3.3 主键模型的更新场景
主键模型适合那些“每个对象有一个唯一 ID,ID 对应的状态会不断变化”的数据。最典型的就是用户维表、商品维表、设备状态表。这类数据量可能很大,但单次更新涉及的键数量少,查询绝大多数是精确按主键取数。
如果你在建表时选择了主键模型,有一个隐形的约束:主键列必须是建表字段中靠前的列,且主键列以外都是值列。值列不指定聚合函数,也没必要指定,因为语义就是后写覆盖前写。
这里我也要提醒一句:主键模型适合更新,但不代表可以像 MySQL 那样随意 UPDATE 单行。Doris 不是面向事务型 OLTP 的数据库,主键模型更适合批量导入最新快照或者流式写入变更数据。如果真的要做逐行极低延迟点更新,需要考虑导入频率和写入并发。
3.4 实操建表语句和参数解析
下面给出三张示例表的建表 SQL,方便直接对照。
明细模型示例:
CREATE TABLE example_db.user_click_log ( log_id BIGINT NOT NULL, user_id BIGINT NOT NULL, click_time DATETIME NOT NULL, channel VARCHAR(20), page_url VARCHAR(200) ) DUPLICATE KEY(log_id) DISTRIBUTED BY HASH(user_id) BUCKETS 12 PROPERTIES ( "replication_num" = "3" );聚合模型示例:
CREATE TABLE example_db.order_agg_daily ( order_date DATE NOT NULL, city_id INT NOT NULL, order_amount BIGINT SUM DEFAULT '0', order_count BIGINT SUM DEFAULT '0', max_single_amount BIGINT MAX DEFAULT '0' ) AGGREGATE KEY(order_date, city_id) DISTRIBUTED BY HASH(city_id) BUCKETS 6 PROPERTIES ( "replication_num" = "3" );主键模型示例:
CREATE TABLE example_db.user_profile ( user_id BIGINT NOT NULL, nickname VARCHAR(64), vip_level INT, update_time DATETIME ) UNIQUE KEY(user_id) DISTRIBUTED BY HASH(user_id) BUCKETS 10 PROPERTIES ( "replication_num" = "3", "enable_unique_key_merge_on_write" = "true" );建表时几个关键参数值得注意:replication_num控制副本数,生产环境至少 3 个;BUCKETS控制分桶数量,一般按照预估数据量和机器数量来定,不是越大越好;enable_unique_key_merge_on_write只对主键模型生效,新表建议显式开启。
4. 常见坑与排查技巧
4.1 表建错了能不能改模型
经常有人问:表建完之后发现模型选错了,能不能 ALTER TABLE 修改模型?很遗憾,Doris 的表模型在建表时固定,目前不能直接变更。这个时候你能做的选择基本只有两个:新建一张正确模型的表,把数据重新导入;或者用视图/Rollup 在逻辑层做拓展,但物理模型本身改不了。
所以我一直建议建模前先做一轮数据特征分析,重点看三个问题:数据是否允许重复?重复后怎么处理?查询主要按什么维度过滤?把这三个问题答清楚,模型基本就定了。
4.2 聚合模型查询结果不对
聚合模型用得多了以后,我遇到过一个很典型的问题:某张聚合表里有 SUM 和 REPLACE 两种聚合列,导入几条新数据后,单查某一天的指标看起来正确,但跨天查总和时,REPLACE 列的结果也跟着做了一种“不知道该怎么算”的合并。
这不是 Doris 算错了,而是 REPLACE 列的语义本来就是“取最新值”,做跨维度汇总时它并不会重新求和。所以理解每种聚合函数的语义非常重要:SUM 是可累加的,MAX/MIN 是幂等的,REPLACE 则是状态值。如果你把用于 REPLACE 的字段当成指标字段做 SUM,那一定会踩坑。
4.3 主键模型性能退化
主键模型在开启 MOW 后,点查性能已经很好,但有一个场景容易性能退化:高频小批量导入且每次导入的 Key 与之前大量重叠。因为每次写入都要定位旧版本并标记删除,如果导入频率远高于 Compaction 处理能力,删除标记会不断积累。
遇到这种情况,我会先检查写入频率和批次大小。尽量把多次小更新合并成一批导入,减少版本数和标记写入。如果业务真的需要秒级更新,还需要评估是否应该换用更合适的技术栈,Doris 的强项始终是分析场景而不是单行写入。
4.4 排序键与去重键的边界
还有一个容易混淆的地方:Key 列既参与排序,也参与去重/聚合。但在明细模型里,Key 列只是排序键,不代表主键,更不能用来做强制唯一约束。在聚合模型里,Key 列是分组维度,相同 Key 才会聚合;在主键模型里,Key 列既是排序键也是唯一键。
因此建表时不要因为主键模型强调唯一,就把所有值列都塞进 Key 列。那样虽然从结果上能实现“所有字段完全相同才去重”的效果,但也意味着前缀索引和排序的开销大幅增加,得不偿失。这里我踩过真实的坑:某表把 20 多个字段全部设为 Key,结果导入耗时翻倍,查询扫描范围也明显变大。
5. 我的选型经验总结
聊到最后,我还是想强调一点:Doris 这三种存储模型没有绝对的优劣,只有适不适合业务。我自己的习惯是先画一张数据流图,把数据源头、更新频率、查询方式标清楚,再决定模型。
如果业务只关心结果指标,聚合模型通常最省心;如果业务要保留完整事实,明细模型最稳妥;如果业务需要按主键维护最新状态,主键模型是必经之路。组合使用也很常见,同一份原始数据可以同时存在明细模型表和主键模型表里,前者用来追溯细节,后者用来服务实时查询。
我个人在实际操作中的体会是:宁可在建表前多花一小时确认数据特征,也不要在上线后花一天去重导数据。模型定错了,再高级的查询优化也很难救回来。