DeepSeek总结的chDB开发历程
原文地址:https://clickhouse.com/blog/chdb-embedded-clickhouse-rocket-engine-on-a-bicycle
chDB - 自行车上的火箭发动机
作者:@Auxten
发布时间:2023年9月29日 · 阅读时间约13分钟
注:这篇客座博客最初发布在 Auxten 的个人博客上,并根据近期发展进行了更新。
引言
在正式开启 chDB 的旅程之前,我认为最好先简要介绍一下 ClickHouse。近年来,“向量化引擎”在 OLAP 数据库领域特别流行。主要原因在于 CPU 中添加了越来越多的 SIMD 指令,极大地加速了 OLAP 场景下大量数据的聚合、排序和连接操作。ClickHouse 在“向量化”等多个方面进行了非常细致的优化,从它对 lz4 和 memcpy 的优化中就能窥见一斑。
如果关于 ClickHouse 是否是性能最佳的 OLAP 引擎还存在争议,那么至少根据基准测试,它属于顶级梯队。除了性能,ClickHouse 还拥有强大的功能,使其成为数据库领域的“瑞士军刀”:
- 直接查询存储在 S3、GCS 等对象存储上的数据。
- 使用 ReplacingMergeTree 简化变化数据的处理。
- 无需依赖第三方工具即可完成跨数据库数据查询甚至表连接。
- 甚至能自动进行谓词下推。
开发和维护一个生产就绪且高效的 SQL 引擎需要人才和时间。作为顶级的 OLAP 引擎之一,Alexey Milovidov 及其团队已经为 ClickHouse 的开发奉献了 14 年。既然 ClickHouse 已经在 SQL 引擎上做了这么多工作,为什么不考虑将其引擎提取出来作为一个 Python 模块呢?这感觉就像给自行车装上了火箭发动机!
2023 年 2 月,我开始开发 chDB,主要目标是使强大的 ClickHouse 引擎能够作为一个“开箱即用”的 Python 模块。ClickHouse 已经有一个名为 clickhouse-local 的独立版本,可以从命令行独立运行;这使得 chDB 的实现更加可行。
黑客视角:深入 ClickHouse
将 ClickHouse 嵌入 Python 模块有一个非常简单直接的实现方法:直接将 clickhouse-local 二进制文件包含在 Python 包中,然后通过类似 popen 的方式将 SQL 传递给它,并通过管道检索结果。
[此处应为一张描述该简单方案的示意图]
然而,这种方法带来了几个问题:
- 为每个查询启动一个独立进程会极大地影响性能,尤其是当
clickhouse-local二进制文件大小约为 500MB 时。 - SQL 查询结果不可避免地会产生多次拷贝,因为我们需要从管道读取,再复制到 Python 进程的缓冲区中。
- 与 Python 的集成受限,使得实现 Python UDF 和支持 Pandas DataFrame 上的 SQL 变得困难。
- 最重要的是,这种方法缺乏“优雅”感。
得益于 ClickHouse 结构良好的代码库,我成功地在春节期间,一边吃饭一边研究 ClickHouse 近 90 万行代码,最终创建出了一个原型。
ClickHouse 包含一系列称为 BufferBase 的实现,包括 ReadBuffer 和 WriteBuffer,它们大致对应于 C++ 的 istream 和 ostream。为了高效地从文件读取和输出结果(例如,读取 CSV 或 JSONEachRow,并输出 SQL 执行结果),ClickHouse 的 Buffer 也支持对底层内存的随机访问。它甚至可以基于一个向量创建新的 Buffer,而无需拷贝内存。ClickHouse 内部使用 BufferBase 的派生类来读写压缩文件以及远程文件(S3, HTTP)。
为了在 ClickHouse 层面实现零拷贝检索 SQL 执行结果,我使用了内置的 WriteBufferFromVector 代替 stdout 来接收数据。这确保了并行输出流水线不会被阻塞,同时方便地获取 SQL 执行输出的原始内存块。
为了避免从 C++ 到 Python 对象的内存拷贝,我利用 Python 的 memoryview 进行直接内存映射。
[此处应为一张描述 Pybind11 绑定的示意图]
由于 Pybind11 的成熟,现在可以轻松地将 C++ 类的构造和析构与 Python 对象的生命周期绑定。只需一个简单的类模板定义即可实现:
class __attribute__((visibility("default"))) query_result {
public:
query_result(local_result * result) : result(result);
~query_result();
}
py::class_<query_result>(m, "query_result")
就这样,chDB 迅速启动并运行起来,我非常兴奋地发布了它。chDB 的架构大致如下图所示:
[此处应为 chDB 的架构图]
团队集结
最初,我开发 chDB 的唯一目的是创建一个可以在 Jupyter Notebook 中独立运行的 ClickHouse 引擎。这样,在使用 Python 训练 CV 模型时,我就能轻松访问大量的标注信息,而无需依赖缓慢的 Hive 集群。令人惊讶的是,chDB 的独立版本在大多数场景下实际上优于由数百台服务器组成的 Hive 集群。
[此处应为 chDB 开发相关的示意图]
chDB 发布后,来自 QXIP 的 Lorenzo 迅速联系了我。他提了一个 issue,建议移除对 AVX2 指令集的依赖,这样 chDB 在 Lambda 服务上运行会更方便。我很快就实现了这个功能,之后 Lorenzo 在 fly.io 上为 chDB 创建了一个 demo。老实说,我之前从未想过这样的用法。
随后,Lorenzo 和他的团队为 chDB 开发了 Golang、NodeJS 和 Rust 的绑定。为了将这些项目整合在一起,我在 GitHub 上创建了 chdb.io 组织。
是的!我们还在 Linux 上为 Bun 提供了一个实验性的 chDB FFI 绑定。
[此处应为 chDB Bun 绑定的相关截图]
之后,@laodouya 为 chDB 贡献了 Python DB API 2.0 接口的实现。@nmreadelf 为 chDB 增加了对 DataFrame 输出格式的支持。朋友们如 @dchimeno、@Berry、@Dan Goodman、@Sebastian Gale、@Mimoune、@schaal 和 @alanpaulkwan 也为 chDB 提出了许多宝贵的问题。
共享对象中的 Jemalloc
chDB 有很多性能优化,其中包括将 jemalloc 移植到 chdb 的共享库 (.so) 中这一极具挑战性的任务。
在仔细分析 chDB 在 ClickBench 基准测试中的表现后,发现在 Q23 查询上,chDB 和 clickhouse-local 之间存在显著的性能差距。我们认为这种差异是由于实现 Q23 时,chDB 为了简化而移除了 jemalloc 造成的。那么,我们是如何解决的呢?
ClickHouse 引擎包含数百个子模块,包括 Boost 和 LLVM 这样的重量级库。为了确保良好的兼容性并实现 JIT 执行引擎,ClickHouse 与其自己的 LLVM 版本的 libc 和 libc++ 静态链接。这样 ClickHouse 的二进制文件可以轻松保证整体的链接安全性。然而,对于作为共享对象 (.so) 的 chDB 来说,这部分变得异常棘手,原因如下:
- Python 运行时环境有它自己的
libc。在加载chdb.so后,原本应该在 ClickHouse 二进制文件中链接到 jemalloc 的许多内存分配和管理函数,将不可避免地通过@plt连接到 Python 内置的libc。 - 为了解决上述问题,一个方案是修改 ClickHouse 源代码,使所有相关函数都显式地以
je_前缀调用,例如je_malloc、je_free。但这种方案会带来两个新问题。其中一个问题可以比较容易地解决:修改第三方库中调用malloc的代码将是一个巨大的工程。相反,我在使用clang++链接时使用了一个技巧:-Wl,-wrap,malloc。例如,在链接阶段,所有对malloc符号的调用都被重定向到__wrap_malloc。您可以参考 chDB 中的这段代码:mallocAdapt.c。
看起来问题已经解决了,但真正的噩梦才刚刚浮现。chDB 仍然在一些je_free调用上偶尔崩溃。经过不懈的调查,最终发现这是一个古老的libc遗留问题:
当编写 C 代码时,malloc/calloc 通常与 free 成对出现。我们会尽量避免在函数内部返回由 malloc 在堆上分配的内存。因为这很容易导致调用者忘记调用 free,从而造成内存泄漏。
然而,由于历史遗留问题,GNU libc 中有某些函数,如 getcwd() 和 get_current_dir_name(),它们在内部调用 malloc 来分配自己的内存并返回它。
而这些函数在 STL 和 Boost 等库中被广泛用于实现路径相关的函数。因此,我们会遇到这样的情况:getcwd 返回由 glibc 的 malloc 分配的内存,但我们却试图用 je_free 释放它。于是…… 崩溃!
[此处应为描述内存分配与释放问题的示意图]
理想情况下,jemalloc 应该提供一个接口,用于查询某个指针指向的内存是否由 jemalloc 分配。我们只需要在调用 je_free 之前像下面这样检查即可。
void __wrap_free(void * ptr)
{
int arena_ind;
if (unlikely(ptr == NULL))
{
return;
}
// 在某些 glibc 函数中,返回的缓冲区是由 glibc malloc 分配的
// 所以我们需要用 glibc free 来释放它。
// 例如 getcwd,参见:https://man7.org/linux/man-pages/man3/getcwd.3.html
// 因此我们需要检查缓冲区是否由 jemalloc 分配
// 如果不是,则需要用 glibc free 释放它
arena_ind = je_mallctl("arenas.lookup", NULL, NULL, &ptr, sizeof(ptr));
if (unlikely(arena_ind != 0)) {
__real_free(ptr);
return;
}
je_free(ptr);
}
但不幸的是,当使用 arenas.lookup 查询非 jemalloc 分配的内存时,jemalloc 的 mallctl 可能会在断言上失败……
查找操作会导致断言失败?这显然不理想,所以我向 jemalloc 提交了一个补丁:#2424 Make arenas_lookup_ctl triable。官方仓库已经合并了这个 PR。因此,我现在也成了 jemalloc 的贡献者之一。
展示时刻
经过对 ClickHouse 和 jemalloc 数周的努力,chDB 的内存使用量显著降低了 50%。
[此处应为 chDB 内存使用量对比图]
根据 ClickBench 的数据,chDB 是目前最快的无状态和无服务器数据库(不包括 ClickHouse Web)。
[此处应为 ClickBench 基准测试结果图]
chDB 是目前最快的 Parquet SQL 查询实现(DuckDB 的实际性能是在耗时 142 到 425 秒的“加载”过程之后达到的)。
[此处应为 chDB 与 DuckDB 性能对比图]
近期工作
随着 chDB v0.14 的发布,让我们回顾一下最近的进展:
-
v0.12 - 支持在多个 Pandas DataFrame 上查询。 您甚至可以将 Parquet 文件与 DataFrame 进行连接!
import pandas as pd import chdb df1 = pd.DataFrame({'a': [1, 2, 3], 'b': ["one", "two", "three"]}) df2 = pd.DataFrame({'c': [1, 2, 3], 'd': ["ONE", "TWO", "THREE"]}) # 将 df2 保存到 Parquet 文件 df2.to_parquet('df2.parquet') print("\n# 连接 DataFrame 和 Parquet:") print(chdb.query(sql="select * from __tbl1__ t1 join __tbl2__ t2 on t1.a = t2.c", tbl1=df1, tbl2=chdb.Table(parquet_path='df2.parquet'))) -
v0.13 - 获取查询统计信息,如
rows_read、bytes_read和耗时。import chdb data = "file('hits_0.parquet', Parquet)" sql = f"""SELECT RegionID, SUM(AdvEngineID), COUNT(*) AS c, AVG(ResolutionWidth), COUNT(DISTINCT UserID) FROM {data} GROUP BY RegionID ORDER BY c DESC""" res = chdb.query(sql) print(f"\nSQL 读取了 {res.rows_read()} 行,{res.bytes_read()} 字节,耗时 {res.elapsed()} 秒") -
v0.14 - Python UDF(用户定义函数)
from chdb.udf import chdb_udf from chdb import query @chdb_udf() def sum_udf(lhs, rhs): return int(lhs) + int(rhs) print(query("select sum_udf(12,22)"))
展望未来
chDB 在 v0.11 版本升级到了 ClickHouse 23.6,在 Parquet 上运行 SQL 的性能得到了显著提升。但等等,还有更多!就在几天前,我们兴奋地发现 ClickHouse 23.8 通过“Parquet 谓词下推”进一步优化了 Parquet 性能。所以,基于 ClickHouse 23.8 的 chDB 即将到来!
我们还在以下领域与 ClickHouse 团队密切合作:
- 尽可能减小 chDB 安装包的总体积(目前已压缩到约 100MB,我们希望今年能精简到 80MB)。
- 为 chDB 添加表函数和 UDAF(用户定义聚合函数)。
- chDB 已经支持将 Pandas DataFrame 作为输入和输出,我们将继续优化这方面的性能。
我们欢迎大家使用 chDB,也非常感谢您通过在 GitHub 上给我们点 Star 来表示支持。
在此,我要向 ClickHouse CTO @Alexey 和产品主管 @Tanya 表示感谢,感谢他们的支持和鼓励。没有你们的帮助,就不会有今天的 chDB!
目前,chdb.io 组织下有 10 个项目,每个人都是 ClickHouse 的忠实粉丝。我们是一群“用爱发电”的黑客!我们的目标是创建世界上功能最强大、性能最高的嵌入式数据库!
更多推荐

所有评论(0)