原文地址: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 的实现,包括 ReadBufferWriteBuffer,它们大致对应于 C++ 的 istreamostream。为了高效地从文件读取和输出结果(例如,读取 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 版本的 libclibc++ 静态链接。这样 ClickHouse 的二进制文件可以轻松保证整体的链接安全性。然而,对于作为共享对象 (.so) 的 chDB 来说,这部分变得异常棘手,原因如下:

  • Python 运行时环境有它自己的 libc。在加载 chdb.so 后,原本应该在 ClickHouse 二进制文件中链接到 jemalloc 的许多内存分配和管理函数,将不可避免地通过 @plt 连接到 Python 内置的 libc
  • 为了解决上述问题,一个方案是修改 ClickHouse 源代码,使所有相关函数都显式地以 je_ 前缀调用,例如 je_mallocje_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_readbytes_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 的忠实粉丝。我们是一群“用爱发电”的黑客!我们的目标是创建世界上功能最强大、性能最高的嵌入式数据库!

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐