从 30 秒到 0.5 秒:chDB 如何把 Pandas 上的 SQL 查询做到极限性能?
本文字数:6330;估计阅读时间:16 分钟
作者:Xiaozhe Yu Auxten Wang
如果你曾在 Python 中处理数据,那一定对这种场景并不陌生:Pandas 几乎无处不在。它是数据科学领域的通用语言,贯穿了数据加载、清洗、分析和可视化的整个流程。然而,当数据集规模超过几百万行时,Pandas 就开始显得吃力了。单线程执行模式、极高的内存消耗,以及在复杂聚合场景下并不友好的语法,都会迅速演变成实际问题。
与此同时,ClickHouse 一直在那里——作为全球最快的开源 OLAP 引擎之一,随时准备大显身手。但在传统用法中,使用 ClickHouse 通常意味着需要部署服务器、将数据导入表中,并维护一整套数据库基础设施。当你只是想对一个 DataFrame 做一次快速聚合时,这样的成本显然过高。
正因如此,我们构建了 chDB:将 ClickHouse 封装为一个简单易用的 Python 库。只需一行 pip install chdb,即可开始使用。
不过,这里有个问题——我们的第一个版本其实隐藏着一个不小的性能隐患。
每一次查询都会经历四次序列化和反序列化。其结果是:几乎所有查询的执行时间都超过 100ms,与查询复杂度无关。我们拥有世界上最快的查询引擎,却被数据转换的额外开销严重拖慢。
站在用户的角度,他们的需求其实非常直接:
“我有一个 DataFrame。我想在它上面执行 SQL。把结果以 DataFrame 的形式返回给我,而且要足够快。”
不需要临时文件。不需要格式转换。也不希望出现内存暴涨。只要原生、顺滑、无缝的集成体验。
具体来说,他们期待的是:
DataFrame 输入,DataFrame 输出 — 可以直接用 SQL 查询任意 Pandas DataFrame,并将结果返回为 DataFrame
零配置 — 无需注册、无需定义 schema,只需在 SQL 中引用变量名
ClickHouse 级性能 — 多线程执行、向量化处理,充分发挥引擎优势
内存高效 — 通过流式处理支持超出内存容量的数据集
这正是我们在 chDB v2 中所要实现的目标。
经过数月的持续投入,如今 chDB 的使用体验已经变成这样:
import pandas as pdimport chdb# Your data, wherever it comes fromthe_df = pd.DataFrame({'user_id': range(1_000_000),'category': ['A', 'B', 'C'] * 333334,'value': [i * 0.1 for i in range(1_000_000)]})# SQL on DataFrame — just reference the variable name!result = chdb.query("""SELECT category,COUNT(*) as cnt,AVG(value) as avg_valFROM Python(the_df)GROUP BY categoryORDER BY cnt DESC""", "DataFrame")print(result)
就是这么简单。无需注册,也不需要任何临时文件。DataFrame 变量 df 会被自动发现,并作为一张表直接使用。查询结果会以 DataFrame 的形式返回,可以立刻衔接后续的 Pandas 操作或可视化流程。
这是一次巨大的性能飞跃——相比 v1.0 提升了 87 倍!但这还不是终点。
挑战 1:自动发现 DataFrame
当你在 SQL 中写下 Python(df) 时,chDB 必须能够在 Python 运行环境中定位到这个变量。为此,我们设计了一套自动发现机制,用于:
在本地作用域和全局作用域中查找对应变量名
确认该变量确实是一个 Pandas DataFrame
解析其列名与数据类型
将其包装成一个 ClickHouse 表函数
整个过程无需任何显式注册——只要为 DataFrame 命名,即可直接使用。
挑战 2:GIL 带来的限制
真正的难点出现在这里。ClickHouse 是一个高度并行的引擎,天然希望充分利用所有 CPU 核心;而 Python 的全局解释器锁 (Global Interpreter Lock, GIL) 却限制了并发执行,一次只能允许一个线程运行。
如果每次访问数据都调用 Python 的 C API,多线程查询管线就会被迫退化为单线程执行。我们的解决思路是:在并行执行开始之前,尽量减少 CPython API 的调用,并将所有 Python 层面的交互提前批量完成。
挑战 3:Python 字符串编码的复杂性
Python 的 str 类型本身就非常复杂。它可能采用 UTF-8、UTF-16、UTF-32,甚至其他内部表示方式。若通过 Python API 将字符串转换为 ClickHouse 所需的 UTF-8 格式,就意味着每处理一个字符串都必须获取一次 GIL。
因此,我们采取了一种相当激进的做法:用 C++ 重新实现了 Python 的字符串编码逻辑。这样一来,字符串转换就可以在不触碰 GIL 的情况下并行完成。
最终效果非常显著。我们的测试查询 Q23 (SELECT * FROM hits WHERE URL LIKE '%google%' ORDER BY EventTime LIMIT 10) 的执行时间从 8.6 秒缩短到 0.56 秒——仅这一项优化,就带来了 15 倍的性能提升。
┌─────────────────────────────────────────────────────────────────┐│ String Encoding Performance Impact ││ ││ Q23 Query Time (seconds) ││ ││ Python API Encoding: ████████████████████████████████ 8.6s ││ C++ Native Encoding: ██ 0.56s ││ ││ 15x faster! 🚀 │└─────────────────────────────────────────────────────────────────┘
在真实的数据场景中,DataFrame 往往包含较为复杂的 object 列,其中可能嵌套着字典等结构化数据。chDB 可以自动应对这类情况:
import pandas as pdimport chdb# DataFrame with nested JSON-like datadata = pd.DataFrame({'event': [{'type': 'click', 'metadata': {'x': 100, 'y': 200}},{'type': 'scroll', 'metadata': {'x': 150, 'y': 300}},{'type': 'click', 'metadata': {'x': 200, 'y': 400}}]})# Query nested fields directly with ClickHouse JSON syntaxresult = chdb.query("""SELECTevent.type,event.metadata.x as x_coord,event.metadata.y as y_coordFROM Python(data)WHERE event.type = 'click'""", "DataFrame")
chDB 会对 object 列进行采样分析,识别其中的字典结构,并自动将其映射为 ClickHouse 原生的 JSON 类型——从而让你可以直接使用 ClickHouse 强大的 JSON 函数能力。
我们基于内存中的 DataFrame ClickBench 数据集(100 万行,Parquet 格式约 117MB),对 chDB 与 Pandas 原生操作进行了性能基准测试。
简单聚合:COUNT(*)
chDB SQL 语句:
SELECT COUNT(*) FROM Python(df);对应的 Pandas 操作:
df.count()借助 ClickHouse 多线程查询执行引擎对多核 CPU 的充分利用,chDB 在执行 COUNT(*) 聚合时的速度大幅领先于 Pandas,整体性能提升接近 247 倍。
复杂查询:GROUP BY + 多重聚合
chDB SQL 语句:
SELECTRegionID,SUM(AdvEngineID),COUNT(*) AS c,AVG(ResolutionWidth),COUNT(DISTINCT UserID)FROM Python(df)GROUP BY RegionIDORDER BY c DESCLIMIT 10
对应的 Pandas 操作:
df.groupby("RegionID").agg(AdvEngineID=("AdvEngineID", "sum"),ResolutionWidth=("ResolutionWidth", "mean"),UserID=("UserID", "nunique"),c=("RegionID", "size")).sort_values("c", ascending=False).head(10)
得益于 ClickHouse 查询执行引擎在 Group By 相关算子上的深度优化,在涉及“分组 + 排序 + 多重聚合”的复杂查询场景中,chDB 依然明显优于 Pandas 的原生实现。
如果查询结果规模大到无法完全加载进内存,该怎么办?chDB 提供了对结果集的流式处理支持:
import chdb# Initialize chDB connectionconn = chdb.connect()# Construct query (generate 500,000 rows of data)query = "SELECT number FROM numbers(500000)"# Stream query: retrieve results by block and processwith conn.send_query(query, "DataFrame") as stream_result:for chunk_df in stream_result:# Custom business logicprint(chunk_df)# Close connectionconn.close()
不再需要担心 OOM 错误,即使在笔记本电脑上,也可以处理 TB 级别的数据。
在 v2.0 中,我们已经在输入侧实现了零拷贝——可以在无需序列化的情况下直接读取 DataFrame。但在输出侧,结果仍然需要经过 Parquet 的序列化过程。到了 chDB v4.0,这一限制被彻底消除。
输出侧零拷贝的实现方式
当 ClickHouse 生成查询结果时,我们不再将其序列化为 Parquet 再反序列化,而是采用以下方式直接返回结果:
直接类型映射:将 ClickHouse 的列类型直接映射为 NumPy 的 dtype
内存共享:在条件允许的情况下复用底层内存缓冲区
批量转换:通过 SIMD 优化的转换逻辑,将结果数据块直接转为 NumPy 数组
下面是我们实现的完整类型映射列表:
基准测试:v4.0 输出性能
我们基于 ClickBench hits 数据集,对 chDB 在将查询结果导出为 Pandas DataFrame 时的性能进行了基准测试,并与另一款类似的嵌入式分析引擎 DuckDB 进行了对比。
测试环境
数据集:ClickBench hits 数据集 (100 万行,Parquet 格式,文件大小约 117MB)
硬件环境:AWS EC2 c6a.4xlarge 实例
测试方法:对同一条查询执行 3 次,取其中的最佳结果
对比场景:chDB 导出为 DataFrame vs. DuckDB 导出为 Pandas DataFrame
测试代码:
# chDB: Query Parquet file and export to Pandas DataFrameimport chdbchdb.query("SELECT * FROM file('hits_0.parquet')", "DataFrame")# DuckDB: Query Parquet file and export to Pandas DataFrameimport duckdbduckdb.query("SELECT * FROM read_parquet('hits_0.parquet')").df()
导出耗时
chDB:2.6418 秒
DuckDB:3.4744 秒
测试结果显示,在将 100 万行数据导出为 Pandas DataFrame 的场景下,chDB 的整体耗时相比 DuckDB 减少了约 24%,体现出更出色的数据转换效率。
面向日常 Pandas 使用场景的基准测试
为了更全面地展示能够直接读写 Pandas DataFrame 的不同库在实际使用中的性能差异,我们选取了 14 个常见操作,并在三种不同数据规模下,对 chDB、Pandas 和 DuckDB 进行了对比测试。测试硬件为 MacBook M4 Pro + 48G 内存。从结果中可以清楚地看到以下结论:
在 Head/Limit 等切片操作中,Pandas 自身始终保持最快
随着数据规模的增长,chDB 和 DuckDB 的优势逐渐显现,在大多数 100 万行和 1000 万行测试中,chDB 处于领先位置
在多数测试场景下,chDB 与 DuckDB 的性能非常接近,而得益于 v4.0.0 的优化改进,在 1000 万行数据规模下,chDB 仍保持一定优势,整体表现相较 Pandas 与 DuckDB 约为 7:3
基准测试及图表生成代码可在 github.com/auxten/chdb-ds 中查看。
下面简单总结一下 chDB 与 Pandas 深度集成后所带来的价值:
chDB v4 现已进入 beta 阶段!
欢迎大家试用 chDB v4 并向我们反馈使用体验。pip install "chdb>=4.0.0b2"
#pip install chdbimport pandas as pdimport chdb# Load your data however you wantdf = pd.read_csv("your_data.csv")# Query with SQLresult = chdb.query("""SELECT column_a, SUM(column_b)FROM Python(df)GROUP BY column_a""", "DataFrame")# Use the result with any Pandas-compatible toolresult.plot(kind='bar')
我们始终致力于持续改进 chDB,欢迎通过以下方式与我们交流:
📧 Email:[email protected]
💬 Discord:加入我们的社区
🐛 Issues:GitHub Issues
⭐ Star 我们:github.com/chdb-io/chdb
从 30 秒缩短到 0.5 秒,这段旅程并不轻松,但这仅仅是开始。你会用 chDB 构建怎样的应用?
/END/
试用阿里云 ClickHouse企业版
轻松节省30%云资源成本?阿里云数据库ClickHouse 云原生架构全新升级,首次购买ClickHouse企业版计算和存储资源组合,首月消费不超过99.58元(包含最大16CCU+450G OSS用量)了解详情:https://t.aliyun.com/Kz5Z0q9G
征稿启示
面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:[email protected]