ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Rust接入chdb嵌入式ClickHouse:编译FFI绑定与Arrow结果解析实践

Rust接入chdb嵌入式ClickHouse:编译FFI绑定与Arrow结果解析实践 如果你和我一样手上经常有一堆本地文件CSV、Parquet、JSON想在 Rust 程序里直接写 SQL 做聚合分析又不想为了一个查询就起 ClickHouse 服务器那chdb-rust这条路非常值得认真走一遍。chdb 的完整形态是一个嵌入式 ClickHouse 引擎官方把它编译成单个libchdb.so对外暴露 C 接口而 chdb-rust 就是让 Rust 代码能调用这套接口的桥。很多人在这一步会被“编译”两个字劝退一方面 libchdb 本身是 C 巨兽 ClickHouse 的编译产物构建链路不短另一方面 Rust 这边用 FFI 链接动态库链接器、运行动态库路径、Arrow 版本冲突全是坑。这篇文章把我从零开始编译 libchdb、接入 chdb-rust、再到跑通第一个查询的完整过程记录下来包括我踩过的坑和最终的工程化建议。适合想在 Rust 工具链里嵌入 SQL 分析能力、或者想搞明白“Rust 怎么吃下 C 接口数据”的朋友参考。1. chdb 到底解决了什么问题Rust 程序里想要一个“能跑 SQL 的引擎”1.1 嵌入式 ClickHouse 是什么概念chdb 的全称是 ClickHouse Embedded Database本质上是把 ClickHouse 的列式存储引擎、SQL 解析器、执行算子全部塞进一个动态库里。你的程序 load 这个.so就能直接在进程内执行 SQL不需要另外监听端口、不需要独立进程、不需要维护一堆配置文件。它和常规 ClickHouse 服务的区别类比一下就是你平时做饭要去公共厨房服务器chdb 是给你发了一个随身燃气灶。你一个人在家炒菜不需要把整栋楼的厨房都点着也不需要等公共厨房的锅热起来。数据文件在哪SQL 就在哪跑查询完结果包装成 Arrow 内存格式交还给你。chdb 官方提供的发布物主要面向 Python 生态pip install chdb就能用底层的libchdb.so被隐藏得很好。但如果你的主语言是 Rust事情就没那么顺手了。chdb-rust这个绑定库不是官方主推的star 数也不算高很多细节需要自己趟。1.2 为什么在 Rust 项目里选 chdb而不是 DuckDB 或 DataFusion做嵌入式 SQL 分析Rust 生态里其实有几个选择我把它们拉出来对比过各自的定位差得很远。方案核心形态优势短板chdb嵌入式 ClickHouseC 接口直接吃 ClickHouse 方言SSB/ClickBench 这类列存查询性能强构建重绑定相对小众DuckDB嵌入式分析库C 实现文档全、C API 稳定、进程内分析那套很成熟SQL 方言偏 PostgreSQL跟 ClickHouse 不是一路DataFusion纯 Rust 实现的计算引擎原生 Rust、无 FFI、和 arrow-rs 生态完美衔接没有原生存储引擎文件扫描、SQL 优化都靠社区组件组合我最后选择 chdb 的核心原因有三个一是我的业务目标文件大多是 Parquet 和 ClickHouse 导出的数据chdb 读这类列存文件基本是零成本二是团队的离线 SQL 逻辑全部基于 ClickHouse 语法换 DuckDB 要改不少查询三是我需要在 Rust 进程里直接拿到 Arrow 格式的查询结果chdb 的 C 接口输出恰好就是 Arrow IPC 流省掉了一层序列化开销。当然缺点也得提前认账chdb-rust 的封装很薄意味着内存管理、版本对齐、异常处理这些脏活都要自己干。这篇文章后面大半部分都在解决这些脏活。1.3 整条链路长什么样如果你从零开始搭整条链路是这样的先编译或下载libchdb.so然后在 Rust 里通过chdb-rust或者你自己写的 FFI 层调用queryV2接口拿到一段内存指针这段指针指向的数据是 Arrow IPC 格式最后用arrowcrate 的 IPC Reader 解析成RecordBatch。一句话总结chdb-rust 是 Rust 和 C 库之间的翻译官真正的数据通道是 Arrow IPC 那段内存流。2. 先把核心依赖编译出来libchdb 的获取与验证2.1 直接下载官方预编译包如果只是想在开发环境快速跑通不需要自己从头编译。chdb 的 GitHub Releases 页面会提供针对 Linux x86_64 的预编译包常见的是一个压缩包里面包含libchdb.so和可执行文件。解压之后把.so放到一个固定目录比如/opt/chdb/lib然后编译 Rust 工程时把链接路径指过去就行。mkdir -p /opt/chdb/lib tar -xzf chdb-xxx-linux-x86_64.tar.gz -C /opt/chdb/lib ldd /opt/chdb/lib/libchdb.soldd这一步非常关键它告诉你这个动态库依赖哪些系统库。我见过有人下了预编译包结果在比较老的 CentOS 上跑起来报GLIBC_2.28 not found最后只能换机器或者自己编译。所以拿到.so之后先看依赖别急着写 Rust 代码。2.2 源码编译想深度定制就只能走这条路预编译包版本落后于我需要的功能或者要在 macOS / 特定架构上跑就得自己编译。chdb 的源码仓库结构基本是 ClickHouse 的一个子集加了一层薄封装编译脚本是官方提供的理论上执行一条命令就能触发整个构建。大致流程如下git clone --recursive https://github.com/chdb-io/chdb.git cd chdb ./build.sh但这句简单命令背后是漫长的等待。ClickHouse 的依赖非常多编译器需要 Clang 16构建工具需要 CMake 和 Ninja还需要 Python3 处理代码生成。我实际编译时用了一台 8 核 16G 内存的 Linux 机器首次全量构建花了将近 40 分钟主要时间耗在编译 ClickHouse 内核和链接静态库上。如果你是 4 核机器建议做好一小时的预期管理期间不要开着几十个浏览器标签页内存不够会出现 OOM 直接把构建进程杀掉的惨剧。构建产物出来后重点找两个东西libchdb.so动态库和chdb单文件可执行程序。官方 build 脚本会把它们放到build目录或者项目的根目录具体位置看构建脚本的输出。拿到.so后同样用ldd验证一次。2.3 用 nm 看符号用 Python 冒烟验证编译完别急着接 Rust先用最原始的方式确认这个库是活的、接口符号是存在的。nm可以列出动态库导出的符号nm -D /opt/chdb/lib/libchdb.so | grep queryV2 nm -D /opt/chdb/lib/libchdb.so | grep ReleasePtr能看到queryV2和ReleasePtr这类符号说明 C 接口层已经编进去了。接下来用 Python 官方绑定或者直接ctypes调一下确认能真实返回数据。这一步的目的是把“C 库本身的问题”和“Rust 绑定的问题”隔离开避免后面排查时两头都怀疑。import ctypes lib ctypes.CDLL(/opt/chdb/lib/libchdb.so) lib.queryV2.restype ctypes.c_void_p # 这里只是冒烟验证具体参数定义以你的头文件为准 print(symbol loaded:, lib.queryV2)2.4 源码编译时的构建资源注意事项经验上chdb 构建最容易被坑的是三件事磁盘空间、内存、第三方依赖下载。ClickHouse 的源码全量拉下来就有好几个 GB编译中间产物还会膨胀确保你的磁盘有 30GB 以上余量。构建过程中会从 GitHub 拉各种第三方子模块网络不稳定时构建会在某个依赖处卡很久我的做法是失败就重试不要手动改构建脚本官方脚本对依赖版本有锁定乱改只会引入更多不确定性。3. chdb-rust 的引入方式直接依赖 crate 还是自己写 FFI3.1 先看一眼 crates.io 上的 chdb-rustcrates.io上确实有chdb-rust这个 crate它做的事情是帮你声明extern块、封装queryV2调用、再把返回结果初步包装一下。如果你只是想写个 demo直接把它加到 Cargo.toml 里是最省事的。[dependencies] chdb-rust 0.4但我要泼一盆冷水这类绑定 crate 的维护速度往往赶不上 libchdb 的发布节奏而且它们对 Arrow 版本的选择很可能跟你项目里已经用的 arrow-rs 版本冲突。我实际用的时候就被一个版本问题坑惨了后面第 5 节会详细讲。所以我的建议是chdb-rust 可以当参考实现但不要把它当成一个稳定的黑盒依赖最终还是要有能力自己维护 FFI 层。3.2 Cargo.toml 与基础环境配置假设你还是想先跑通 chdb-rust那 Cargo.toml 长这样[package] name chdb-demo version 0.1.0 edition 2021 [dependencies] chdb-rust 0.4 arrow { version 53, features [ipc] }edition 2021是有讲究的因为 Rust 2024 edition 对extern块的unsafe要求更严格而很多绑定 crate 还没有完全适配。用 2021 能少遇到一些编译期报错。如果你用的是 VS Code rust-analyzer记得装好插件后执行一次cargo metadata刷新依赖缓存不然新建项目后插件会一直转圈找不到模块。3.3 手写 FFI 绑定层我最终选择的方案当 chdb-rust 跟不上你的需求时自己写 FFI 其实没有想象中可怕。chdb 的 C 接口非常薄核心就两个函数和一个结构体。按我实际验证过的接口定义对应 Rust 代码可以写成这样#[repr(C)] struct LocalMemoryView { ptr: *mut u8, size: u64, } #[link(name chdb)] extern C { fn queryV2(argc: i32, argv: *mut *mut u8, size: *mut u64) - LocalMemoryView; fn ReleasePtr(ptr: *mut u8); }你需要确保.so的导出符号签名和你写的一致最稳妥的办法是去 chdb 源码仓库里翻它的chdb.h或者 C 接口实现别凭记忆写。接口确认好之后把它们包在一个 Rustmod里对外只暴露安全的封装函数后续就算 libchdb 升级也只需要改这一个文件。3.4 为什么要做安全封装C 接口不跟你讲生命周期裸 FFI 最大的问题不是“不会写”而是“忘了释放”。queryV2返回的LocalMemoryView.ptr指向引擎分配的内存Rust 侧没有任何机制帮你管理它不调用ReleasePtr就是内存泄漏。我的做法是定义一个持有视图的 Rust 结构体并实现Dropstruct ChdbView { view: LocalMemoryView, } impl ChdbView { fn query(sql: str) - ResultSelf, ChdbError { let prog chdb.to_string(); let mut args vec![prog.as_bytes().to_vec(), sql.as_bytes().to_vec()]; let mut arg_ptrs: Vec*mut u8 args.iter_mut().map(|v| v.as_mut_ptr()).collect(); let mut size: u64 0; let view unsafe { queryV2(arg_ptrs.len() as i32, arg_ptrs.as_mut_ptr(), mut size) }; if view.ptr.is_null() { return Err(ChdbError::QueryFailed); } Ok(ChdbView { view }) } fn as_slice(self) - [u8] { unsafe { std::slice::from_raw_parts(self.view.ptr, self.view.size as usize) } } } impl Drop for ChdbView { fn drop(mut self) { if !self.view.ptr.is_null() { unsafe { ReleasePtr(self.view.ptr) }; } } }把ReleasePtr放在Drop里能确保即使后面解析 Arrow 数据时产生错误、提前return内存也不会泄漏。这个习惯在绑定任何 C 接口时都适用不是你临时记得就释放而是一套机制帮你兜底。4. 从零跑通第一个查询工程搭建到 Arrow 结果落地4.1 工程结构与依赖选择我在实际项目里的建议是不要把所有 FFI 代码都堆在main.rs里而是拆成ffi.rs裸绑定 内存视图封装、engine.rs查询逻辑、结果转换、main.rs业务入口。这样后续换版本、加功能都清晰。最小工程结构如下chdb-demo/ ├── Cargo.toml ├── build.rs ├── src/ │ ├── ffi.rs │ ├── engine.rs │ └── main.rsbuild.rs的作用是让cargo build阶段告诉链接器去哪找 libchdb.sofn main() { println!(cargo:rustc-link-searchnative/opt/chdb/lib); println!(cargo:rustc-link-libdylibchdb); println!(cargo:rustc-link-arg-Wl,-rpath,/opt/chdb/lib); }第三行-Wl,-rpath非常建议加上它把动态库搜索路径直接写进最终二进制的 RUNPATH这样运行时不用每次都设置LD_LIBRARY_PATH。注意这只适用于 LinuxmacOS 上要用install_name_tool或者-Wl,-rpath的等价参数处理。4.2 ffi.rs 和 engine.rs 怎么写ffi.rs里面的核心代码就是第 3 节那几个结构体、extern 块和ChdbView。你要做的就是把queryV2的调用参数拼对。这里有个细节argc不仅仅是 SQL 参数个数chdb 的 C 接口沿用了命令行风格argv[0]一般是被忽略的占位符所以传程序名进去只是为了对齐格式。argv里真正起作用的通常是argv[1]的 SQL 语句某些版本还允许通过后续参数指定输出格式。engine.rs负责把ChdbView里的字节流转成 ArrowRecordBatchuse arrow::ipc::reader::StreamReader; use std::io::Cursor; pub fn run_query(sql: str) - Result(), Boxdyn std::error::Error { let view ffi::ChdbView::query(sql)?; let cursor Cursor::new(view.as_slice()); let reader StreamReader::try_new(cursor, None)?; for batch in reader { let batch batch?; println!(rows: {}, cols: {}, batch.num_rows(), batch.num_columns()); } Ok(()) }这里用StreamReader而非FileReader是因为 queryV2 返回的 Arrow 数据是流式 IPC 格式。如果你把StreamReader写成FileReader解析时大概率会报“invalid IPC stream”之类的错误我第一次就是这么翻车的。4.3 第一个完整查询示例查 Parquet 文件一切就绪后在main.rs里写一个完整的业务调用。我这里直接在 SQL 里通过FROM从句指向一个本地 Parquet 文件chdb 会自动识别格式mod ffi; mod engine; fn main() - Result(), Boxdyn std::error::Error { let sql SELECT region, count() AS cnt, sum(amount) AS total_amount FROM /tmp/sales.parquet GROUP BY region ORDER BY total_amount DESC ; engine::run_query(sql)?; Ok(()) }编译并运行export LD_LIBRARY_PATH/opt/chdb/lib:$LD_LIBRARY_PATH cargo run如果你在build.rs里加了-Wl,-rpath第二行export都可以省略。这个查询会读一个 1GB 左右的 Parquet 文件chdb 列存引擎点开相关列后聚合结果按区域排序输出。实测下来单机本地文件场景下性能非常能打几百万行的 Parquet 聚合基本是秒级返回。4.4 输出到 CSV 或自定格式chdb 的 C 接口输出 Arrow 流只是默认行为你其实可以要求它直接输出 CSV、JSON 等文本格式。在部分版本的queryV2接口中通过追加一个argv参数可以指定输出格式。如果不想依赖这个特性也可以在 Rust 侧处理拿到RecordBatch后用arrow的 CSV writer 写出去这样输出格式的控制权完全在自己手里。5. 编译和运行时的那些坑实测排查链路5.1 链接器说找不到 libchdb.so这是每个新手都会遇到的第一道坎。报错长这样error: linking with cc failed: exit status 1 note: /usr/bin/ld: cannot find -lchdb看到这个错误先别急着怀疑build.rs没生效。按顺序排查三件事。第一确认libchdb.so的文件名和build.rs里写的库名一致。动态库的命名规则是libname.solink 指令里写chdb对应文件必须是libchdb.so。如果你下载的包把库命名成libchdb.so.0.3之类的带版本号要建一个软链接或者直接在build.rs里指定完整路径。ln -s /opt/chdb/lib/libchdb.so.0.3 /opt/chdb/lib/libchdb.so第二确认cargo:rustc-link-searchnative/opt/chdb/lib里的路径真的存在并且当前用户有读权限。第三如果前两步都没问题执行一次cargo clean再重新cargo build。Cargo 对build.rs的输出有缓存有时候你改了build.rs旧链接参数还在缓存里导致链接器继续用旧的失效路径。这个坑我遇到过不止一次明明build.rs改对了链接器还在报同样的错。5.2 编译过了但运行时报找不到动态库链接器找到库不代表运行时能加载库。编译通过后cargo run可能报error while loading shared libraries: libchdb.so: cannot open shared object file: No such file or directory原因是动态库不像静态库链接阶段只需要确认符号存在运行阶段还需要在动态链接器的搜索路径里找到文件。最简单的解法就是我前面说的在build.rs里加-Wl,-rpath。如果你不想把 rpath 写死也可以保留LD_LIBRARY_PATH的方式export LD_LIBRARY_PATH/opt/chdb/lib:$LD_LIBRARY_PATH cargo run生产环境部署时我建议把libchdb.so放到系统的标准库搜索路径里比如/usr/local/lib然后执行ldconfig。这样二进制拿到任何机器上都能直接跑不用依赖部署脚本去设置环境变量。5.3 Arrow 版本不匹配导致的内存崩溃这个坑我要展开说因为它最隐蔽。chdb 的 C API 返回的是 Arrow IPC 流字节理论上任何支持 Arrow IPC 的解析器都能读但 arrow-rs 的 IPC 解析器对不同版本的 Arrow 协议有细微兼容性问题。我遇到过的情况是项目里用的 arrow 版本是 49chdb-rust 绑定里某个子模块用了 52 的 API结果编译期没报错运行到一半直接段错误。解决思路只有一个锁定所有 Arrow 相关依赖到同一个版本。不要在Cargo.toml里同时出现arrow 49和arrow-ipc 52也不要让chdb-rust依赖的 arrow 版本跟你主项目不一致。一个比较省心的做法是在Cargo.toml里用cargo update把 arrow 系列统一升到一个大版本然后看chdb-rust的 Cargo.toml 声明如果它锁死的是另一个版本就直接拷贝它的 FFI 实现自己维护别硬绑着两边对齐。5.4 Arrow IPC 解析的“借用的内存”陷阱前面ChdbView::as_slice返回的是view.ptr指向内存的切片它生命周期绑定在ChdbView上。如果你在StreamReader::try_new里传的是这个切片然后继续持有StreamReader就必须保证ChdbView活得比StreamReader久。一个常见错误是let cursor Cursor::new(view.as_slice()); // view 在这里还是活的 drop(view); // 这里把 view 释放了 let reader StreamReader::try_new(cursor, None)?; // 这里 cursor 指向的内存已经没了解决方法是先让StreamReader在view存活期间把 batch 读出来或者直接把切片转成 owned 的Vecu8再解析。考虑到查询结果通常不会小到可以忽略拷贝成本我的建议是前者把解析代码放在ChdbView的作用域内解析完再释放。5.5 编译慢和依赖下载问题Rust 工程本身的编译速度倒是没有太大问题但如果你从源码编译 libchdb那耗时主要卡在 C 侧。libchdb 的依赖下载和编译都比较吃资源我在第 2 节已经提过。这里补充一个 Rust 侧的提速小技巧如果你的网速拉crates.io依赖比较吃力可以配置镜像源加速改~/.cargo/config.toml[source.crates-io] replace-with rsproxy-sparse [source.rsproxy-sparse] registry sparsehttps://rsproxy.cn/index/注意这里只是举例具体用哪个镜像以你所在的网络环境为准。镜像源能明显加快首次拉依赖的速度这对一个依赖 arrow 这种大 crate 的项目来说体验提升非常明显。5.6 chdb-rust 版本落后于 libchdb 时的处理我前面反复说 chdb-rust 版本可能滞后实际操作中遇到的情况是我需要用 libchdb 新版本里才支持的某个表函数但 chdb-rust 还停留在旧版接口的封装上。碰到的编译错误千奇百怪有说queryV2符号找不到的有说结构体字段对不上的。我的解决流程是先去 chdb 源码仓库看chdb.h的变更拿到新接口定义然后在本地建一个ffi.rs照着改最后把chdb-rust从依赖里移除跑一遍全量测试。改造量其实不大因为你只需要维护两个 C 函数和一个结构体。相比之下去跟别人维护的绑定库扯皮来回沟通的成本更高。6. 进阶用法与个人的最终建议6.1 把 chdb 封装成一个可控的查询组件跑通单条查询之后我建议你再往前走一步把ChdbView和run_query封装成你自己的数据访问组件。比如定义ChdbEngine结构体在其中保存 libchdb 的初始化状态、查询计数器、错误处理策略对外暴露统一的方法。pub struct ChdbEngine { lib_path: PathBuf, last_error: OptionString, } impl ChdbEngine { pub fn new(lib_path: PathBuf) - Self { ChdbEngine { lib_path, last_error: None } } pub fn query_arrow(mut self, sql: str) - ResultVecRecordBatch, ChdbError { // 实际调用 ffi::ChdbView::query然后解析 Arrow 流 } }这样做至少有两个好处一是项目里其他模块不直接依赖裸 FFI后续想切换 DuckDB 或 DataFusion 时只改一个组件二是可以在组件层统一处理ReleasePtr的调用时机和错误信息收集。6.2 性能与内存注意事项关于 chdb-rust 的性能网上能查到的真实数据不多我根据自己的测试给几个结论。第一chdb 对 Parquet 文件的读取是列级 lazy 的SQL 里只SELECT需要的列它不会把整个文件读进内存所以你在写 SQL 时尽量只 select 必要的列传输量和内存占用都会小很多。第二queryV2返回的 Arrow IPC 数据已经是一次物化结果如果结果集特别大建议在 SQL 端做LIMIT或聚合不要把几千万行原始数据捞回 Rust 再过滤这样会白白浪费一次内存拷贝。第三chdb 本身是嵌入式的但如果你的 Rust 程序开了多线程并发调用查询要确认底层接口的线程安全级别。我个人的保守做法是让查询组件内部用一个Mutex串行化查询避免在 C 和 Rust 之间出现数据竞争。6.3 更合适的落地场景和后续扩展从我的实践看chdb-rust 最适合的四类场景是第一本地 CLI 数据分析工具直接对 Parquet / CSV 跑 SQL第二ETL 管道里的数据校验环节用 SQL 快速验证上下游文件的数据质量第三单机批处理程序需要复用 ClickHouse 方言的离线计算逻辑第四单元测试里的“小型分析引擎”不用为了测试 SQL 去起外部服务。如果你需要更进一步的整合可以考虑把 chdb-rust 和tokio结合查询任务放到spawn_blocking线程池里执行避免阻塞异步运行时。还可以把查询结果转换成serde可序列化的结构体直接透传给 HTTP API 响应层。这些扩展都不会破坏前面的封装结构属于在业务层做加法。6.4 我踩完坑之后的最简启动路径如果你现在准备上手我建议的路径是第一步确认系统里有 clang、cmake、ninja直接用官方预编译包或源码构建拿到libchdb.so第二步创建一个 Rust 2021 edition 的新工程先把build.rs里的 rpath 写好再把arrow版本固定第三步复制本文第 4 节的ffi.rs和engine.rs代码跑通SELECT 1第四步确认结果正常后再用SELECT * FROM your.parquet测试真实数据。跑通了这一套你手上就多了一个非常实用的“Rust 进程内跑 ClickHouse SQL”的能力。之后无论是写数据分析工具、做本地文件处理服务还是给现有系统加一个 SQL 查询接口都会比你去起一个 ClickHouse 集群轻量得多。
返回列表