接手 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 主键模型的更新场景主键模型适合那些“每个对象有一个唯一 IDID 对应的状态会不断变化”的数据。最典型的就是用户维表、商品维表、设备状态表。这类数据量可能很大但单次更新涉及的键数量少查询绝大多数是精确按主键取数。如果你在建表时选择了主键模型有一个隐形的约束主键列必须是建表字段中靠前的列且主键列以外都是值列。值列不指定聚合函数也没必要指定因为语义就是后写覆盖前写。这里我也要提醒一句主键模型适合更新但不代表可以像 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 这三种存储模型没有绝对的优劣只有适不适合业务。我自己的习惯是先画一张数据流图把数据源头、更新频率、查询方式标清楚再决定模型。如果业务只关心结果指标聚合模型通常最省心如果业务要保留完整事实明细模型最稳妥如果业务需要按主键维护最新状态主键模型是必经之路。组合使用也很常见同一份原始数据可以同时存在明细模型表和主键模型表里前者用来追溯细节后者用来服务实时查询。我个人在实际操作中的体会是宁可在建表前多花一小时确认数据特征也不要在上线后花一天去重导数据。模型定错了再高级的查询优化也很难救回来。