我把实时报表从 ClickHouse 迁到 Doris 后,导入吞吐从 8k/s 升到 12 万/s
2026/7/22 18:05:12 网站建设 项目流程

我把实时报表从 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

迁之前我们做了一圈调研,对比了三个候选:

引擎实时写入复杂 JOINMySQL 兼容运维成本
ClickHouse中(part merge 痛点)弱(大宽表)
Doris强(Stream Load + 主键模型)强(自适应 JOIN)强(MySQL 协议)
StarRocks

选 Doris 不是因为它最强,是因为Doris 的 MySQL 协议兼容 + Stream Load + Unique Key 模型刚好对上了我们的痛点

选 Doris 的三个核心理由:

  1. MySQL 协议兼容——业务方继续用 MySQL 客户端连,DBA 不用学新东西
  2. Stream Load HTTP 接口——Kafka 消费端可以直接 HTTP 推送,比 ClickHouse 的 Kafka 表灵活
  3. 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 ReplacingMergeTree5 分钟后 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_load

label 参数是关键——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_name

ClickHouse 上跑要 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.4s620ms
内存占用大宽表 8GB维表各 200MB

业务方第一次看到 620ms 的查询结果时,运营总监还以为我没跑完。之前 ClickHouse 要 1.4 秒,结果回到界面要等 1.5-2 秒,肉眼能感觉到卡顿。Doris 这边几乎是"刷一下就出"。


第四步:compaction 调优与冷热数据分层

Doris 也有 compaction 问题,但比 ClickHouse 好调得多。

compaction 调优的核心是compaction_policycumulative_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 PARTITIONSADMIN 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.4s180ms-87%
5 表 JOIN 改造需大宽表零改造
看板数据跳变频繁几乎为零
FE/BE 资源占用3 节点 ClickHouse3 FE + 6 BE持平
存储成本(3 个月热数据)1.2TB SSD0.8TB SSD + 1.5TB HDD-25%

最让我们意外的是看板跳变问题。之前业务方每天都反馈"数据不准",运营复盘时经常因为跳变数据误判趋势。Doris 的 merge-on-write 之后,运营再也没反馈过这个问题


写在最后

ClickHouse 和 Doris 不是"谁取代谁"的关系——它们适合的场景不一样

ClickHouse 更适合:

  • 日志分析、用户行为分析
  • 大宽表 + 复杂聚合查询
  • 写入频率不高(分钟级或更低)
  • 数据量特别大(PB 级)

Doris 更适合:

  • 实时数仓(高频小批量写入)
  • 多表 JOIN 频繁的实时报表
  • 需要 MySQL 协议兼容(业务方学习成本低)
  • 强一致的实时去重

这次迁移的教训:

  1. 没有银弹引擎——选型要按场景来,别被"ClickHouse 性能好"这种话术骗了
  2. Doris 的 merge-on-write 是杀手锏——别用默认的 Unique Key 模型,一定要打开
  3. Stream Load 的 label 一定要全局唯一——这是 Exactly-Once 的基础
  4. compaction 调优按表类型来——时序数据用 time_series,频繁更新用 size_based

如果你也在做实时数仓选型,强烈建议把 Doris 加进候选清单。它的 MySQL 兼容 + Stream Load + merge-on-write 这三个特性,组合起来是实时数仓场景下非常完整的方案。

—— 把 8k/s 干到 12 万/s 之后终于能睡个好觉的数仓工程师

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询