ClickHouseInc

从 30 秒到 0.5 秒:chDB 如何把 Pandas 上的 SQL 查询做到极限性能?

图片

本文字数:6330;估计阅读时间:16 分钟

作者:Xiaozhe Yu Auxten Wang

Image

Image

问题:一个被序列化开销束缚的伟大引擎 

如果你曾在 Python 中处理数据,那一定对这种场景并不陌生:Pandas 几乎无处不在。它是数据科学领域的通用语言,贯穿了数据加载、清洗、分析和可视化的整个流程。然而,当数据集规模超过几百万行时,Pandas 就开始显得吃力了。单线程执行模式、极高的内存消耗,以及在复杂聚合场景下并不友好的语法,都会迅速演变成实际问题。

与此同时,ClickHouse 一直在那里——作为全球最快的开源 OLAP 引擎之一,随时准备大显身手。但在传统用法中,使用 ClickHouse 通常意味着需要部署服务器、将数据导入表中,并维护一整套数据库基础设施。当你只是想对一个 DataFrame 做一次快速聚合时,这样的成本显然过高。

正因如此,我们构建了 chDB:将 ClickHouse 封装为一个简单易用的 Python 库。只需一行 pip install chdb,即可开始使用。

不过,这里有个问题——我们的第一个版本其实隐藏着一个不小的性能隐患。

Image

每一次查询都会经历四次序列化和反序列化。其结果是:几乎所有查询的执行时间都超过 100ms,与查询复杂度无关。我们拥有世界上最快的查询引擎,却被数据转换的额外开销严重拖慢。

Image

愿景:用户真正想要的是什么

站在用户的角度,他们的需求其实非常直接:

“我有一个 DataFrame。我想在它上面执行 SQL。把结果以 DataFrame 的形式返回给我,而且要足够快。”

不需要临时文件。不需要格式转换。也不希望出现内存暴涨。只要原生、顺滑、无缝的集成体验。

具体来说,他们期待的是:

  • DataFrame 输入,DataFrame 输出 — 可以直接用 SQL 查询任意 Pandas DataFrame,并将结果返回为 DataFrame

  • 零配置 — 无需注册、无需定义 schema,只需在 SQL 中引用变量名

  • ClickHouse 级性能 — 多线程执行、向量化处理,充分发挥引擎优势

  • 内存高效 — 通过流式处理支持超出内存容量的数据集

这正是我们在 chDB v2 中所要实现的目标。

Image

我们构建的成果:真正的原生集成 

经过数月的持续投入,如今 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_val    FROM Python(the_df)    GROUP BY category    ORDER BY cnt DESC""", "DataFrame")print(result)

就是这么简单。无需注册,也不需要任何临时文件。DataFrame 变量 df 会被自动发现,并作为一张表直接使用。查询结果会以 DataFrame 的形式返回,可以立刻衔接后续的 Pandas 操作或可视化流程。

Image

这是一次巨大的性能飞跃——相比 v1.0 提升了 87 倍!但这还不是终点。

Image

幕后揭秘:技术实现之旅 

挑战 1:自动发现 DataFrame 

当你在 SQL 中写下 Python(df) 时,chDB 必须能够在 Python 运行环境中定位到这个变量。为此,我们设计了一套自动发现机制,用于:

  1. 在本地作用域和全局作用域中查找对应变量名

  2. 确认该变量确实是一个 Pandas DataFrame

  3. 解析其列名与数据类型

  4. 将其包装成一个 ClickHouse 表函数

整个过程无需任何显式注册——只要为 DataFrame 命名,即可直接使用。

Image

挑战 2:GIL 带来的限制 

真正的难点出现在这里。ClickHouse 是一个高度并行的引擎,天然希望充分利用所有 CPU 核心;而 Python 的全局解释器锁 (Global Interpreter Lock, GIL) 却限制了并发执行,一次只能允许一个线程运行。

如果每次访问数据都调用 Python 的 C API,多线程查询管线就会被迫退化为单线程执行。我们的解决思路是:在并行执行开始之前,尽量减少 CPython API 的调用,并将所有 Python 层面的交互提前批量完成。

Image

挑战 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! 🚀                                                │└─────────────────────────────────────────────────────────────────┘

Image

半结构化数据:不止于简单列 

在真实的数据场景中,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("""    SELECT        event.type,        event.metadata.x as x_coord,        event.metadata.y as y_coord    FROM Python(data)    WHERE event.type = 'click'""", "DataFrame")

Image

chDB 会对 object 列进行采样分析,识别其中的字典结构,并自动将其映射为 ClickHouse 原生的 JSON 类型——从而让你可以直接使用 ClickHouse 强大的 JSON 函数能力。

Image

性能表现:用数据说话

我们基于内存中的 DataFrame ClickBench 数据集(100 万行,Parquet 格式约 117MB),对 chDB 与 Pandas 原生操作进行了性能基准测试。

简单聚合:COUNT(*) 

chDB SQL 语句:

SELECT COUNT(*) FROM Python(df);

对应的 Pandas 操作:

df.count()

借助 ClickHouse 多线程查询执行引擎对多核 CPU 的充分利用,chDB 在执行 COUNT(*) 聚合时的速度大幅领先于 Pandas,整体性能提升接近 247 倍。

Image

复杂查询:GROUP BY + 多重聚合 

chDB SQL 语句:

SELECT    RegionID,    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 的原生实现。

Image

Image

流式处理:突破内存瓶颈

如果查询结果规模大到无法完全加载进内存,该怎么办?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 logic        print(chunk_df)# Close connectionconn.close()

Image

不再需要担心 OOM 错误,即使在笔记本电脑上,也可以处理 TB 级别的数据。

Image

v4.0:打通零拷贝的最后一环

在 v2.0 中,我们已经在输入侧实现了零拷贝——可以在无需序列化的情况下直接读取 DataFrame。但在输出侧,结果仍然需要经过 Parquet 的序列化过程。到了 chDB v4.0,这一限制被彻底消除。

Image

输出侧零拷贝的实现方式 

当 ClickHouse 生成查询结果时,我们不再将其序列化为 Parquet 再反序列化,而是采用以下方式直接返回结果:

  1. 直接类型映射:将 ClickHouse 的列类型直接映射为 NumPy 的 dtype

  2. 内存共享:在条件允许的情况下复用底层内存缓冲区

  3. 批量转换:通过 SIMD 优化的转换逻辑,将结果数据块直接转为 NumPy 数组

下面是我们实现的完整类型映射列表:

Image

基准测试: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 内存。从结果中可以清楚地看到以下结论:

  1. 在 Head/Limit 等切片操作中,Pandas 自身始终保持最快

  2. 随着数据规模的增长,chDB 和 DuckDB 的优势逐渐显现,在大多数 100 万行和 1000 万行测试中,chDB 处于领先位置

  3. 在多数测试场景下,chDB 与 DuckDB 的性能非常接近,而得益于 v4.0.0 的优化改进,在 1000 万行数据规模下,chDB 仍保持一定优势,整体表现相较 Pandas 与 DuckDB 约为 7:3

Image

基准测试及图表生成代码可在 github.com/auxten/chdb-ds 中查看。

Image

我们从起点走到现在

Image

Image

作为用户你能获得什么

下面简单总结一下 chDB 与 Pandas 深度集成后所带来的价值:

Image

Image

快速开始

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')

Image

加入社区

我们始终致力于持续改进 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]

图片图片