我把实时报表从 ClickHouse 迁到 Doris 后,导入吞吐从 8k/s 升到 12 万/s

📅 2026/7/22 18:05:30 👤 编程新知 🏷️ 技术资讯
我把实时报表从 ClickHouse 迁到 Doris 后,导入吞吐从 8k/s 升到 12 万/s 我把实时报表从 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 万/sP99 查询延迟从 1.4s 压到 180ms业务看板彻底没再卡过。今天把这次迁移讲清楚——不是 ClickHouse 不好是不同引擎有它适合的战场。背景为什么 ClickHouse 在我们这里水土不服先说清楚我们原本的架构。业务 MySQL主从 → Canal → Kafka → ClickHouseReplacingMergeTree ↑ Grafana 实时看板看似很标准问题出在三个地方。问题一高频小批量写入触发 part 风暴。ClickHouse 的存储模型是每次写入产生一个 part后台异步 merge。小批量1k 条/批高频每秒一批写入时part 数量会快速膨胀。我们高峰期看到后台有 800 个 part 在排队 mergemerge 占满了磁盘 IO。system.parts里直接报警SELECTcount()FROMsystem.partsWHEREtableorder_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 的三个核心理由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 KeyCREATETABLEorder_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_num3,enable_unique_key_merge_on_writetrue-- 关键开启 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.4sDoris Unique Key默认流畅偶尔跳变220msDoris Unique Keymerge-on-write流畅不跳变180ms差距最明显的是看板跳变——从 0 次/分钟到接近 0。这是业务方最关心的指标。第二步Stream Load 替代 Kafka 引擎表ClickHouse 的 Kafka 表让我们又爱又恨——配置简单但太脆。-- ClickHouse 旧配置已废弃CREATETABLEorder_log_kafka(order_id UInt64,user_id UInt64,...)ENGINEKafka()SETTINGS kafka_broker_listkafka:9092,kafka_topic_listorder_log,kafka_group_nameclickhouse_consumer,kafka_formatJSONEachRow;这套配置的问题ClickHouse 直接当 Kafka 消费者没办法做字段转换、过滤异常、关联维表。脏数据进来直接污染整张表。Doris 的 Stream Load 思路完全不同——Doris 只管怎么高效接收数据不管数据从哪来# Stream Load HTTP 接口最基础的用法curl--location-trusted-uroot:\-Hlabel: order_log_${date}_${uuid}\-Hcolumn_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 (简化版)publicclassDorisStreamLoadSinkextendsRichSinkFunctionString{privateHttpClienthttpClient;Overridepublicvoidinvoke(Stringvalue,Contextcontext)throwsException{// value 是一行 CSV 格式HttpPostpostnewHttpPost(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 默认每条数据触发一次 invoke8k/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 SQL4-5 张表 JOINSELECTdate_trunc(minute,o.created_at)ASminute,p.category_name,COUNT(DISTINCTo.order_id)ASorder_cnt,SUM(o.amount)ASgmvFROMorder_log oJOINuser_dim uONo.user_idu.user_idJOINsku_dim sONo.sku_ids.sku_idJOINpromotion_dim pONo.promo_idp.promo_idJOINstore_dim stONo.store_idst.store_idWHEREo.created_atNOW()-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_idu.user_idJOINsku_dim sONo.sku_ids.sku_idJOINpromotion_dim pONo.promo_idp.promo_idJOINstore_dim stONo.store_idst.store_idWHEREo.created_atNOW()-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_policy和cumulative_size_based_promotion_ratio-- 表级别 compaction 配置ALTERTABLEorder_logSET(compaction_policytime_series,-- 时序数据用 time_series 策略time_series_compaction_goal_size_mbytes1024,-- 单 segment 目标大小time_series_compaction_file_count_threshold2000);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));-- 历史分区放到冷盘HDDALTERTABLEorder_logMODIFYPARTITIONp202605SET(storage_mediumHDD,storage_cooldown_time2026-06-01 00:00:00);热数据最近 7 天放 SSD冷数据7 天自动滚动到 HDD。Doris 通过storage_cooldown_time自动迁移不用手动调度。踩坑记录迁移过程不是一帆风顺几个值得记下来的坑。1. Stream Load 的 label 重复导致数据丢失。我们一开始用时间戳生成 labellabel_${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 协议连 DorisDoris 的 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_at2026-07-20;绝不要无分区条件的大批量 DELETE。5. Flink 攒批太大反而延迟。我们一开始设buffer(10000, 5s)——5 秒或 1 万条才写一次。结果 5 秒攒批让看板延迟从 1 秒变成 6 秒业务方又来找我了。调成buffer(5000, 1s)后导入吞吐和延迟达到最佳平衡点——12 万/s 峰值导入看板延迟稳定在 2 秒内。效果对比迁移前后关键指标对比指标ClickHouse 旧链路Doris 新链路变化峰值导入吞吐8k/s开始卡顿12 万/s15x实时看板 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 协议兼容业务方学习成本低强一致的实时去重这次迁移的教训没有银弹引擎——选型要按场景来别被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 之后终于能睡个好觉的数仓工程师