
上周线上一个订单汇总任务突然对不上账count 出来的行数比业务系统多了一倍多。查了半天原因不复杂——那张表用的是 Doris 明细模型每天同步一次订单全量快照相同订单 ID 在表里存了两份。这就是我为什么想认真写一篇 Doris 主键模型的完整解析。主键模型Unique Key Model就是专门解决这种“同一业务主键只需要保留一条最新记录”的场景它能帮你做覆盖更新、维表实时同步、MySQL Binlog 落库这一类典型需求。这篇文章会从底层的读合并/写合并机制讲起到建表语句、三种导入方式、部分列更新、sequence 列、常见坑最后把主键模型和明细/聚合模型的选型边界也一并理清楚。不管你是刚接触 Doris还是已经在生产环境里踩过坑这篇文章都应该能给你一些直接能用的东西。1. 为什么需要主键模型从一份重复的订单数据说起1.1 先还原一下那个典型的翻车现场我遇到的那个场景用大白话说就是业务方每天早上把前一天的全量订单快照导进 Doris任务是简单但根本没法应对“同一个订单今天状态变了”这种更新需求。因为明细模型下同一主键的数据可以存在多行今天导入的订单 1001 是“已支付”明天同步过来的订单 1001 变成“已发货”两行都留在表里。你查最新状态得先按主键开窗排序再做去重。数据量小的时候没感觉等表里积了十几天的全量快照每一份查询都要带着去重逻辑跑性能肉眼可见地往下掉。这种问题不是个例。但凡你从业务库MySQL、PG、Oracle往 Doris 同步数据只要业务表有更新操作直接用明细模型就是在给自己埋雷。主键模型之所以有用是因为它在表结构层面就声明了“这个字段是唯一键”相同主键的数据进来后到的覆盖先到的最终表里每个主键只会有一条记录。你不需要在查询时做任何额外处理count、join、group by 都是干净的结果。1.2 明细模型到底适合什么场景先说清楚明细模型不是不好它是用来存“事件流”的。比如用户点击日志、接口访问记录、设备上报消息这些数据的特点是只追加、不修改、不删除。用明细模型存储没有任何合并成本写入吞吐最高查询时想怎么聚合就怎么聚合。你要是在这类场景里强行用主键模型反而会付出额外的合并代价纯属浪费。问题出在很多人把明细模型当成了 Doris 的默认选项不管什么数据都往里塞。一旦业务数据里出现“修改”语义明细模型就露馅了。哪怕你只在查询时去重Doris 底层仍然要为每一份导入版本做归并排序版本一多查询时的合并开销陡增。1.3 主键模型的典型应用场景在我实际接触的项目里主键模型最常用的场景有这几类MySQL 业务表的实时/准实时同步不管是 Binlog 监听还是定时捞增量同步过来的每一条变更记录都带着主键用主键模型可以保证 Doris 里始终是业务表的最新镜像。维表更新比如用户维表、商品维表每天从上游同步一次全量数据用主键模型天然去重查询时不需要再“取最新一条”。替代 Elasticsearch 一部分场景把文档 ID 作为主键配合 Doris 的倒排索引既做检索又做分析后面我会单独展开。订单快照、库存快照这类“每天全量覆盖”的数据。要提醒一句Doris 是分析型数据库不是 OLTP。主键模型解决的是“分析场景下数据更新”的问题它扛不住单条高并发点查也不支持事务级更新。你要是拿它去承接业务系统的在线读写那方向就错了。2. 主键模型的底层机制读合并与写合并的取舍2.1 两种合并方式先搞清楚一个核心区别Doris 主键模型在不同版本里有两种底层实现读合并Merge-On-ReadMOR和写合并Merge-On-WriteMOW。名字已经说得很直白了读合并导入数据时不处理相同主键的冲突先全部落盘。等你要读数据的时候系统再按主键把多个版本合并起来只保留最新一条。写合并导入数据时系统就检查主键冲突旧版本直接标记为删除数据落盘后已经是“干净”的状态读取时不需要额外合并。我举个实际例子。假设一张订单表主键是 order_id你用 Stream Load 把同一个 CSV 连续导入了 100 次每次里面都包含 order_id1001 的记录只是状态不同。MOR 表里会积累 100 个版本的 order_id1001你查询时 Doris 要把这 100 个版本全部读出来按版本号排序再取最新的一条。MOW 表则是在前 99 次导入时就把旧版本标记删除了最后表里只有第 100 次导入的那一条。查询时从源头上就只剩一份数据不需要额外 merge。读合并的优势是写入快因为不需要维护复杂的索引结构缺点是查询时的合并开销会随着导入批次增长而恶化。写合并的优势是查询快尤其适合点查和小范围查询缺点是导入时要额外计算主键冲突对内存有要求。2.2 Doris 版本演进MOW 不是开了就完事Doris 在 1.2 版本开始支持 MOW当时要通过建表属性显式指定。到了 2.1 及之后的版本MOW 逐渐成为主键模型的默认方式。但“默认开启”不代表你没有选择建表时用enable_unique_key_merge_on_write可以控制PROPERTIES ( enable_unique_key_merge_on_write true )MOW 底层维护的东西比 MOR 多最核心的就是 delete bitmap删除位图。这个位图标记了哪些 rowset 里的哪些行已经被主键覆盖或删除导入时发现主键冲突就更新位图查询的时候直接跳过这些标记行。我自己的实践体会如果 Doris 版本是 2.0 及以下而且你的表有高并发大吞吐导入需求MOR 反而更稳。如果版本在 2.1 以上从查询性能考虑默认 MOW 没什么问题但要注意主键索引的内存开销这个我在第 5 节展开。2.3 主键、排序键、分区键三个容易混的概念Doris 主键模型里有一个容易忽略的细节UNIQUE KEY指定的列既充当主键也是排序键。数据在 BE 端物理存储时是按照这一组 key 列排序的。这个特性直接影响查询性能因为 Doris 的谓词下推和前缀索引都是基于排序键设计的。比如你的表指定了UNIQUE KEY(user_id, order_id)那么查询条件里带user_id或者同时带user_id和order_id时Doris 能利用前缀索引直接定位数据效率很高。但如果你只按order_id查没办法走前缀索引只能全表扫描。主键列的选择有几个实际经验把过滤频率最高的列放在最前面。尽量选整型或较短的字符串MOW 模式下主键会参与内存索引构建字符串越长内存压力越大。不要把大字段如 JSON、长文本设为主键排序开销和存储开销都扛不住。分区键是另一个概念。Doris 支持按范围或列表分区比如按天分区。主键和分区键可以不是同一个字段但分区列如果不在主键里DDL 会有一些限制实际业务里常见的做法是让分区列同时作为主键的前缀列比如UNIQUE KEY(day, order_id)。3. 建表与导入实战一套能上生产的 Doris 主键表3.1 先给一张可以直接抄的主键建表语句不绕弯子先看一个我在生产环境使用的建表示例。假设要同步 MySQL 里的用户表主键是 user_id带一个更新时间字段CREATE TABLE IF NOT EXISTS dwd_user_info ( user_id BIGINT, name VARCHAR(64), age INT, email VARCHAR(128), update_time DATETIME ) UNIQUE KEY(user_id) DISTRIBUTED BY HASH(user_id) BUCKETS 16 PROPERTIES ( replication_num 3, enable_unique_key_merge_on_write true, function_column.sequence_col update_time );几个关键点逐个说。DISTRIBUTED BY HASH(user_id) BUCKETS 16是分桶设置Doris 会按 user_id 的哈希值把数据分散到 16 个桶里。分桶数量要结合数据量和集群规模来定不建议一次性拍脑袋先看单桶数据量经验值是单桶 1GB 到 5GB 左右后期可以通过ALTER TABLE调整分桶数。replication_num是副本数生产环境至少 2 或者 3保证 BE 节点挂掉之后数据不丢。单机测试可以设成 1。function_column.sequence_col是 sequence 列它解决的是乱序问题下面第 4 节重点讲。这里先记住同步数据带时间字段的就把它配上去宁可不用不能没有。3.2 三种导入方式Stream Load、Routine Load、Insert IntoDoris 的主键模型只是一张表的属性数据怎么进去还得靠导入方式。我按实际使用频率排个序。Stream Load 是用的最多的适合程序里通过 HTTP 接口把本地文件或内存中的数据批量导入。curl --location-trusted -u admin:password \ -H label:load_user_info_20250101 \ -H column_separator:, \ -H columns:user_id,name,age,email,update_time \ -T user_info.csv \ http://127.0.0.1:8030/api/example_db/dwd_user_info/_stream_loadStream Load 返回 JSON里面有Status、NumberTotalRows、NumberLoadedRows这些字段脚本里一定要做状态判断Status: Success才算成功。Routine Load 适合从 Kafka 持续消费数据做实时同步。CREATE ROUTINE LOAD example_db.user_info_load ON dwd_user_info COLUMNS(user_id, name, age, email, update_time) PROPERTIES ( desired_concurrent_number 3, format json, jsonpaths [\$.user_id\, \$.name\, \$.age\, \$.email\, \$.update_time\] ) FROM KAFKA ( kafka_broker_list 10.0.0.1:9092, kafka_topic ods_user_info, kafka_partitions 0,1,2, kafka_offsets OFFSET_BEGINNING );Insert Into 适合小批量、即席查询时手工灌数据。需要注意的是主键表支持INSERT INTO ... VALUES也支持INSERT INTO ... SELECT重复主键会走同样的覆盖逻辑。生产环境大批量导入不要用 Insert Into性能远不如 Stream Load。三种方式的选择逻辑很简单Kafka 实时消息用 Routine Load离线文件/程序落库用 Stream Load手动作业、临时调试用 Insert Into。3.3 用 Doris 替换 ES主键模型在其中扮演什么角色这是最近被问得特别多的话题。很多团队想用一个组件同时解决检索和分析Doris 从 2.1 版本开始支持倒排索引和全文检索配合 MOW 主键模型确实能替代一部分 ES 场景。为什么主键模型在这里很关键ES 里的文档 ID 天然唯一更新文档就是覆盖。Doris 主键模型正好能对上这个语义你把 ES 的_id映射成 Doris 的主键列每次导入时相同 ID 的文档自动被覆盖doc 内容变了就更新一行不需要先 DELETE 再 INSERT。我之前帮一个项目做过日志分析系统的选型原来的架构是 ES 做检索、Doris 做报表两套集群都要维护。后来把日志数据按日志 ID 作为主键写入 Doris配合倒排索引做关键字查询同时直接在 Doris 上做聚合统计链路短了不少。但也要说清楚Doris 的全文检索能力跟 ES 比还是有差距复杂的相关性打分、嵌套聚合、模糊查询ES 还是更强。用 Doris 替换 ES 的合理场景是“检索分析一体化”而不是“把 ES 完全干掉”。4. 数据更新实战覆盖更新、部分列更新与乱序保护4.1 全字段覆盖更新理解 UPSERT 的本质主键模型最核心的语义是 UPSERT也就是存在即更新、不存在即插入。你同一批数据重复导入 100 次最终表里每行只保留最后导入的那一版。这个设计对数据同步任务特别友好因为任务天然幂等重跑不会产生重复数据。实际用的时候要注意一个点如果你导入的文件里同一主键在文件内部就出现了多行Doris 按什么规则保留答案是保留文件内最后一次出现的那一行。所以做数据清洗时最好保证一个导入批次内部主键唯一否则容易产生“你以为更新了但顺序不对”的迷惑结果。全字段覆盖更新的写法不需要任何特殊语法正常导入即可。它适合的场景是每次导入都包含整行所有字段比如 MySQL 全量快照同步。4.2 部分列更新只改一个字段的高效方案很多时候我们不想更新整行只想改其中某几个字段。比如用户维表里用户基本信息保持不变只是用户等级变了。如果按全字段覆盖你得先把所有字段查出来再拼装回去麻烦且容易出错。Doris 从 2.0 开始支持部分列更新前提是主键表开启了写合并。具体操作是通过 Stream Load 的partial_columns参数指定curl --location-trusted -u admin:password \ -H partial_columns:true \ -H columns:user_id,user_level,update_time \ -T user_level_update.csv \ http://127.0.0.1:8030/api/example_db/dwd_user_info/_stream_load注意columns里只列要更新的列没有列出的字段保持原值不动。这是主键模型一个非常实用的能力尤其是从业务库同步字段级变更时比如 Binlog 只变更了一个字段直接按变更字段更新省掉很多拼接逻辑。部分列更新有一个注意点如果表里还没有这一行主键的记录部分列更新会自动插入一行只是缺失的字段会变成默认值NULL 或建表默认值不会报错。所以部分列更新更适合“数据已经初始化过”的表。4.3 sequence 列防止旧日志覆盖新数据这部分是我踩过坑之后才真正重视的。场景是这样的你用 Canal 监听 MySQL Binlog把变更记录发到 KafkaDoris 用 Routine Load 消费。但由于 Kafka 分区乱序、任务重试等原因晚产生的变更日志可能先到早产生的后到。如果没有保护机制后到的旧数据会把新数据覆盖掉Doris 里看到的状态就回退了。解决方案是 sequence 列。你指定一个“版本字段”Doris 导入时比较相同主键的 sequence 值只保留 sequence 更大的那一条而不是简单地“后到者覆盖”。建表时有两种配置方式PROPERTIES ( function_column.sequence_col update_time )或者只声明类型然后在导入时指定PROPERTIES ( function_column.sequence_type DATETIME )使用sequence_col的方式更直观表里直接用一个真实业务字段如 update_time作为版本依据。使用sequence_type的方式更灵活导入时在 columns 里额外映射一个-H columns:user_id,name,age,email,update_time,sequpdate_time我用 sequence 列解决过一次线上数据回退问题。那次是同步链路里某个环节出现积压Kafka 分区数据重放一批旧数据晚到了十几分钟如果没有 sequence 列整张表的更新状态会倒退到几分钟之前报表全部错乱。加上之后旧数据导入进来会被丢弃因为 sequence 值比当前已有的小。4.4 删除数据DELETE 与主键模型的配合有更新就必然有删除。Doris 主键表支持标准的DELETE FROM语句DELETE FROM dwd_user_info WHERE user_id 1001;MOR 模式下DELETE 不会立刻从存储介质上移除数据而是生成一个删除标记查询时这些行会被过滤掉。MOW 模式下DELETE 会更新 delete bitmap查询时直接跳过。删除操作本身不难难的是大批量删除。假设你要清掉一个月前的历史数据如果用DELETE FROM table WHERE update_time 2024-01-01Doris 要处理大量删除标记短期内会产生较多小文件影响查询性能和 compaction 效率。生产中更推荐的做法按分区设计表直接删分区ALTER TABLE DROP PARTITION这个操作非常快。或者把要保留的数据 SELECT 出来写入新表然后替换表名。如果你的业务确实有频繁的单行删除需求也要清醒一点Doris 不是为高频 OLTP 删除设计的单日几十万次的小 DELETE 可以做再高就要考虑架构调整了。5. 主键模型实务中的三个坑OOM、连不上 9030 与同步链路中断5.1 BE 端 OOM写合并的内存代价MOW 模式在导入时要做主键冲突检测BE 进程需要把主键索引的一部分缓存在内存里。如果一张主键表的数据量特别大或者主键字段又长又多或者导入并发开得很高很容易触发内存超限。我遇到过典型报错是 BE 日志里出现Memory limit exceededStream Load 返回失败任务一直重试然后 BE 数据目录出现大量待合并的版本文件。排查链路一般是这样先看 BE 的配置项mem_limit默认是物理内存的 80%这是 BE 进程整体内存上限。再看导入相关的load_process_max_memory_limit_percent默认 50%。如果单次导入的数据量太大并行导入任务太多就把这两个值调整一下但不能无脑调大要给读查询留足内存。更有效的做法是从表结构上优化。主键尽量用整型避免用很长的大字段主键列不要太多一列能搞定的不要设计成三四列分桶数不要设得过大分桶越多导入时并行的内存消耗越大。我见过一张表 BUCKETS 设成 128单次导入就触发 OOM后来调成 32 就好了。如果你用的是 Doris 2.0 之前的版本MOW 还不稳定导入高峰内存抖动会更明显。条件允许的话优先升级到 2.1 以上。5.2 FE 连不上 9030从客户端一路排查Cant connect to MySQL server on 127.0.0.1:9030这个报错社区里问的极多。大多数情况不是 Doris 坏了而是客户端连接方式不对。排查顺序我建议是这样的第一步确认 FE 进程活着。在 FE 所在机器上执行ps -ef | grep DorisFE或者看fe/log/fe.log最后有没有报错。FE 如果启动失败常见原因是元数据目录权限不对、editlog 损坏、端口被占用。第二步确认 9030 端口有监听。执行netstat -tlnp | grep 9030。端口没监听就检查 FE 配置里query_port是不是改成别的了。这里有一个容易犯的错FE 和 BE 的端口是分开的BE 的be_port默认 9060FE 的query_port默认 9030。你连 MySQL 协议走的是 9030不是 BE 的端口。第三步检查 MySQL 客户端版本。Doris 兼容 MySQL 8.0 的握手协议但个别太老的客户端如 5.1、5.5会遇到认证方式不兼容的问题表现为能建立 TCP 连接但握手失败。建议统一用 MySQL 8.x 的客户端或者直接用 Doris 自带的mysql-client。第四步确认不是网络或防火墙问题。远程连接时只改 IP 没用要确认 9030 端口在安全组、防火墙里放行了。Doris 的 FE 和 BE 之间内部通信还会用到 9010、9020、9060 等端口仅在集群内网放开不要暴露到公网。5.3 同步链路中断Kettle、DataX 与 Routine Load 的坑数据同步工具的选择上Kettle 和 DataX 是接入 Doris 高频出现的两条路。Kettle 通过 Doris 插件把数据写入 Doris本质也是走 Stream Load 协议。常见的坑是插件版本和 Doris 版本不匹配老插件发起请求时用的参数新版本不认或者返回解析失败。遇到这种情况先确认 Kettle 插件版本到 Doris 官网或社区下载匹配的版本别用年代久远的第三方插件。DataX 同步到 Doris 用得更多官方有doriswriter插件同样走 Stream Load。主键模型下 DataX 写入天然幂等重复执行不会产生重复行。实际使用时注意两个点一是batchSize不要设置过大建议 10000 到 50000 之间过大容易拖慢 BE 导入线程二是任务失败重跑时Doris 的 label 是全局唯一的如果你在 DataX 配置里指定了固定 label重跑会报 label 已存在。简单处理就是不指定 label让 Doris 自动生成。Routine Load 的坑更多一些。任务新建后用SHOW ROUTINE LOAD\G;看状态集中在Statistic字段receivedBytes长时间不涨说明 Kafka 侧没有新数据或者消费组被别的任务占据了。errorRows持续增加去查日志看 JSON 解析失败原因八成是jsonpaths写错或者 Kafka 消息里有脏数据。任务处于PAUSED一般是因为反压导致的导入错误次超过阈值修正后执行RESUME ROUTINE LOAD。我习惯的做法是新建 Routine Load 任务时先用一个小 topic 测试kafka_offsets从最新开始消费跑几个小时确认稳定后再放开到全量。不要一上来就OFFSET_BEGINNING消费海量历史数据怕把 BE 压垮。6. 主键模型和其他模型怎么选一张表说清楚6.1 三种数据模型的定位差异Doris 一共就三种模型明细模型Duplicate Key、主键模型Unique Key、聚合模型Aggregate Key。很多新手选型困难其实记住它们的定位差异就够了。明细模型存事实只追加。它不管数据是否重复来什么存什么。适合日志、点击流、事件明细。查询时所有行都在聚合由查询端决定。主键模型存最新状态自动去重。适合维表、订单状态、用户画像、MySQL 镜像表。它背后是覆盖更新逻辑也就是上面说的 UPSERT。聚合模型存预聚合结果导入时按聚合函数合并。适合流量统计、收益汇总、指标看板。比如你要按天统计 PV/UV直接在表里定义 SUM 聚合列数据导入时自动累加查询极快。聚合模型有一个容易被忽视的点它也有 key 列但不是主键模型那种“去重取最新”而是“相同 key 的数据按聚合函数合并”。比如SUM聚合列会把两次导入的数值相加REPLACE聚合列则和主键模型的覆盖行为类似。实际业务里如果只需要“同 key 保留最新”用主键模型如果需要“同 key 做求和/最大值/最小值”用聚合模型。6.2 快速决策清单给你一个我经常用的判断逻辑数据只增不改要全量历史明细 → 明细模型。数据有更新要保留最新状态 → 主键模型。数据有更新但只关心汇总结果不需要明细 → 聚合模型。要替代 ES 做检索和聚合 → 主键模型 倒排索引。要同步 MySQL Binlog → 主键模型 sequence 列。要做留存分析、漏斗分析、行为路径分析 → 明细模型别用主键模型。方案设计阶段多花十分钟想清楚模型比上线后再改表结构省太多事。Doris 支持三种模型之间互相转换吗不支持直接转换。改表结构最稳妥的方式是新建目标模型的表用INSERT INTO ... SELECT把数据导过去再切换表名。数据量大的时候记得在低峰期操作。6.3 最后谈一点个人体会从我自己的项目经历看主键模型最大的价值不是“去重”这个功能本身而是它让 Doris 从“只能存历史日志的仓库”变成了“可以承接业务状态同步的分析平台”。订单状态、用户等级、库存快照、配置表这些原本要落到 MySQL 或者 Redis 的数据现在都能进 Doris 做分析减少了跨系统数据链路的复杂度。但也要清醒主键模型不是万能的。别拿它硬接高频 OLTP 写入也别在细粒度事实表上用主键模型做去重那会让数据失去历史轨迹。我后来在订单事实表上宁愿用明细模型把每一次状态变更都留痕真正需要“最新状态”的用主键模型单独建表各司其职。再分享一个具体的操作习惯。每次给主键表导入数据无论是 Stream Load 还是 Routine Load我都会先在测试表上做一轮 10 万行左右的小批量导入然后直接查SELECT count(*) FROM table和业务系统的总行数做对比。这个看起来很笨的动作帮我挡掉过很多次列错位、分隔符不对、JSON 字段映射错误这类低级问题。数据同步这种事验证永远比自信重要。