
本文字数:8001;估计阅读时间:21分钟
作者:Josh Ventura

编者按: 本文译自 ClickHouse 原博客(链接点文末-查看原文)。 原文围绕「pg_clickhouse 通过深化下推逻辑与驱动重构持续优化分析性能」展开。本次更新在 TPC-H 下推覆盖率上取得实质进展,Q2 等查询的性能优化效果显著。底层驱动的 C 语言重构为后续的稳定性与并发处理奠定了更好基础。
我们持续投入 pg_clickhouse,当前的首要重点依然是提升分析型工作负载的下推(pushdown)覆盖率,近期目标是实现 TPC-H 基准测试套件的全面下推。自六月更新以来,我们取得了诸多进展。其中 TPC-H 的进度自去年 12 月的介绍文章后就没有详细讨论过,因此我们就从这里说起。随着 v0.10.0 的发布,22 个 TPC-H 查询中完全实现下推的数量已从 12 个增加到 16 个,距离全覆盖只剩 6 个。
在此期间,我们还:
• 基于全新的纯 C 客户端库重构了二进制驱动;
• 将支持下推的函数和聚合函数数量增加了一倍多;
• 修复了二进制驱动的几个并发 Bug(详见下文)。
成绩单
此次又有三个 TPC-H 查询实现了完全下推。受限于之前的查询结构,pg_clickhouse 必须逐行从 ClickHouse 拉取数据,然后在本地执行子查询计算,导致这三个查询曾经极其低效(完整图表(https://github.com/ClickHouse/pg_clickhouse/#test-case-tpc-h)):
| 查询 | PostgreSQL | pg_clickhouse 0.3 | pg_clickhouse 0.10 | 下推情况 |
|---|---|---|---|---|
| Q2 | 588 ms | 3,446 ms | 24 ms | ✔ |
| Q17 | 2107 ms | 32,709 ms | 37 ms | ✔ |
| Q22 | 270 ms | 1,415 ms | 45 ms | ✼ |
( ✔ = 整个查询体现为单一的外部扫描 )
( ✼ = 已下推,但被拆分为多个远程查询;通常包含一个外部扫描和一个 InitPlan 扫描。)
Q17 是最大的亮点:这是一个相关子查询,用于计算每个 part 的 l_quantity 平均值。在比例因子为 1 的 600 万行明细数据集中,以前外部查询每返回一行都要执行一次子查询,耗时高达 32.7 秒。完全实现下推后,耗时仅需 37 毫秒。性能提升了三个数量级,这也清楚地表明,在同一查询中,pg_clickhouse 胜过了原生 PostgreSQL 自身的执行计划(2.1 秒)。
目前还有六个查询尚未实现下推:Q13、Q15、Q16、Q18、Q20、Q21。Q16 和 Q18 为我们指明了方向:pg_clickhouse 已经能够下推 它们所需的 SQL 结构(即像 Q2 和 Q17 那样,将 IN 和 NOT IN 反解析为反连接或半连接);目前的阻碍在于,PostgreSQL 会将它们的子查询展平为反连接或半连接,而 这些连接的输入本身也是连接,但反解析器目前还无法遍历连接两侧的连接树(join tree)。Q15 和 Q20 遇到了该问题的变体。这是子查询下推功能下一步要解决的核心任务。
完善子查询支持
12 月的核心功能是让规划器将整个相关 EXISTS 子查询(correlated subquery)作为单独的 LEFT SEMI JOIN 下推,而不是采用嵌套循环(外表每输出一行就要向 ClickHouse 发起一次请求)。这让支持下推的 TPC-H 查询数量从 22 个中的 3 个跃升至 12 个。剩下 10 个查询面临同一个问题:规划器根本无法将子查询折叠为连接,因此只能保留一个 SubPlan(子计划)。它是查询计划的一部分,包含一个独立查询的完整计划,在整个查询的执行过程中运行(通常是每行执行一次)。下推这类计划是我们路线图上的第五项任务,在最新的 0.10.0 版本中,我们已经完成了这项工作(#289)。现在,Postgres 中的子查询会直接转换为 ClickHouse 中的子查询:
EXPLAIN (VERBOSE, COSTS OFF)
SELECT s.sale_id, s.amount FROM sales s
WHERE s.amount > (SELECT 1.5 * avg(s2.amount) FROM sales s2
WHERE s2.item_id = s.item_id)
ORDER BY s.sale_id;
Foreign Scan on subplan_test.sales s
Output: s.sale_id, s.amount
Remote SQL: SELECT sale_id, amount FROM subplan_test.sales r1 WHERE ((r1.amount > (SELECT (1.5 * avg(q1_1.amount)) FROM subplan_test.sales q1_1 WHERE ((q1_1.item_id = (r1.item_id)))))) ORDER BY r1.sale_id ASC NULLS LAST
SubPlan expr_1
-> Foreign Scan
Output: ((1.5 * avg(s2.amount)))
Relations: Aggregate on (sales s2)
Remote SQL: SELECT (1.5 * avg(amount)) FROM subplan_test.sales WHERE ((item_id = {p1:Int32}))
(8 rows)
EXPLAIN 输出依然会显示 SubPlan 节点(这只是 PostgreSQL 用于记录关联关系的内部机制),但可以看到,顶层的 Remote SQL 在发往 ClickHouse 的单条语句中包含了完整的比较操作,其中也包含该子查询。借助同样的机制,pg_clickhouse 现在能够下推完整的 TPC-H Q2 查询:只需一次 Foreign Scan 和一次远程查询。只要规划器能确认转换是安全的,NOT IN 也能通过 LEFT ANTI JOIN(即 v0.1.0 版本中半连接的否定形式)得到同等处理。
需注意,上述功能无法在低于 25.8 版本的 ClickHouse 中运行,因为旧版本不支持相关子查询的 SQL 结构;
pg_clickhouse会在规划阶段检查服务器版本,如果在旧版服务器上运行,则会像处理其他不支持的结构一样,回退到本地执行。
NOT IN 的正确实现方式
实现 SQL 下推比较简单,难点在于确保其计算结果与 PostgreSQL 完全一致(#315, #317),而这就碰到了一个大坑。ClickHouse 的 IN 操作采用二值逻辑,而 PostgreSQL 采用三值逻辑。这意味着在 PostgreSQL 中,x NOT IN (1, NULL) 可以是 FALSE(当 x=1 时)或 NULL,但绝不可能是 TRUE。如果直接简单粗暴地下推,只要比较中涉及 NULL,这些表达式就会在暗中反转结果:比如 WHERE NOT IN 会返回本该被 PostgreSQL 过滤掉的行,GROUP BY 会将 NULL 分组错误归并为 FALSE 等。常规测试很难发现这些问题,这也是此类问题在下推扩展中极为危险的原因;执行计划看起来没问题,但输出结果却存在微妙的偏差。
V0.10 版本的修复方案会追踪每个表达式结果的使用方式,从而明确查询需要做多少兜底处理才能保持结果一致。也正是在这里,这个坑变得像门格海绵(一种极其错综复杂的空间结构)一样棘手:
• 过滤条件天然可以将 NULL 视为 FALSE ,因此在不涉及 NOT 的条件中,直接使用 ClickHouse 的默认行为即可。
• 若表达式用于直接取值或包含逻辑取反,则需要对空值做额外检查,从而在结果中带入符合 Postgres 预期的值。
• 如果 pg_clickhouse 能够确认操作数不可能为 NULL (比如追溯发现它是非空常量,或是带有 NOT NULL 约束且未被外连接重新引入空值的列,亦或是基于上述两者的非空计算结果等),就可以直接跳过那些用来模拟 Postgres 行为的额外保护逻辑。
因此,加上保护逻辑并完全模拟 Postgres 行为的查询看起来是这样的:
EXPLAIN (VERBOSE, COSTS OFF)
SELECT id FROM tnull WHERE xn NOT IN (1, NULL) ORDER BY id;
Foreign Scan on in_null_test.tnull
Output: id
Remote SQL: SELECT id FROM in_null_test.tnull WHERE ((CASE WHEN xn IS NULL AND notEmpty([1,NULL]) THEN NULL WHEN countEqual([1,NULL], xn) > 0 THEN false WHEN countEqual([1,NULL], NULL) > 0 THEN NULL ELSE true END)) ORDER BY id ASC NULLS LAST
(3 rows)
而遇到常见的、绝对不含 NULL 的场景,则可以直接使用普通的原生 IN:
EXPLAIN (VERBOSE, COSTS OFF)
SELECT id FROM tnull WHERE xn NOT IN (1, 500) ORDER BY id;
Foreign Scan on in_null_test.tnull
Output: id
Remote SQL: SELECT id FROM in_null_test.tnull WHERE ((xn NOT IN (1,500))) ORDER BY id ASC NULLS LAST
(3 rows)
后续改进(#317)将此逻辑扩展到了 IN 系列的所有操作符(包括 IN、NOT IN、= ANY、= ALL、<> ANY、<> ALL),无论标量还是数组形式均适用。这使得它们可以无条件地下推,而不必在无法证明非空时退回到本地计算。
此外,我们之前还有一个小 bug,即 <> ANY(array) 实际计算的却是 <> ALL,这次修改相关代码时也就顺手修复了。
这一切的前提是,ClickHouse 的 IN 确实如前文所述以二值逻辑运行。但服务器级别的设置(transform_null_in)可能会改变这一行为。因此,我们在默认的 pg_clickhouse.session_settings 中添加了 transform_null_in 0,以确保 ClickHouse 的服务器配置不会在暗中破坏刚才提到的防护机制。
扩展下推范围
除了 JOIN 和子查询方面的改进,支持下推的独立函数、操作符和聚合函数数量也大幅增加。由于新增内容过多,无法在此完整列出(详情请参阅 CHANGELOG),以下示例展示了此次扩展的覆盖范围:
-
正则表达式: 关于此处的许多更新,我们在 早前的博文 中已有详细介绍,欢迎阅读!
-
聚合:
-
统计聚合( #290 )
-
-
corr , covar_pop / samp
-
-
-
stddev_pop / samp - var_pop / samp
-
-
-
any_value
-
-
有序集聚合( #291 ): 映射至 ClickHouse 的参数化形式。
-
-
percentile_cont / disc → quantile(s) / quantileExactLow
-
-
-
按分区聚合( #298 ): 如果你将偏重分析的分区移至 ClickHouse,同时将偏重事务的分区保留在Postgres 中,该功能将非常实用。
-
外部分区计算: 若查询在混合了本地与部分区的分区表上进行聚合,现在会直接在 ClickHouse 上计算外部分区的合结果,而不再是拉取全部行。
-
适用于整数类型的可分解聚合(如 count 、sum 、 min 、 max 、 avg )。
-
需要开启enable_partitionwise_aggregate 。
-
-
-
其他更新:
-
格式化与编码: encode(bytea, 'hex'|'base64'|'base64url') ( #302 )。
-
字符串: 三参数的 ltrim / rtrim / btrim ( #307 ),
-
成本估算: 修复了部分成本函数,确保计划器(planner)在处理 ClickHouse 上的 MIN/MAX 时能选择开销更小的方案。( #310 )
-
日期与时间:
-
-
时间间隔(Interval)运算扩展支持 date / timestamp 操作数及减法 ( #301 )
-
-
-
针对会话时区和亚秒级精度,调整了 CURRENT_* / now() / clock_timestamp() 系列函数。
-
-
在这一切背后,有一项重要的架构变更:内置函数下推现已改为按需启用(#245)。在早期版本中,只要 Postgres 内置函数的名称与 ClickHouse 函数匹配,就会默认进行下推。这意味着两者的签名或行为若有差异,可能会在暗中改变查询结果。促成这一变更的具体案例是三角函数(asin / acos / atanh / acosh):当对定义域外的值调用这些函数时,Postgres 会抛出错误,而 ClickHouse 则返回 NaN。自 v0.3.0 起,只有经过显式映射的函数才会下推。尽管这延缓了支持列表的扩充速度,但确保了列表内的函数在保持 Postgres 语义方面绝对可靠。
Driver Updates
我们在最初的介绍文章中提到,对旧版 clickhouse_fdw 代码库进行现代化改造的措施之一,是采用 clickhouse-cpp 来实现原生协议访问。但这已经成为过去式:在 v0.3.1 中,我们将其彻底替换为 ClickHouse/clickhouse-c(#254),这是一个作为 Git 子模块引入的全新 C 客户端。这次切换远不止是一次简单的依赖升级;此前,将 C++ 的异常处理与 PostgreSQL 自身基于 setjmp / longjmp 的错误处理机制混用,一直是引发程序崩溃的根源,并且 clickhouse-cpp 会将结果整体放入缓冲区,导致内存消耗随结果集大小线性增长。相比之下,clickhouse-c 改为逐块流式传输结果,同时使引入库的构建时间和体积缩减了 75% 以上。
在初步切换到 clickhouse-c 之后,我们继续推进代码整合:HTTP 驱动现在也采用了 ClickHouse 的 Native 格式。它使用了与二进制驱动相同的编解码方式,取代了过去基于 TSV 的处理路径 (#328)。这一改动影响了 fetch_size,它是 HTTP 驱动早期流式处理功能中的分批选项。由于 Native 解码器现在固定每次以流式处理一个 curl 数据块,该设置已无用武之地,因此现已被弃用。在写入方面,二进制驱动现在会在缓冲的 INSERT / COPY FROM 数据超过 64MiB 时执行刷新 (#303),不再将整个批次全部滞留在内存中。两种驱动都增加了显式的 compression(none/lz4/zstd)(#268) 和 TLS 控制(secure = on/off/auto,min_tls_version)(#272) 选项,取代了过去通过主机名和端口推断是否使用 TLS 的启发式机制。类型支持范围也有所扩大:现在这两种驱动的读写操作均支持多维数组 (#233),且支持通过二进制协议插入 Array(Nullable(T)) 数据 (#316)。
两种驱动共用同一套二进制编码路径,也为本周期的多项可靠性修复奠定了基础。过去,二进制驱动上的并发外部扫描(例如相关子查询,或涉及两个外部表的嵌套循环连接)会在共用的单一连接上发生冲突并导致崩溃;现在,每次并发扫描都会获取专属的连接 (#296)。这一改动也顺带修复了在重新执行外部扫描时,其批处理内存上下文中存在的一处 use-after-free(释放后使用)缺陷。此外,我们还修复了特定查询结构在执行时失败的问题,该问题的起因是在选择执行查询的用户身份时,选取了无效的关系 OID(对象标识符)(#319)。
深入细节来看,我们修复了通过 HTTP 插入时间戳时的亚秒级精度丢失问题(#300)。此外,对代码库进行更全面的静态分析后,我们发现并修复了几个潜在的 Bug(#313)。这些问题尚未在实际环境中触发,但防患于未然,仍值得提前修复。
新增接口
一些新功能扩展了用户的主动操作能力,而不仅限于自动下推功能:clickhouse_query(server, sql) 可以在配置好的服务器上执行任意查询,并根据用户提供的列定义列表映射结果类型,该函数现已支持二进制驱动(#309)。列定义列表是必不可少的(Postgres 需要在拉取数据前预先知道行结构),但这同时也意味着 clickhouse_query() 无法执行像 CREATE TABLE 这样的操作,因为这类操作既不返回数据行,也没有可声明的结构。这就用到了它的新配套函数 clickhouse_perform(server, sql):这是一个存储过程,需要使用 CALL 而不是 SELECT 来调用,专用于执行那些只产生执行效果而不返回行的语句(#329)。此外,clickhouse_server_version(server) 可以返回当前连接的服务器版本(#293),该功能主要供条件逻辑内部使用。
如果你此前一直在使用 clickhouse_raw_query() 执行这类操作,请注意该函数现已被弃用,并将在下个版本中移除(#329)。请改用 clickhouse_query() 或 CALL clickhouse_perform();这两个函数都通过已配置的外部服务器(foreign server)执行,使用外部服务器自身的连接管理和驱动选择机制,而不再依赖临时的原始连接字符串。
原有接口
我们曾在其他渠道介绍过历史版本中的部分功能,但遗漏了此处的说明,现特此补充完善。
-
JSON:
-
操作符与函数: -> / ->> 和 jsonb_extract_path[_text]() 映射到了 ClickHouse 的 子列语法 ( #169 、 #176 )。
-
类型: ClickHouse 原生的 JSON 类型映射到了 Postgres 的 json ,并提供相同的操作符支持。
-
-
数组:
-
新函数与操作符: 有十多个函数支持下推(push down):
-
-
array_cat
-
-
-
append
-
-
-
remove
-
-
-
to_string
-
-
-
length
-
-
-
hasAll / hasAny 对应 @> / <@ / &&
-
-
-
切片语法( arr[L:U] )对应 arraySlice()
-
-
-
还有更多...
-
-
-
聚合:
-
窗口函数( #175 ): 全套均支持下推,包括以下函数及其相关系列:
-
-
ROW_NUMBER
-
-
-
RANK
-
-
-
LEAD / LAG
-
-
-
NTILE
-
-
布尔与字符串 ( #184 ): bool_and 、 bool_or 、 string_agg
-
-
其他:
-
带有格式字符串校验的 to_char() ( #244 )
-
split_part() ( #206 )
-
fuzzystrmatch 的 soundex() / levenshtein() ( #210 )。
-
剩余工作
除了剩余的 6 个 TPC-H 查询外,最初的路线图中仍有许多待完成项:尚未覆盖的 PostgreSQL 函数、轻量级 DELETE / UPDATE,以及 UNION 下推。阻碍 Q15/16/18/20 执行的“两侧连接树”(join-tree-on-both-sides)限制是影响最大的一块;除非我们在下个版本能把这 6 个查询全部搞定,否则这顺理成章会成为本系列下一篇文章的主题。
关于我们
ClickHouse 是面向 AI 时代打造的高性能实时分析数据库,能够以极致性能处理海量数据分析任务。凭借高并发、低延迟和云原生架构,ClickHouse 广泛应用于可观测性、数据仓库、实时分析及 AI 数据基础设施等场景。我们致力于帮助企业在公有云平台上构建安全、弹性且高性价比的实时分析与 AI 数据平台,加速释放数据价值,推动智能化创新与数字化转型。目前,Trip.com、DiDi、Meta、Sony、Netflix、Deutsche Bank、Sierra、Cloudflare 等全球领先企业均在使用 ClickHouse 支撑其关键业务和数据分析平台。
4869



被折叠的 条评论
为什么被折叠?



