我把实时报表从 ClickHouse 迁到 Doris 后,导入吞吐从 8k/s 升到 12 万/s
上周业务方在群里甩过来一张 SQL 截图,我一看就皱眉头了。
“这个实时看板怎么又卡了?15 秒还没出结果。”
我们做的是电商运营的实时大盘——GMV、订单热区、用户漏斗,每 5 秒刷一次。一开始用的是 ClickHouse,扛了好几个月。结果最近几天双 11 预热,订单流水从平时的 1k/s 涨到 8k/s,导入就开始堵车了。
最离谱的一次,看板卡了 47 秒才出数。运营总监直接打电话过来——你们这个数据是不是坏了?
我心里很清楚:不是 ClickHouse 不行,是场景选错了。
ClickHouse 强在 OLAP 大宽表的复杂聚合,不在高频小批量实时写入。我们把它当实时数仓用,每天导入 7000 万条记录,结果高频写入时各种 part 合并卡住后台,查询也跟着抖。
折腾了两周,我把链路从 ClickHouse 迁到 Apache Doris。导入吞吐从 8k/s 涨到12 万/s,P99 查询延迟从 1.4s 压到 180ms,业务看板彻底没再卡过。
今天把这次迁移讲清楚——不是 ClickHouse 不好,是不同引擎有它适合的战场。
背景:为什么 ClickHouse 在我们这里"水土不服"
先说清楚我们原本的架构。
业务 MySQL(主从) → Canal → Kafka → ClickHouse(ReplacingMergeTree) ↑ Grafana 实时看板看似很标准,问题出在三个地方。
问题一:高频小批量写入触发 part 风暴。
ClickHouse 的存储模型是"每次写入产生一个 part,后台异步 merge"。小批量(1k 条/批)高频(每秒一批)写入时,part 数量会快速膨胀。我们高峰期看到后台有 800+ 个 part 在排队 merge,merge 占满了磁盘 IO。
system.parts里直接报警:
SELECTcount()FROMsystem.partsWHEREtable='order_log';-- 返回 847,意味着有 847 个未合并的 part问题二:ReplacingMergeTree 的"最终一致性"对实时看板不友好。
我们用 ReplacingMergeTree 做订单去重(同一订单号可能因补偿逻辑写多次)。问题是ReplacingMergeTree 的去重只在 part merge 时发生。part 堆积时,同一订单可能出现多次,看板上的"实时订单数"会跳变——前一秒 1.2 万,下一秒 1.5 万,业务方完全没法用。
问题三:多表 JOIN 在 ClickHouse 上是大宽表场景。
我们订单表要 JOIN 用户表、商品表、营销活动表,4-5 张表 JOIN 在 ClickHouse 上要靠"大宽表"预聚合。每次加新维度(比如新增营销活动类型),都要重建大宽表,ETL 改造成本非常高。
为什么选 Doris 不是 Impala / StarRocks
迁之前我们做了一圈调研,对比了三个候选:
| 引擎 | 实时写入 | 复杂 JOIN | MySQL 兼容 | 运维成本 |
|---|---|---|---|---|
| ClickHouse | 中(part merge 痛点) | 弱(大宽表) | 无 | 中 |
| Doris | 强(Stream Load + 主键模型) | 强(自适应 JOIN) | 强(MySQL 协议) | 低 |
| StarRocks | 强 | 强 | 中 | 中 |
选 Doris 不是因为它最强,是因为Doris 的 MySQL 协议兼容 + Stream Load + Unique Key 模型刚好对上了我们的痛点。
选 Doris 的三个核心理由:
- MySQL 协议兼容——业务方继续用 MySQL 客户端连,DBA 不用学新东西
- Stream Load HTTP 接口——Kafka 消费端可以直接 HTTP 推送,比 ClickHouse 的 Kafka 表灵活
- Unique Key 模型 + 强一致的实时去重——没有 ReplacingMergeTree 的"最终一致"问题
下面是迁移后的链路:
业务 MySQL → Canal → Kafka → Flink (轻量 ETL) → Doris Stream Load ↑ Grafana 实时看板Flink 只做一层轻量 ETL(字段映射、过滤异常值),不做聚合。所有实时聚合都在 Doris 里做。
第一步:Doris 集群部署与表模型选型
Doris 集群我们用 3 FE + 6 BE 的结构(中等规模生产环境够用):
# fe.conf 核心配置http_port = 8030 rpc_port = 9020 query_port = 9030 priority_networks = 192.168.0.0/16# be.conf 核心配置be_port = 9060 webserver_port = 8040 storage_root_path = /data1/doris;/data2/doris# 多盘 IO 分散表模型选择——Doris 有 Aggregate、Unique、Duplicate 三种模型,我们订单表用Unique Key:
CREATETABLEorder_log(order_idBIGINTNOTNULL,user_idBIGINTNOTNULL,sku_idBIGINTNOTNULL,amountDECIMAL(18,2)NOTNULL,statusVARCHAR(32)NOTNULL,created_atDATETIMENOTNULL,updated_atDATETIMENOTNULL)UNIQUEKEY(order_id,created_at)PARTITIONBYRANGE(created_at)()DISTRIBUTEDBYHASH(order_id)BUCKETS32PROPERTIES("replication_num"="3","enable_unique_key_merge_on_write"="true"-- 关键:开启 merge-on-write);关键点:enable_unique_key_merge_on_write = true。
这个属性是 Doris 1.2 之后的杀手锏。默认 Unique Key 是读时合并(类似 ClickHouse 的 ReplacingMergeTree),开启 merge-on-write 后变成写时合并——同一 order_id 的多次写入在 BE 层直接覆盖,没有 part 堆积问题,也没有"最终一致"跳变。
我做了个对比测试:
| 模型 | 8k/s 持续写入 | 看板数据跳变 | P99 查询 |
|---|---|---|---|
| ClickHouse ReplacingMergeTree | 5 分钟后 part 堆积 | 频繁跳变 | 1.4s |
| Doris Unique Key(默认) | 流畅 | 偶尔跳变 | 220ms |
| Doris Unique Key(merge-on-write) | 流畅 | 不跳变 | 180ms |
差距最明显的是"看板跳变"——从 0 次/分钟到接近 0。这是业务方最关心的指标。
第二步:Stream Load 替代 Kafka 引擎表
ClickHouse 的 Kafka 表让我们又爱又恨——配置简单,但太脆。
-- ClickHouse 旧配置(已废弃)CREATETABLEorder_log_kafka(order_id UInt64,user_id UInt64,...)ENGINE=Kafka()SETTINGS kafka_broker_list='kafka:9092',kafka_topic_list='order_log',kafka_group_name='clickhouse_consumer',kafka_format='JSONEachRow';这套配置的问题:ClickHouse 直接当 Kafka 消费者,没办法做字段转换、过滤异常、关联维表。脏数据进来直接污染整张表。
Doris 的 Stream Load 思路完全不同——Doris 只管"怎么高效接收数据",不管"数据从哪来":
# Stream Load HTTP 接口(最基础的用法)curl--location-trusted-uroot:\-H"label: order_log_${date}_${uuid}"\-H"column_separator:,"\-T/tmp/order_log.csv\http://doris-fe:8030/api/order_db/order_log/_stream_loadlabel 参数是关键——Doris 通过 label 保证 Exactly-Once 语义,同一 label 多次提交只生效一次。配合 Flink 的两阶段提交,能做到端到端不丢不重。
我们用 Flink 做中间层,Flink 消费 Kafka 后调用 Stream Load:
// Flink Doris Sink (简化版)publicclassDorisStreamLoadSinkextendsRichSinkFunction<String>{privateHttpClienthttpClient;@Overridepublicvoidinvoke(Stringvalue,Contextcontext)throwsException{// value 是一行 CSV 格式HttpPostpost=newHttpPost("http://doris-fe:8030/api/order_db/order_log/_stream_load");post.setHeader("label","flink_"+UUID.randomUUID().toString());post.setHeader("column_separator",",");post.setEntity(newStringEntity(value,ContentType.TEXT_PLAIN));// Basic Authpost.setHeader("Authorization","Basic "+Base64.encode("root:"));httpClient.execute(post);}}这一步我们做了几个优化:
优化 1:攒批写入。
Flink 默认每条数据触发一次 invoke,8k/s 订单就意味着 8000 次/秒 HTTP 请求,Doris FE 扛不住。我们改成每 5000 条或 1 秒攒一批:
.buffer(5000,TimeUnit.SECONDS.toMillis(1))优化 2:并行多 Sink。
单 Sink 写入上限就是单 FE 的处理能力。我们起了 8 个并行 Sink 写同一张表的 32 个 bucket,不同 bucket 间互不冲突:
// Flink envenv.addSink(newDorisStreamLoadSink()).setParallelism(8).name("doris-sink");优化 3:失败重试 + label 去重。
如果 Stream Load 因为网络抖动失败,Flink 重试时用同一个 label重发,Doris 会自动去重(label 库保留 3 天)。这样即使 Flink 重启也不会重复写入。
第三步:复杂查询从 ClickHouse 迁过来的改造
业务方有 200 多条 SQL 要迁,最大头是这个 5 表 JOIN 的实时看板:
-- 原 ClickHouse SQL(4-5 张表 JOIN)SELECTdate_trunc('minute',o.created_at)ASminute,p.category_name,COUNT(DISTINCTo.order_id)ASorder_cnt,SUM(o.amount)ASgmvFROMorder_log oJOINuser_dim uONo.user_id=u.user_idJOINsku_dim sONo.sku_id=s.sku_idJOINpromotion_dim pONo.promo_id=p.promo_idJOINstore_dim stONo.store_id=st.store_idWHEREo.created_at>=NOW()-INTERVAL1HOURGROUPBYminute,p.category_nameClickHouse 上跑要 1.4 秒(已经建了大宽表),迁到 Doris 后我没建大宽表——因为 Doris 的自适应 JOIN 优化器能直接处理多表 JOIN:
-- Doris 上同样的 SQL,零改造SELECTdate_trunc(o.created_at,'minute')ASminute,p.category_name,COUNT(DISTINCTo.order_id)ASorder_cnt,SUM(o.amount)ASgmvFROMorder_log oJOINuser_dim uONo.user_id=u.user_idJOINsku_dim sONo.sku_id=s.sku_idJOINpromotion_dim pONo.promo_id=p.promo_idJOINstore_dim stONo.store_id=st.store_idWHEREo.created_at>=NOW()-INTERVAL1HOURGROUPBYminute,p.category_name;第一次跑:620ms。比 ClickHouse 还快。
我又用EXPLAIN看执行计划,Doris 的 CBO 优化器自动把 4 张小维表(user_dim、sku_dim、promotion_dim、store_dim)做了Broadcast Join——把维表全量广播到 BE 节点,订单大表在本地做 Hash Join,省掉了 Shuffle Join 的网络开销。
对比 ClickHouse 的 JOIN 改造:
| 维度 | ClickHouse 方案 | Doris 方案 |
|---|---|---|
| 是否需要大宽表 | 是 | 否 |
| 新增维度改造 | 重建宽表(全量) | 加 JOIN(增量) |
| 5 表 JOIN 耗时 | 1.4s | 620ms |
| 内存占用 | 大宽表 8GB | 维表各 200MB |
业务方第一次看到 620ms 的查询结果时,运营总监还以为我没跑完。之前 ClickHouse 要 1.4 秒,结果回到界面要等 1.5-2 秒,肉眼能感觉到卡顿。Doris 这边几乎是"刷一下就出"。
第四步:compaction 调优与冷热数据分层
Doris 也有 compaction 问题,但比 ClickHouse 好调得多。
compaction 调优的核心是compaction_policy和cumulative_size_based_promotion_ratio:
-- 表级别 compaction 配置ALTERTABLEorder_logSET("compaction_policy"="time_series",-- 时序数据用 time_series 策略"time_series_compaction_goal_size_mbytes"="1024",-- 单 segment 目标大小"time_series_compaction_file_count_threshold"="2000");time_series 策略专为"高频写入 + 时序查询"场景——它会主动把老数据合并成大 segment,新写入保持小 segment 持续追加。避免 ClickHouse 那种"part 数量爆炸"的问题。
冷热数据分层我们也做了:
-- 订单表按时间分区ALTERTABLEorder_logMODIFYPARTITIONBYRANGE(created_at)(PARTITIONp202606VALUESLESS THAN("2026-07-01"),PARTITIONp202607VALUESLESS THAN("2026-08-01"),PARTITIONp202608VALUESLESS THAN("2026-09-01"));-- 历史分区放到冷盘(HDD)ALTERTABLEorder_logMODIFYPARTITIONp202605SET("storage_medium"="HDD","storage_cooldown_time"="2026-06-01 00:00:00");热数据(最近 7 天)放 SSD,冷数据(>7 天)自动滚动到 HDD。Doris 通过storage_cooldown_time自动迁移,不用手动调度。
踩坑记录
迁移过程不是一帆风顺,几个值得记下来的坑。
1. Stream Load 的 label 重复导致数据丢失。
我们一开始用时间戳生成 label(label_${timestamp}),结果同一秒内多个并发 Flink Sink 任务可能生成重复 label。Doris 对重复 label 的处理是"返回成功但跳过写入"——业务上等同于丢数。
解决:label 改成flink_${uuid}_${taskId}_${checkpointId},保证全局唯一。
2. 第一次跑 SELECT COUNT(*) 直接超时。
ClickHouse 上 count() 是毫秒级,Doris 上居然超时。原因是我们的order_log表有 4 亿行,**count() 触发全表扫描**。
ClickHouse 之所以快,是因为它有count()的特殊优化(直接读元数据)。Doris 没有这个优化。
解决:Doris 上别用 count(*),改用SHOW PARTITIONS或ADMIN SHOW REPLICA STATUS看行数,或者用EXPLAIN看扫描量。如果真要精确 count,加 WHERE 条件限定分区。
3. JDBC 连接 Doris 报"too many connections"。
业务方有 50 多个应用通过 MySQL 协议连 Doris,Doris 的 FE 默认最多接受 1024 个连接(这数字看起来很多,但很多 BI 工具的连接池默认 50+)。
解决:FE 配置调大:
qe_max_connection = 5000同时在 BI 工具侧降低连接池(一般 10-20 足够)。
4. Unique Key 模型的删除性能问题。
Doris 的 Unique Key 模型删除走的是"标记删除 + 后台 compaction"——单次 DELETE 影响 100 万行会卡 compaction 10 分钟。
我们的解决方案:用DELETE FROM table WHERE order_id IN (...)配合分区裁剪,比如只删最近 3 天的脏数据:
DELETEFROMorder_logWHEREorder_idIN(123,456,789)ANDcreated_at>='2026-07-20';绝不要无分区条件的大批量 DELETE。
5. Flink 攒批太大反而延迟。
我们一开始设buffer(10000, 5s)——5 秒或 1 万条才写一次。结果 5 秒攒批让看板延迟从 1 秒变成 6 秒,业务方又来找我了。
调成buffer(5000, 1s)后,导入吞吐和延迟达到最佳平衡点——12 万/s 峰值导入,看板延迟稳定在 2 秒内。
效果对比
迁移前后关键指标对比:
| 指标 | ClickHouse 旧链路 | Doris 新链路 | 变化 |
|---|---|---|---|
| 峰值导入吞吐 | 8k/s(开始卡顿) | 12 万/s | +15x |
| 实时看板 P99 查询 | 1.4s | 180ms | -87% |
| 5 表 JOIN 改造 | 需大宽表 | 零改造 | — |
| 看板数据跳变 | 频繁 | 几乎为零 | — |
| FE/BE 资源占用 | 3 节点 ClickHouse | 3 FE + 6 BE | 持平 |
| 存储成本(3 个月热数据) | 1.2TB SSD | 0.8TB SSD + 1.5TB HDD | -25% |
最让我们意外的是看板跳变问题。之前业务方每天都反馈"数据不准",运营复盘时经常因为跳变数据误判趋势。Doris 的 merge-on-write 之后,运营再也没反馈过这个问题。
写在最后
ClickHouse 和 Doris 不是"谁取代谁"的关系——它们适合的场景不一样。
ClickHouse 更适合:
- 日志分析、用户行为分析
- 大宽表 + 复杂聚合查询
- 写入频率不高(分钟级或更低)
- 数据量特别大(PB 级)
Doris 更适合:
- 实时数仓(高频小批量写入)
- 多表 JOIN 频繁的实时报表
- 需要 MySQL 协议兼容(业务方学习成本低)
- 强一致的实时去重
这次迁移的教训:
- 没有银弹引擎——选型要按场景来,别被"ClickHouse 性能好"这种话术骗了
- Doris 的 merge-on-write 是杀手锏——别用默认的 Unique Key 模型,一定要打开
- Stream Load 的 label 一定要全局唯一——这是 Exactly-Once 的基础
- compaction 调优按表类型来——时序数据用 time_series,频繁更新用 size_based
如果你也在做实时数仓选型,强烈建议把 Doris 加进候选清单。它的 MySQL 兼容 + Stream Load + merge-on-write 这三个特性,组合起来是实时数仓场景下非常完整的方案。
—— 把 8k/s 干到 12 万/s 之后终于能睡个好觉的数仓工程师