ClickHouse 的 Postgres 扩展如何处理 C/C++ 内存安全
DataHot 速览
ClickHouse 开发了 pg_clickhouse、pg_re2、pg_chdb、pg_stat_ch 等 Postgres 扩展,并将 C++ 引入原本用 C 编写的 Postgres,由此产生内存管理冲突。Postgres 采用 MemoryContext 与 palloc/pfree,并用基于 setjmp/longjmp 的 PG_TRY/PG_CATCH/PG_FINALLY 处理错误;C++ 则使用 new/delete 和异常,二者交互会跳过析构、触发未定义行为,未捕获异常还会导致进程 abort。各扩展采取不同策略:pg_clickhouse 完全移除 C++,用新的 C 库 clickhouse-c 替代 clickhouse-cpp,并包装为 pg-clickhouse-c 以整合 Postgres 逻辑、保持通用库复用。clickhouse-c 被设计为传输无关,支持 Native 协议之外的 Native 格式,使 HTTP 驱动复用大量编解码逻辑。
为什么值得关注:对数据库扩展、FDW 与数据集成组件的开发者有直接参考价值:它讲清 C/C++ 内存安全边界、错误处理和进程隔离的坑,可帮助避免崩溃与共享内存损坏。
译文
AI 逐段翻译我们在 ClickHouse 开发了一些 postgres 扩展:
- pg_clickhouse(clickhouse fdw)
- pg_re2(将 re2,即 ClickHouse 使用的同一正则表达式引擎,集成到 postgres 中)
- pg_chdb(在 postgres 中为 COPY 提供 ClickHouse 对象存储能力)
- pg_stat_ch(发送指标与日志)
所有这些都涉及将 C++ 引入 Postgres 扩展。Postgres 是 C。C 和 C++ 当然能很好地协作,对吧?
不。Postgres 有自己的内存分配模式,围绕 MemoryContext 构建。不应使用 malloc/free,而应使用 palloc/pfree。这些函数将分配与一个 MemoryContext 关联,后者可以充当 arena 分配器,在事务或聚合等结束时释放所有内存。MemoryContext 还可以拥有回调来充当析构器。Postgres 通过抛出错误来处理内存分配失败,为此它有一套完整的 PG_TRY/PG_CATCH/PG_FINALLY 宏包,构建在 setjmp/longjmp 之上。PG_FINALLY 是另一种构建 RAII 式析构逻辑的方法。
另一方面,C++ 代码倾向于使用 new/delete,这会调用构造函数/析构函数,并在分配失败时抛出 std::bad_alloc。这两个系统不能很好地交互:setjmp/longjmp 会跳过 C++ 清理;跳过自动对象的非平凡析构函数是 未定义行为,而不仅仅是泄漏。PG_FINALLY 和 MemoryContext 回调并不能使这种跳转安全。异常会绕过 PG_CATCH/PG_FINALLY。更糟的是,未捕获的异常会导致进程中止,即使是在后台工作进程中,也会导致 Postgres postmaster 进程因担心共享内存已损坏而不得不重启一切。
这些扩展各自围绕内存安全采取了不同的策略。
pg_clickhouse
对于 pg_clickhouse,我们彻底根除了 C++ 的使用。这涉及将 clickhouse-cpp 替换为一个全新的 C 库 clickhouse-c,我们最近又将其包装在另一个库 pg-clickhouse-c 中,以整合 postgres 逻辑,同时让 clickhouse-c 保持为通用库。clickhouse-c 被设计为传输无关,支持 Native 协议之外的 Native 格式,因此现在 HTTP 驱动通过使用基于 HTTP 的 Native 格式,与二进制驱动共享大部分解码/编码逻辑。对于 HTTP,我们使用 libcurl,它提供了足够低层的接口,可以与 Postgres 的环境良好协作。
例如,之前在 binary.cpp 中,make_datum 使用以下代码将 ClickHouse String 转换:
1auto s = std::string(col->AsStrict<ColumnString>()->At(row));2ret = PointerGetDatum(cstring_to_text_with_len(s.data(), s.size()));cstring_to_text_with_len 使用 palloc 分配一个 Postgres text 值。如果该分配失败,Postgres 会抛出 ERROR 并跳转到其错误处理器,从而绕过 s 的析构函数。外围的 C++ try/catch 无法捕获这种跳转。
pg_re2
对于 pg_re2,C++ 被限制在 re2_wrapper.cpp 中。所有 C++ 分配都发生在 C++ try/catch 内,并且它不会调用 postgres 的 C 代码。关注点分离足以使这种封装在两个系统之间划出清晰的界线。
pg_chdb
pg_chdb 复用 pg-clickhouse-c 来解码/编码 native 块。最初它将 libchdb 加载到后台工作进程中,但最终将 libchdb 移入一个单独的辅助可执行文件。隔离 libchdb 正是关键:它的 C++ 运行时、线程、分配和故障都位于 Postgres 后端或托管后台工作进程之外。后端 fork 并 exec 这个辅助程序,因此它仍然是 postmaster 的后代,但 postmaster 不会将其作为后台工作进程管理。这个辅助程序只需要足够的信息来运行 ClickHouse 查询并交换 Native 块,因此它不需要 postgres 头文件。libchdb 崩溃可以使调用操作失败,而不会触发 Postgres 集群崩溃恢复。
pg_stat_ch
pg_stat_ch 依赖 OpenTelemetry 库和 Arrow 的 C++ 绑定,因为 nanoarrow 缺少许多 IPC 功能。C++ 代码被隔离在一个 Postgres 后台工作进程中,我们随后用 C++ 异常处理和 PG 异常处理对其进行插桩。terminate 处理器将未捕获的异常 / std::terminate 通过 ereport(FATAL) 路由,而不是默认的 abort() / SIGABRT。FATAL 通常会运行 Postgres 进程退出清理并以状态 1 退出。如果共享内存分离也干净完成,Postmaster 会将其接受为非崩溃退出;随后会按照 bgw_restart_time 重启工作进程。相比之下,异常的工作进程终止可能触发集群范围的崩溃恢复并断开其他会话。
这是一种缓解措施,而非通用的崩溃隔离保证。FATAL 无法修复不一致的共享内存,清理失败仍可能使 postmaster 将退出视为崩溃。如果由库线程调用,进程范围的 terminate 处理器也需要仔细审查:它并不能使 Postgres 错误处理和退出回调可以安全地从任意线程调用。
撰写这篇博客暴露了一个具体的所有权问题,已在 PR #126 中解决:一次被中断的出队可能留下一个槽位,指向已释放的错误文本或已释放的查询文本。恢复会在尾部前进之前重试该槽位,可能重复释放或释放同一引用两次。长期来看,将 C++ 依赖项移入辅助可执行文件,如 pg_chdb 所做的那样,将进一步加固 pg_stat_ch。 PR #129 是第一步:将 C++ 隔离在 src/exporter 中,不依赖 Postgres,并让 bgworker.c 处理 C/C++ 边界。
这篇内容对你有用吗?
反馈只用于改善内容筛选,不等同于收藏