直接说结论:为了给内部数仓选型拿数据,我花了快两周时间,用TPC-DS标准基准测试集把ClickHouse和Doris这两款OLAP引擎从头到尾测了一遍。这篇文章就是这次性能测试的完整复盘,覆盖为什么选TPC-DS、怎么生成数据、如何改造建表方言、99条SQL到底怎么跑,以及测试过程中踩过的各种坑。如果你正在为团队做OLAP选型,或者已经部署了其中一款但想知道它的性能边界,这份经验可以直接拿去做参考。
之所以强调“复盘”而不是“教程”,是因为这类测试最大的难点从来不是跑SQL,而是保证对比公平。同一个TPC-DS数据集,导入方式不同、建表参数不同、内存限制不同,出来的结果能差出好几倍。下面我把整个流程和判断逻辑都摊开讲清楚。
1. 为什么拿TPC-DS测ClickHouse和Doris,不选TPC-H
1.1 TPC-DS比TPC-H狠在哪
很多团队做OLAP选型时,第一反应是跑TPC-H。TPC-H确实经典,但它只有22条查询,事实表也就一张订单表加几个维度表,查询形态偏简单,大部分是单表聚合或两表join。说句不好听的,TPC-H的成绩在不少场景下可以用物化视图、预聚合甚至结果缓存来“刷”,压测出来的分数和真实业务负载相关性有限。
TPC-DS完全是另一个量级的测试。它模拟的是一个零售企业的整个决策支持系统,包含24张表——7张事实表加上17张维度表,覆盖门店销售、目录销售、网络销售三条完整链路,还有退货、库存、促销、客户画像等真实数仓里会出现的对象。99条SQL覆盖了报表统计、即席查询、ETL类操作,里面大量使用多表join、窗口函数、group by grouping sets、case when嵌套、in/exists子查询,还专门设计了数据倾斜和NULL值分布。
这意味着TPC-DS非常不适合“刷分”。预聚合和物化视图在TPC-DS面前只能解决一小部分查询,更多的查询是典型的星形甚至雪花形关联,查询优化器和执行引擎的能力会被直接暴露出来。所以要说“模拟真实数仓负载”,TPC-DS是目前最靠谱的开源基准了。
1.2 这次测试到底想回答什么问题
团队当时的情况是:业务方既要高并发大宽表的实时报表,又要支持数仓层多表关联的复杂分析。候选引擎缩到两款——ClickHouse和Doris。所以这次测试不是为了分出谁是“冠军”,而是要回答三个具体问题:
第一,多表join到底能不能打。这是ClickHouse常年被质疑的点,Doris作为MPP架构在这方面理论上有优势,但实际差距有多大,得用数据说话。
第二,单表聚合扫描谁更快。ClickHouse的看家本领是列存加向量化计算,Doris 2.x之后的向量化引擎也很成熟,两者在纯聚合场景下的差距,决定了实时报表类业务的选型方向。
第三,在正常配置下跑复杂查询会不会OOM、会不会超时、需不需要改写SQL。这一点最容易被忽略,但恰恰是生产环境最头疼的问题。
测试口径上我也做了约束:两边都用官方默认或推荐的配置,不改SQL语义,不手工加索引,不加物化视图。换句话说,测的是“开箱即用”的表现,而不是“调优后的极限性能”。极限性能当然有意义,但那应该是在确定某个引擎之后为生产环境再做的事。
2. 测试前先看清两类引擎的架构底牌
2.1 ClickHouse的宽表哲学与MergeTree引擎
在跑数据之前,有必要先搞清楚ClickHouse的设计逻辑。它的核心是MergeTree表引擎家族,数据按主键索引有序排列,配合列式存储和稀疏索引,扫描大宽表时只会读取需要的列,然后直接喂给向量化执行引擎。这种设计让它在“单表过滤+聚合+分组”这类查询上快得离谱,CPU利用率能做到很高。
但ClickHouse在join能力上确实不是传统数据库的思路。它早期版本的join使用内存哈希表,右表放不进内存就直接报错。虽然后续版本加了partial_merge等优化策略,可以把join的右表落盘,但执行效率相比真正MPP引擎还是有差距。更关键的是,ClickHouse分布式查询的默认逻辑是通过Distributed表把查询下推到每个分片,每个节点各自算自己的数据,最后汇总到协调节点。这种“本地计算+结果汇总”的模式,碰到需要跨节点数据重分布的join时就会比较吃力。
2.2 Doris的MPP执行引擎与分布式join
Doris走的是另一条路。它的架构更接近Impala、Presto这类MPP分析引擎,查询会由优化器生成分布式执行计划,数据按join key在节点间做shuffle,然后多个BE节点并行计算。这种模型天然适合多表关联,因为join可以在真正“分布式”的层面完成,而不是把数据拉到本地硬拼。
Doris还提供了几类针对join的优化手段,比如Colocate Join可以把相同分桶键的数据放在同一节点,避免网络shuffle;Bucket Shuffle Join允许小表以分桶粒度广播到大表所在节点。再加上Doris对标准SQL的支持非常完整,TPC-DS这类查询不需要做太多语法改造。
2.3 版本选择与集群配置
测试环境的参数选择会直接影响结果,这里详细列一下我当时的配置,方便读者对照。
整个测试用3台物理节点,每台32核64GB内存,万兆内网,SSD盘。ClickHouse部署的是当时最新的LTS版本(24.3系列),Doris用的是2.1.x社区版。两边都部署单副本,关闭数据复制,避免副本机制干扰查询性能。
| 配置项 | ClickHouse | Doris(社区版) |
|---|---|---|
| 节点数 | 3 | 3 |
| 每节点规格 | 32核64GB | 32核64GB |
| 数据副本数 | 1 | 1 |
| 部署方式 | 单机多分片(每个节点1个分片) | 1 FE + 3 BE |
| 数据规模因子 | SF=20 | SF=20 |
这里特别提醒:ClickHouse我把每个节点视为一个独立分片,建了分布式表来跑SQL;Doris则是标准的FE负责解析和调度,BE负责实际存储和计算。这样两边都是三节点并行扫描,对比才有意义。
提示:如果只是验证流程,SF=10甚至SF=1都够用;但如果要做认真的选型判断,初始规模建议至少SF=20,否则倾斜和shuffle特征不明显,看不出差距。
3. 从dsdgen到结果落盘——整套测试流程复盘
3.1 用dsdgen生成数据,注意并行度和文件格式
TPC-DS官方提供了数据生成工具dsdgen,这一步是整个流程里最容易踩坑的起点。命令行参数不复杂,但有几个关键点要先说清楚。
我用的规模因子是SF=20,生成命令如下:
# 先创建数据目录 mkdir -p /data/tpcds/sf20 # 8个并行进程分别生成不同表,CHILD从1到8 for i in $(seq 1 8); do dsdgen -SCALE 20 -PARALLEL 8 -CHILD $i -DIR /data/tpcds/sf20 & done waitdsdgen默认输出的是以“|”作为分隔符的文本文件。并行生成时,如果不用-PARALLEL和-CHILD参数,单进程生成长表会非常慢;但乱开并行也不行,表之间有外键关联,必须保证子任务一致。SF=20最终生成的原始文本大概20GB出头,但解压后或者导入后占用的空间更大,磁盘至少要留出三倍以上余量。
生成完先用wc -l统计一下每张表的行数,后续导入完成后要拿这个数字校验。这一步我一开始嫌麻烦跳过了,结果后来发现某个字段空串处理出错,数据行数对不上,排查了半天才发现是表之间行数本身就不同,没有基准线做参照很难定位问题。
3.2 建表改造:两种SQL方言的语法差异
TPC-DS官方会提供建表SQL,但它是PostgreSQL/DB2方言,ClickHouse和Doris都不能直接执行,必须手动改。
以store_sales这张核心事实表为例,ClickHouse建表如下:
CREATE TABLE tpcds.store_sales ( ss_sold_date_sk Nullable(Int64), ss_sold_time_sk Nullable(Int64), ss_item_sk Nullable(Int64), ss_customer_sk Nullable(Int64), ss_cdemo_sk Nullable(Int64), ss_hdemo_sk Nullable(Int64), ss_addr_sk Nullable(Int64), ss_store_sk Nullable(Int64), ss_promo_sk Nullable(Int64), ss_ticket_number Nullable(Int64), ss_quantity Nullable(Int32), ss_wholesale_cost Nullable(Decimal(7,2)), ss_list_price Nullable(Decimal(7,2)), ss_sales_price Nullable(Decimal(7,2)), ss_ext_discount_amt Nullable(Decimal(7,2)), ss_ext_sales_price Nullable(Decimal(7,2)), ss_ext_wholesale_cost Nullable(Decimal(7,2)), ss_ext_list_price Nullable(Decimal(7,2)), ss_ext_tax Nullable(Decimal(7,2)), ss_coupon_amt Nullable(Decimal(7,2)), ss_net_paid Nullable(Decimal(7,2)), ss_net_paid_inc_tax Nullable(Decimal(7,2)), ss_net_profit Nullable(Decimal(7,2)) ) ENGINE = MergeTree ORDER BY (ss_sold_date_sk, ss_item_sk, ss_ticket_number) SETTINGS index_granularity = 8192;有两点必须说明。第一,TPC-DS的文本文件里空值是以空字符串形式出现的,所以事实表几乎全部字段要用Nullable包装,否则导入阶段就会报类型错误,或者更糟——导入成功但空值变成0,后面聚合结果完全失真。第二,ORDER BY非常关键,它同时也是主键索引,要根据最常用的过滤字段选,我选了日期sk加上商品sk,这样查询过滤日期范围内数据时能跳过大量granule。
Doris建表逻辑完全不同,看这个例子:
CREATE TABLE tpcds.store_sales ( ss_sold_date_sk BIGINT, ss_sold_time_sk BIGINT, ss_item_sk BIGINT, ss_customer_sk BIGINT, ss_cdemo_sk BIGINT, ss_hdemo_sk BIGINT, ss_addr_sk BIGINT, ss_store_sk BIGINT, ss_promo_sk BIGINT, ss_ticket_number BIGINT, ss_quantity INT, ss_wholesale_cost DECIMAL(7,2), ss_list_price DECIMAL(7,2), ss_sales_price DECIMAL(7,2), ss_ext_discount_amt DECIMAL(7,2), ss_ext_sales_price DECIMAL(7,2), ss_ext_wholesale_cost DECIMAL(7,2), ss_ext_list_price DECIMAL(7,2), ss_ext_tax DECIMAL(7,2), ss_coupon_amt DECIMAL(7,2), ss_net_paid DECIMAL(7,2), ss_net_paid_inc_tax DECIMAL(7,2), ss_net_profit DECIMAL(7,2) ) DUPLICATE KEY(ss_sold_date_sk, ss_item_sk, ss_ticket_number) DISTRIBUTED BY HASH(ss_item_sk) BUCKETS 24 PROPERTIES ("replication_num" = "1");Doris这边不需要把每个字段都设成Nullable,导入时空字符串按NULL处理,且默认模型不用Nullable反而扫描性能更好。但分桶键和分桶数必须提前想好,DISTRIBUTED BY HASH的字段直接影响join时是否可以做Colocate优化。我选了ss_item_sk作为分桶键,因为TPC-DS里大量join和filter都围绕商品id展开。分桶数我设24,约等于每节点8个桶,这个值在SF=20下每个桶数据量相对均衡。
3.3 数据导入:Stream Load与clickhouse-client的差异
数据导入方式两边差别很大,这也是测试过程中对比最明显的环节之一。
ClickHouse这边我用clickhouse-client直接加载.dat文件。由于文件是竖线分隔,格式需要做一次转换。最稳妥的方式是先用sed把分隔符转为Tab,再把空字符串统一处理成\\N(ClickHouse的NULL文本表示),然后以TabSeparated格式导入:
# 以store_sales为例 sed 's/|/\t/g; s/\t\t/\\N\t/g; s/\t$/\\N/' store_sales_1.dat > store_sales.tsv clickhouse-client --query "INSERT INTO tpcds.store_sales FORMAT TabSeparated" < store_sales.tsv注意store_sales有几千万行,sed处理大文件会比较慢,直接用clickhouse-local处理也可以,但写起来麻烦一点。导入几千行小表很快,大表每张耗时几分钟到十几分钟不等。全部导入完成后,用之前wc -l统计的行数逐个核对,保证一张不少。
Doris导入推荐用Stream Load,直接把竖线分隔文件交给BE处理,不需要做任何转换:
curl --location-trusted -u root: \ -H "label:store_sales_001" \ -H "column_separator:|" \ -H "columns:ss_sold_date_sk,ss_sold_time_sk,...,ss_net_profit" \ -T store_sales_1.dat \ http://127.0.0.1:8030/api/tpcds/store_sales/_stream_loadStream Load的label是幂等标识,乱码或导入失败后必须换新label重新提交。导入大表时建议把文件切成多个分片并行提交,Doris BE节点越多,导入并行度越高。整体下来Doris的导入速度比ClickHouse快了不少,这跟两者导入架构有关,但不作为本次性能对比的指标。
3.4 跑99条SQL:脚本化执行是唯一正确做法
99条查询不可能一条条手动跑。我把官方查询SQL做了语法适配,全部放到文件夹里,写了一个简单的shell脚本循环执行。
for q in $(seq -w 1 99); do sql_file="queries/q${q}.sql" [ -f "$sql_file" ] || continue echo "Running Q${q} on ClickHouse" start=$(date +%s%N) clickhouse-client --database tpcds --time \ --max_execution_time=600 < "$sql_file" \ >/dev/null 2>>ch_errors.log end=$(date +%s%N) echo "Q${q}: $(( (end - start) / 1000000 )) ms" >> ch_results.txt done这里有几个很关键的细节:
第一,每条查询在测试前一定要确认它“真的执行了”,而不是语法错误导致秒退。脚本里把每次超时和错误都追加到错误日志,跑完后先看错误日志再分析结果。
第二,必须记录冷缓存和暖缓存两个值。准确做法是先重启服务,再跑第一次,记录冷缓存结果;再跑第二次记录暖缓存结果。我这次因为ClickHouse重启成本较高,实际是对每条查询先执行一次预热,然后取第二次耗时,但必须在报告里标注清楚。
第三,超时设置要合理。有些复杂查询确实会跑几分钟,如果设成60秒,一批查询会全部超时,看不到真实情况。我设了600秒,但同时也用max_memory_usage做内存护栏,防止个别查询把机器打爆。
这套脚本改一下连接参数就能跑Doris版本。Doris这边用mysql客户端执行:
mysql -h fe_host -P 9030 -u root -D tpcds < queries/q01.sql4. 99条SQL跑完,哪些查询拉开了差距
4.1 总体耗时对比:没有全面碾压,只有错位竞争
先看整体汇总。在我这套3节点SF=20的环境下,99条查询全部跑完:ClickHouse的几何平均耗时约8.2秒,Doris约5.6秒(含首次冷缓存)。单看这两组数字,Doris整体占优,但拆开看会发现差距完全来自不同查询类型。
从我记录的耗时分布来看,这个差异基本符合预期。这里面的关键启发是:如果业务以单表聚合为主,ClickHouse优势明显;如果业务是多表关联分析,Doris明显更稳。两者不存在“谁全面碾压谁”的结论。
4.2 单表聚合与扫描:ClickHouse的统治区
举个例子,Q1这类查询就是典型的“在store_sales上做简单的筛选和sum聚合”。ClickHouse执行耗时不到1秒,Doris大约在2秒上下。这不是说Doris慢,而是ClickHouse在这个场景上确实做到了极致。
内部原因是ClickHouse的MergeTree在扫描大宽表时非常“挑食”。它只需要读ss_quantity、ss_sales_price等少数几列,列式存储加上稀疏索引扫描数据量极低,向量化引擎对这几列做sum时CPU缓存利用率非常高。Doris虽然也是列存加向量化,但它的路由和调度是标准的MPP执行,算子初始化开销更大,在这类简单的扫描聚合上反而不占优。
4.3 复杂多表join:Doris的扳回局
TPC-DS里真正拉开差距的是那些join了五张以上表的查询,比如Q3、Q7、Q18这类。这类查询通常以门店销售为核心,关联日期、商品、客户、地址等多张维度表,还有子查询嵌套。在我这次测试里,Doris在这类查询上普遍比ClickHouse快两倍左右,而且执行稳定。
ClickHouse的问题出在join执行模型上。它的分布式表join会先把右表拉到本地构建哈希表,节点间数据无法在join key上进行分布式shuffle。当右表行数大、join字段分布不均匀时,某些节点的内存压力就会很高。TPC-DS的表设计本身带倾斜,ss_item_sk的分布并不是完全均匀的,这会让ClickHouse出现“个别节点算得慢、全查询等它”的情况。
Doris的MPP优化器会自己做join重排,优先把小表广播,大表和大表之间做shuffle join。再加上TPC-DS的维表通常比较小,Doris可以直接把它们broadcast到BE节点上,join的中间结果不会在单一节点累积。这是架构上的优势,不是简单靠调参能追回来的。
4.4 从执行计划看根因
跑完结果后,我分别看了几条差距最大查询的执行计划。ClickHouse的EXPLAIN会显示类似ReadFromMergeTree和Expression等阶段,Join操作基本是本地完成;Doris的EXPLAIN会显示EXCHANGE节点,shuffle的规模一目了然。看到这一步,结果归因就非常清楚了:差距本质来自分布式join调度能力,而不是CPU或磁盘速度。
如果想要验证这个判断,可以做一个对比实验:把TPC-DS的宽表都预join成一张大宽表,再跑同一组SQL。这种情况下ClickHouse反而会反超Doris,这就是“宽表哲学”的威力。但这个实验也反过来证明,选型时不能只看单条SQL快不快,而要看团队是否愿意为某个引擎改建模方式。
5. 结果不光彩?先查这几处会骗人的配置
5.1 缓存开关:首跑和次跑的温差能有多大
测试结果如果不符合预期,先别急着否定引擎,第一件事查缓存。
ClickHouse有uncompressed_cache和mark cache,Doris也有page cache,第二次跑同一条SQL时,很多数据块已经缓存在内存里,耗时会比冷启动快很多。我在预热阶段就亲眼看到同一条Q5,第一次跑了2.8秒,第二次只有0.6秒。如果拿暖缓存成绩去对比冷启动成绩,结果没有任何参考价值。
生产实践建议:要么统一记录冷缓存成绩(重启服务后立刻跑),要么统一记录二次暖缓存成绩。做选型对比时,我更倾向用冷缓存成绩,因为它更接近真实生产环境里“第一次遇到新查询”的情况。
5.2 内存参数:OOM和落盘策略会改变一切
跑复杂查询时最崩溃的事情是执行到一半OOM。ClickHouse默认max_memory_usage是0,表示不限制,等于可以吃到机器全部内存。在TPC-DS这种多表join场景下,这是灾难级的隐患。
我建议测试前必须设置几个关键参数:
-- ClickHouse限制内存与join落盘 SET max_memory_usage = 50000000000; SET max_bytes_in_join = 20000000000; SET join_algorithm = 'partial_merge';partial_merge的作用是当右表太大无法放入内存时,改用磁盘归并join。这会让join慢一些,但至少不会把节点搞挂。Doris这边对应的参数是exec_mem_limit,我设为16GB。实测过程中有两条查询在默认配置下直接让ClickHouse的内存冲到接近60GB,强制杀掉后设置落盘参数才能继续跑。
5.3 NULL和空串处理:导入数据的一致性
这是最容易出“脏数据”的环节,而且出错极其隐蔽。dsdgen生成的文本里,NULL是以空字符串出现的。如果导入时没有正确转换成NULL,分析查询时就会出现两种情况:一种是聚合函数自动忽略空串和NULL的差异不明显,但一旦join字段是空串就比较致命,匹配不到任何维度表;另一种是有些查询显式用了IS NULL或IS NOT NULL过滤,空串会让结果行数完全错误。
ClickHouse的导入我统一把空串转成\\N,同时字段全部声明Nullable;Doris这边相对省心,Stream Load默认会把空字符串识别成NULL。导入后一定要做行数和关键字段交叉统计,不要直接开跑。
5.4 并发与超时参数
最后检查一下并发参数。测试时我默认给每条查询开满了线程池,这对ClickHouse来说很危险。它的默认max_threads是每个查询用满CPU核数,如果再叠加几个并发,CPU直接饱和,各查询互相拖慢。
建议测试前期用单并发串行跑一遍全量SQL,先拿到基准线;后续要做并发量测试时再逐步增加并发数。每条查询的超时时间也要设一个合理的值,对于TPC-DS这种复杂查询,我建议至少10分钟,否则不少查询会“假失败”,拖慢整个测试进度。
写在最后
这轮TPC-DS测试跑完,我最大的体会是:基准测试的价值在于暴露引擎的能力边界,而不是简单地给引擎排名。ClickHouse适合以单表宽表为核心的报表和实时分析场景,Doris在多表关联分析、标准SQL兼容性上更省心。选型不是看谁的总分高,而是看你的业务SQL里,到底是“聚合多还是join多”。
最后分享一个我后来一直在用的技巧:把这次的数据生成脚本、建表SQL、查询执行脚本全部留档,放在一个独立目录里。每次ClickHouse或Doris大版本升级、或者换了新硬件、改了集群规模,就花半天时间重跑一遍。TPC-DS的SQL模板和数据集都是公开的,这套东西相当于给集群做一次免费的“体检”,返回值不是绝对性能,而是版本变化带来的相对差异。这个习惯帮我抓到过好几次版本升级导致的性能回退,比看发布说明里的优化列表靠谱得多。