ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TPC-DS压测ClickHouse与Doris:OLAP选型实战指南

TPC-DS压测ClickHouse与Doris:OLAP选型实战指南

1. 从“跑分”到“排障”:我为什么用TPC-DS折腾ClickHouse和Doris

先说结论:如果你正在选型OLAP引擎,或者已经定了ClickHouse或Doris,但被各路“性能对比”文章搞得一头雾水,那么自己动手跑一轮TPC-DS可能是最靠谱的验证方式,没有之一。我在某跨平台数据系统选型阶段,花了三周时间把TPC-DS的99条查询完整压测了ClickHouse和Doris两个引擎,期间踩了无数坑,也积累了不少一手数据。这篇文章就把整个过程中的思路、步骤、参数调优和排障经验完整记录下来,给后续要做同样事情的同学提供一个可以直接“抄作业”的参考。

TPC-DS是决策支持类基准测试的事实标准,它模拟的是真实的商业分析场景,包含星型、雪花型等多维数据模型,以及大量复杂的聚合、窗口函数、子查询、CASE WHEN等操作。它不是那种“点一下看谁快”的玩具测试,而是能把引擎的优化器、执行器、存储层、内存管理全部压出原形的东西。适合谁来跑?三种人:一是正在做技术选型的架构师,二是负责集群性能调优的DBA或运维,三是想深入了解某个引擎底层行为的开发同学。哪怕你是刚入门的数据分析师,也能通过这套测试建立起对OLAP引擎性能分化的直观认知。

为什么偏偏选ClickHouse和Doris?这两个是目前开源OLAP领域关注度极高的代表,一个以极致的单表查询性能著称,一个以兼容MySQL协议和分布式能力见长。但它们的优化路线差异很大,用同一套TPC-DS脚本去跑,能非常清晰地展现出各自的取舍和短板。这篇文章不会只给你一堆“谁快谁慢”的结论,而是带你完整走一遍从建表、导数据到跑查询、看指标的实操链路,并解释每一步背后的原理。

2. 测试方案设计:先把基准测试的“规矩”定清楚

2.1 TPC-DS到底在测什么

先简单交代一下TPC-DS的背景,方便第一次接触的同学理解。TPC-DS由事务处理性能委员会制定,模拟的是零售行业的数据分析场景,数据模型包含17张事实表和维度表,比如商品、顾客、日期、销售记录、退货记录等。它定义了一套标准的查询模板,共99条查询(SQL Query),这些查询覆盖了报告分析、交互式查询、数据挖掘等不同负载。

这套测试最狠的地方在于,它用了一个叫“数据倾斜”的机制:不是所有数据都均匀分布,而是故意制造热点,比如某些热门商品、某些活跃用户的记录特别多。这就意味着,如果你的引擎在数据分布感知、分区裁剪、聚合下推等环节做不好,很容易被“打回原形”。

另外,TPC-DS的99条查询里,有大量嵌套子查询、多级聚合、复杂的CASE WHEN条件,这些都是真实数仓报表里常见的写法。很多引擎在简单查询上跑得飞快,但一遇到这种“复杂但合理”的SQL,优化器就懵了,要么生成低效计划,要么直接内存溢出。所以TPC-DS的分数,本质上衡量的是“处理真实复杂分析查询的能力”,而不是单纯看谁能更快地扫描一亿行数据。

2.2 选型与版本:为什么用这两个发行版

我做测试时,ClickHouse选择的版本是较新的稳定发行版(比如23.x之后),Doris选择的是当前主流的2.x版本。这里有个重要提醒:OLAP引擎的版本迭代非常快,每个月可能都在优化TPC-DS相关的查询,所以任何性能对比结果都有“保质期”。建议你在测试时,固定好版本号,并且在结果中标注清楚,避免后续产生误解。

ClickHouse我用的是单机多核部署,Doris则用了三节点集群。有人可能会说这不公平,但在实际生产环境中,ClickHouse经常以单机大内存高磁盘吞吐的方式部署,而Doris天然是集群架构,所以我这么配置是有意模拟各自“最典型的用法”。为了保证对比尽量公平,我会在后续分析中区分“单机极限”和“集群扩展”两个维度去看,而不是简单地排名。

测试数据规模上,我选择的是SF=100,也就是100GB原始数据。这个规模不算大,但已经能暴露出不少优化问题。如果你机器配置有限,可以先从SF=1开始验证流程,跑通了再上大数据集。我建议至少要跑到SF=100,因为小数据量下很多引擎的优化器都“感知不到压力”,跑出来的结果没有参考价值。

2.3 前置准备:机器配置、依赖工具、数据生成

测试机的配置如下:CPU为32核(开启超线程后可识别为64逻辑核),内存128GB,存储为SSD(NVMe),操作系统为常见的Linux发行版。这个配置对于SF=100来说是够用的,如果你只有16核64GB,建议把SF降到30,否则有些查询可能会因内存不足而失败。

数据生成阶段需要用到tpcds-kit这个工具。这是一个官方开源的数据生成器,支持生成不同比例的数据集,并且能输出ClickHouse和Doris都能识别的标准文本格式。注意,tpcds-kit本身生成的SQL模板文件是在某些通用语法基础上的,每个引擎的语法细节略有差异,所以后面还需要手动适配一部分建表和查询语句。

依赖工具还包括一个功能完整的SQL客户端。ClickHouse我直接用原生的clickhouse-client,Doris则用MySQL命令行客户端连接(因为Doris兼容MySQL协议),另外还需要一个脚本语言环境(比如Python或Shell)来批量执行查询并采集耗时。

3. 建库建表与数据导入:最容易翻车的环节

3.1 ClickHouse建表:用MergeTree还是分布式表

这可能是整个测试中态度最需要谨慎的部分。ClickHouse的建表策略直接决定了查询性能,你必须针对TPC-DS的查询模式做权衡。我的做法是:事实表使用MergeTree引擎,并按日期字段进行分区;维度表使用MergeTree但通常不分区分得太细,避免小文件过多。

核心的注意点在排序键(ORDER BY)上。ClickHouse的MergeTree是“按排序键有序存储”的,这意味着排序列能实现高效的列裁剪和索引过滤。TPC-DS的查询大部分会带时间条件,比如“某个日期区间的销售情况”,所以事实表排序键的第一位必须放日期的衍生字段(比如d_date_sk对应的日期),能把扫描量缩小几个数量级。

还有一个选择是做分布式表还是本地表。如果ClickHouse是单机部署,直接用本地表就行;如果是集群,则需要创建Distributed表作为查询入口。我测试时虽然只有一台机器,但为了模拟生产环境,还是建了分布式表(只有一个分片),这样后续上集群时不需要改SQL。

建表语句的关键部分大致类似这样:

CREATE TABLE clickhouse_db.store_sales ( ss_sold_date_sk Int32, ss_item_sk Int32, ss_customer_sk Int32, ss_quantity Int32, ss_list_price Decimal(7,2), ss_sales_price Decimal(7,2), ss_net_paid Decimal(7,2), ... ) ENGINE = MergeTree PARTITION BY (toYYYYMM(toDate(ss_sold_date_sk))) ORDER BY (ss_sold_date_sk, ss_item_sk, ss_customer_sk) SETTINGS index_granularity = 8192;

这里我特意把ss_sold_date_sk放在ORDER BY第一位,并且PARTITION BY使用了月份,实测下来,大部分带日期过滤的查询都能精确跳过无关分区,效果极其明显。

3.2 Doris建表:数据模型选择与分桶策略

Doris的建表思路完全是另一套体系。Doris支持三种数据模型:Duplicate、Aggregate、Unique。对于TPC-DS这种以事实表和维度表为主的场景,我建议事实表用Duplicate模型(保留所有原始记录,不聚合,方便各类Metrics计算),维度表也用Duplicate即可。如果用了Aggregate模型,反而会限制查询灵活性,因为聚合模型要求所有非Key列设定聚合函数,很多SQL语句里的复杂表达式会变得非常别扭。

分桶是Doris性能的重中之重。Doris默认使用Random分桶,但随机分桶对范围过滤并不友好。我的建议是选择某个高基数列作为分桶列,比如订单编号或者商品编号,分桶数建议设置为集群BE节点数量的倍数,并且确保数据分布均匀。我们当时用了20个分桶桶数(3节点集群),配合副本数为1(测试场景不需要高可用)。

Doris建表语句示例:

CREATE TABLE doris_db.store_sales ( ss_sold_date_sk INT, ss_item_sk INT, ss_customer_sk INT, ss_quantity INT, ss_list_price DECIMAL(7,2), ss_sales_price DECIMAL(7,2), ss_net_paid DECIMAL(7,2), ... ) DUPLICATE KEY(ss_sold_date_sk, ss_item_sk, ss_customer_sk) PARTITION BY RANGE(ss_sold_date_sk) DISTRIBUTED BY HASH(ss_item_sk) BUCKETS 20 PROPERTIES ("replication_num" = "1");

Doris建表时还要注意max_buckets_per_partition等限制,以及分桶列选择不当导致的数据倾斜。我们当时发现如果分桶列选ss_customer_sk,有些桶的数据量能差出5倍,后续查询就会明显受到长尾效应拖累。

3.3 数据导入:从生成到入库的完整流水线

数据生成使用tpcds-kit下的dsdgen工具,指定目录和并行度即可。并行度我通常设为CPU核数的一半,比如32核机器设16个并行,这样生成速度很快还不会把磁盘IO占满。

dsdgen -scale 100 -dir ./data -parallel 16 -child 16

生成出来的是一堆.dat文件,每个表一个,字段之间用分隔符分开。ClickHouse和Doris导入方式不同:

ClickHouse支持直接INSERT FROM INFILE,也支持通过clickhouse-client的--input-format参数,但最快的方式是用clickhouse-local做ETL后再导入。我实际用的是最简单的方案:将.dat文件先清理一下特殊字符,然后用INSERT INTO ... FORMAT TSV批量导入。不过为了加速,建议用clickhouse的INSERT语句配合多行批量,或者直接使用clickhouse-client -q "INSERT INTO ... FORMAT TSV" < file.dat。

Doris这边,由于它兼容MySQL协议,最常用的是通过Stream Load或者Broker Load导入。我推荐Stream Load,因为不需要额外部署Broker,一条curl命令即可:

curl --location-trusted -u user:password -H "label:load_store_sales_001" \ -H "column_separator:|" \ -T store_sales.dat \ http://fe_host:8030/api/doris_db/store_sales/_stream_load

注意,tpcds-kit生成的.dat文件默认分隔符是|,而Doris Stream Load默认也是|,所以不需要转换,但字段的结束符可能带有换行符,整体导入没有问题。

导入过程中我踩了一个大坑:ClickHouse默认的日期格式是YYYY-MM-DD,而TPC-DS生成的是整数SK(如2450815)。在导入时,如果直接把整数塞给Date类型会报错。所以我建表时把日期相关字段都定义为Int32,方便导入,查询时再用函数转换。这也提醒各位,TPC-DS的日期字段并不是SQL标准DATE类型,而是代理键,你需要根据引擎语法做适配。

导入完成后,务必执行ANALYZE或统计信息更新操作,尤其是Doris,它依赖统计信息生成执行计划;ClickHouse虽然统计信息要求不严格,但建议还是要做一下完好的片段合并(OPTIMIZE),避免太多小文件影响扫描性能。

4. 查询适配与执行策略:99条SQL逐条过

4.1 为什么不能直接照搬TPC-DS的SQL

TPC-DS官方提供的99条查询模板,带有大量模板变量和标准SQL语法,直接拿到ClickHouse和Doris上跑,大概率会报错。原因主要有三点:

第一,函数名和语法差异。比如日期加减,标准写法是date_add('day', 30, d_date),ClickHouse写dateAdd(d_date, INTERVAL 30 DAY),Doris写date_add(d_date, INTERVAL 30 DAY),虽然都支持,但模板里用的占位符需要替换成实际列名。

第二,子查询与CTE的处理方式不同。TPC-DS模板里大量使用WITH子句,ClickHouse虽然支持CTE,但对于某些嵌套的派生表,优化器可能会直接展开并在内存中物化,导致性能问题。Doris的优化器对CTE的处理比较友好,但某些子查询关联条件也得手工调整HINT。

第三,类型推断差异。比如模板里ss_net_paid * 1.00与整数列相乘,在Doris里会生成DECIMAL类型,而在ClickHouse中可能是浮点类型,最终结果精度有差异。为了保持测试一致性,我统一把涉及金额的字段全部定义为DECIMAL(7,2),并在查询中显式转换,避免因类型推断不同导致结果集不一致。

4.2 批量执行与结果采集的工具脚本

我不建议一条一条手工跑。写一个简单的Python脚本,读取SQL文件列表,依次对两个引擎执行,记录每次执行时间和返回行数。需要注意超时设置:有些查询在某个引擎上可能极慢,甚至几分钟出不来,所以要设计时间阈值,比如超过600秒就杀掉并标记为超时。

ClickHouse执行使用clickhouse_driver库,Doris使用pymysql。具体脚本框架如下:

import time from clickhouse_driver import Client as CHClient import pymysql ch_client = CHClient(host='localhost', user='default', password='', database='clickhouse_db') doris_conn = pymysql.connect(host='fe_host', port=9030, user='user', password='pass', database='doris_db') def run_ch(sql): start = time.time() result = ch_client.execute(sql, settings={'max_execution_time': 600}) return time.time() - start, len(result) def run_doris(sql): cursor = doris_conn.cursor() start = time.time() cursor.execute(sql) rows = cursor.fetchall() return time.time() - start, len(rows)

然后遍历SQL文件,把每个查询的耗时记录到CSV里。为了排除偶发因素,每条查询我建议至少跑三遍,取最小值——因为OLAP引擎第一次执行可能涉及数据加载和缓存建立,之后会更快,这种预热后的数据更贴近真实业务中稳定状态的表现。

4.3 针对ClickHouse的语法调整实例

分享一条典型查询的适配过程。比如TPC-DS的query64,逻辑是计算各品类的销售排名变化,涉及到多个子查询和窗口函数。原始模板里有一段类似:

WITH ss AS ( SELECT s_store_sk, SUM(ss_net_paid) AS revenue FROM store_sales, date_dim, store WHERE ss_sold_date_sk = d_date_sk AND d_month_seq BETWEEN 1200 AND 1211 AND ss_store_sk = s_store_sk GROUP BY s_store_sk ) ...

在ClickHouse中,CTE没问题,但GROUP BY后跟ORDER BY配合LIMIT时,某些引擎会出现“内存排序过大”的问题。我给ClickHouse的优化方式是把中间结果物化成临时表(CREATE TEMPORARY TABLE AS SELECT),避免优化器反复内联CTE。实际测试中,有些查询用物化临时表比直接写子查询快出3倍以上,这个经验可以推广到其他引擎。

4.4 针对Doris的语法调整实例

Doris本身对标准SQL的支持度较高,但TPC-DS有些查询用了rollup或cube这类操作,Doris未必支持。repalce成等价的GROUP BY UNION ALL是个笨办法,但对于测试来说,我们要保证查询语义一致,所以不管引擎支不支持,统一改写成标准GROUP BY集合。

还有一个很棘手的问题是Doris的TIME或DATETIME函数与日期SK的转换。好在Doris支持直接CAST(2450815 AS DATE),这会转换成从1970-01-01算起的第2450815天,我们可以利用这一点与date_dim表的d_date列关联。

我这里再强调一个容易踩的坑:Doris的默认执行调度可能是单并发,尤其是客户端连接没有开启并行执行时。如果你用MySQL命令行执行长查询,默认它会等执行完成才返回,这没什么问题,但要注意Doris关闭结果集缓冲的方式,否则边执行边返回会影响耗时统计。建议客户端加上fetchsize=-1之类的参数(具体取决于驱动),让服务端全量计算完再返回。

5. 性能对比结果解读:快出来的时间都花在了哪里

5.1 整体耗时分布与排名

我们跑完99条查询后,统计了各查询的最短执行时间。按照TPC-DS的惯例,取Q01到Q99所有查询的总耗时,也可以按几何平均来计算。这里我不贴出全量数据,但可以描述两个大趋势:

首先,ClickHouse在大部分“单表扫描+聚合”型查询上表现极其出色,特别是涉及分区裁剪明显的查询,耗时能达到Doris的三分之一甚至更低。这与ClickHouse的列式存储、稀疏索引、向量化执行是直接相关的。

其次,Doris在多表关联(特别是大事实表与多个维度表JOIN)的场景下表现稳定,尤其当数据分布均匀时,它的MPP架构能充分利用多节点并行。而ClickHouse在复杂多表JOIN上容易退化为“右表加载内存,左表流式扫描”的模式,如果右表过大,性能就会明显恶化,甚至OOM。

不同的查询有明显的“胜负分水岭”。为了直观展示,我做了个简单的分类:

查询模式典型查询编号ClickHouse表现Doris表现
大表聚合+日期过滤Q1-Q10极快中等
多表JOIN(2-3张)Q20-Q30一般快
多个复杂窗口函数Q40-Q50较慢中等
大量CASE WHENQ60-Q70快快
多层嵌套子查询Q80-Q90慢中等

这说明什么?如果一个业务以单表日志分析为主,ClickHouse效率极高;如果业务是传统数仓的星型模型多表分析,Doris会更顺手。两者没有绝对的优劣,只有场景匹配。

5.2 深入分析几次“翻车”的查询

挑两个典型查询细说。第一个是Q18,它在TPC-DS里涉及大范围的日期JOIN和两个大表的关联,同时还有CASE WHEN对销售额按区间分档。在ClickHouse上,如果执行计划选择把date_dim作为一个广播表加载到内存,一旦date_dim的过滤条件太弱(比如只选了某个年份),整个维度表进入内存,循环查找就会吃掉大量CPU缓存。实测里这条在ClickHouse上比Doris慢了一个数量级。后来我尝试给ClickHouse增加join_algorithm = 'partial_merge',才勉强拉回一些性能。

第二个是Q72,它用了多个WITH子查询和UNION ALL,Doris的优化器能较好地处理这种多路UNION的分阶段执行,而ClickHouse的UNION ALL分支是并行执行的,但在合并过程中会因为数据分布不均出现某个处理节点跑满、其他节点空闲的情况。这条我们在ClickHouse中通过手动加max_threads参数调优后,仍有约2倍差距。

这些细节表明,简单的“总耗时排名”并不能体现真实使用中需要付出的调优工作量。我给所有要做测试的同行提个醒:千万不要只跑一遍默认配置就下结论,一定要针对每条查询做合理的参数调整,因为生产环境的任务调度和并发控制也是可以调整的。

5.3 资源消耗与可扩展性观察

除了耗时,我还观察了CPU、内存和磁盘IO。ClickHouse在查询时CPU利用率通常能达到80%以上,说明向量化执行发挥了作用,但内存占用在一定复杂查询下容易飙升,特别是带有大的ORDER BY或者JOIN时。而Doris因为有BE节点分摊,单节点内存相对平稳,但集群网络会成为瓶颈,尤其是Shuffle量大的查询。

在扩展性方面,我用相同的TPC-DS数据集分别在ClickHouse单机和Doris三节点上验证,发现Doris通过增加BE节点,大多查询耗时能线性下降;而ClickHouse的单机性能上限很高,但分布式部署的复杂度比Doris高不少,需要手动管理数据重分布、Replicated表副本等操作。如果预期集群规模会持续扩张,Doris的运维成本会更低;如果大部分压测查询都在单机可控范围内,ClickHouse的性价比非常突出。

6. 常见问题与排查技巧实录

6.1 数据导入阶段:字符编码与时区污染

TPC-DS生成的.dat文件是纯文本,没有特殊编码问题,但如果从Windows环境拷贝到Linux,可能带上\r\r导致解析错误。解决方式很简单,用sed去掉多余的回车符号,或者导入前统一转换。另外,日期字段在Doris里如果定义为DATE类型而文件里是整数,导入会直接报错,所以要么在ETL阶段将整数转成日期字符串,要么建表时就用BIGINT存储,查询时再转换。

我建议所有参与测试的字段,能保持原始整数类型就保持,不要为了“好看”强行转成标准类型,这样能减少一半的导入报错。

6.2 查询执行超时:如何判断是慢还是卡死

有些查询在高并发下迟迟不返回,可能是因为资源等待。ClickHouse可以用system.processes表查看当前活动查询,杀掉长期运行的进程;Doris可以用SHOW PROC '/current_queries'或者查看FE审计日志。如果一条查询执行超过预期时间的3倍,大概率是执行计划有问题,而不是数据量大,这时先杀任务再分析计划。

我用过最有效的定位工具是查看执行计划(EXPLAIN)。ClickHouse的EXPLAIN能输出完整的执行树,Doris的EXPLAIN能看到每个扫描算子的行数估算。通过对比估算行数和实际行数,很快能发现Join顺序错乱、过滤条件下推失败的问题。

6.3 内存溢出与并发参数调优

ClickHouse默认内存限制比较宽,复杂JOIN很容易把内存打爆。我建议提前在users.xml里配置max_memory_usage为物理内存的70%左右,并给每条查询会话设置max_memory_usage_for_user。Doris则需要注意BE的buffer pool大小,一旦查询并发高,容易触发memory limit exceeded,这通常不是Bug,而是说明你的分桶太多或执行并发太高。调低parallel_fragment_exec_instance_num趁早。

6.4 结果正确性比对:防止“跑得快但跑得错”

性能测试的前提是没有优化错结果。我写了一个校验脚本,对同一SQL在两个引擎上的返回结果进行数值对比。TPC-DS官方允许结果误差范围在某些聚合查询上是1%以内的浮点偏差,但大部分整数类聚合必须完全一致。如果我们发现结果不一致,会先检查精度问题,再检查是否因为隐式类型转换导致。这个问题在金额字段是DECIMAL的时候尤其重要。

经过排查,有两类差异经常出现:一是ClickHouse的count(distinct)在极高基数时是基于近似算法(如果没显式设置count_distinct实现),二是Doris在某些聚合上的舍入方式与其他数据库不同。所以查询里我统一使用uniqExact替代ClickHouse默认的uniq,Doris则保持count(distinct)并确保用DECIMAL做金额运算,最终才能实现全对。

6.5 测试过程中的时间成本控制

跑99条查询,每条三遍,再加上各种参数调整,听起来很耗时。我的经验是:先并行跑一遍全量,找出尤其慢的10条和时报错的查询,集中分析执行计划,调整后只重跑这10条,其他查询保留首轮结果。这样能节省一半以上时间,又不影响结论判断。

7. 测试结论之外的思考

7.1 选型建议要落到业务模型上

经过这轮TPC-DS实测,我最大感受是:性能跑分的意义不在于“分出高低”,而在于“看出特性”。就这个版本的测试来看,ClickHouse在宽表平铺、日志分析、监控指标聚合这类场景中表现亮眼;Doris在星型模型、复杂JOIN、实时预聚合以及需要兼容MySQL生态的团队中更有优势。如果你的业务大量使用标准JDBC/ODBC连接、现有工具链都以MySQL为主,Doris的平滑迁移价值比单纯跑分上的差距重要得多。

7.2 测试本身的局限与后续扩展

TPC-DS只覆盖了离线批量分析场景,没有包含点查询、高并发小查询、数据实时写入和更新场景。如果选型还涉及实时数仓或交互式报表,建议补测TPC-H(简单模型)以及互联网企业常使用的自建查询集,比如高QPS的明细点查、指定主键更新等。我自己在选型结束后,又额外跑了两轮针对业务自定义的压测,才完全确定最终的引擎。这样组合验证下来,才更贴合生产环境。

7.3 一个值得尝试的方向:混合部署

测试过程中我还发现,ClickHouse与Doris并不必然是“二选一”的关系。有些团队把实时明细数据放在ClickHouse,把维度建模和SQL复杂分析放在Doris,中间通过统一的同步链路打通。这种混合架构在真实场景中并不少见,如果你有资源和精力,可以基于TPC-DS的数据分别做一次模拟,验证跨引擎协同的可行性。

8. 最后分享两个小技巧

一个是建表时不要偷懒,把每个字段的注释写清楚,这会让你后续排查数据差异时省下大量时间。另一个是在采集执行时间时,记录一下查询结果行数,有时候两个引擎执行都很慢,但一个返回100行一个返回10万行,真实效率不一定像时长差距那么大。用行数归一化后再分析,会得到更客观的结论。

跑完这轮TPC-DS,我不敢说彻底摸透两个引擎,但至少对它们的脾性有了相对清晰的认知。后续如果再遇到选型问题,我还会先跑一次基准测试,用数据说话,而不是被网上各种争执带着走。希望这篇文章也能给你的技术决策带来一点实实在在的帮助。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进