ClickHouse v20.8.16.20-lts 修复解析:常量条件下的分片跳过与 CatBoost 模型加载问题
【免费下载链接】ClickHouseClickHouse® is a real-time analytics database management system项目地址: https://gitcode.com/GitHub_Trending/cli/ClickHouse
导读:本文以 ClickHouse v20.8 LTS 分支的补丁版本 v20.8.16.20-lts 发布记录(docs/changelogs/archive/v20.8.16.20-lts.md)为骨架,深度解析其中两项 Bug Fix 背后的分布式查询分片跳过(optimize_skip_unused_shards)机制与 CatBoost 机器学习模型集成设计。读完你将掌握分片跳过优化器的完整参数体系、触发条件与规避策略,并了解 CatBoost 集成在 ClickHouse 中的演进脉络与兼容处理方式。
版本背景:v20.8 LTS 分支的一次补丁迭代
v20.8.16.20-lts是 ClickHouse v20.8 LTS(Long-Term Support)系列中的一个补丁版本,相较上一补丁版本v20.8.15.11-lts,本次仅包含两项Bug Fix,没有任何新特性或性能改进。这类"小而精"的补丁发布正是 LTS 分支的典型节奏:在功能冻结的前提下,持续回移(backport)社区在主干上修复的缺陷,保证长期维护用户可以以较低风险获得稳定性修复。
本次发布记录中的两项修复分别指向两个截然不同的领域:
| 修复 | 关联 PR | 影响领域 |
|---|---|---|
常量WHERE条件下所有分片可能被跳过,导致返回错误的空结果 | PR #21550(经 #22091 回移) | 分布式查询优化(optimize_skip_unused_shards) |
| 首次执行 CatBoost 模型时发生死锁 | PR #21844(经 #22049 回移,修复 #13832) | 机器学习模型集成(catboostEvaluate等函数) |
下文分别深入这两项修复的技术原理。
修复一:常量WHERE条件下所有分片被错误跳过
故障现象
修复前的行为是:当查询带有常量WHERE条件,且开启了设置optimize_skip_unused_shards时,所有分片都可能被跳过,查询返回一个不正确的空结果。
典型场景如下——对分布式表执行"恒假条件"类查询(例如WHERE 0、WHERE 1 = 2、WHERE shard_key NOT IN (…)等)时,ClickHouse 的分片跳过逻辑会把"没有任何分片需要访问"误解为优化机会,直接跳过全部分片,从而返回空集而非真实结果。如果常量条件实际上是有意义的(例如经过参数替换、宏展开或视图改写后变成常量的条件),这种错误空结果会造成严重的数据正确性问题。
为什么会出错:常量条件求值与分片选择的交互
要理解该缺陷,需要先看optimize_skip_unused_shards的工作方式。该优化(src/Core/Settings.cpp 中的定义):
Enables or disables skipping of unused shards for SELECT queries that have sharding key condition in WHERE/PREWHERE, and activates related optimizations for distributed queries (e.g. aggregation by sharding key).
其核心思想是:如果查询的WHERE/PREWHERE中包含分片键(sharding key)上的过滤条件,那么可以只把查询发给与条件匹配的分片,而不是广播到全部分片,从而显著减少跨节点网络开销与远端计算量。
实现上,ClickHouse 会对条件表达式做常量折叠(replaceConstantExpressions),再基于分片键表达式对折叠后的条件求值,得到一个"该条件命中的分片集合"。当条件本身是常量(如WHERE 0)时,折叠后的表达式不依赖任何输入列,evaluateExpressionOverConstantCondition可以直接在不访问任何数据的前提下给出结论。修复前,逻辑的缺陷在于对"常量条件命中零个分片"这一边界情况的处理不够严谨——所有分片都被标记为"未使用"从而被跳过,最终返回错误的空结果。
底层实现:skipUnusedShards 的求值流程
当前仓库中,这一优化在 src/Storages/StorageDistributed.cpp 的StorageDistributed::skipUnusedShards中实现,其完整流程为:
- 前置检查:若查询既无
PREWHERE也无WHERE,直接返回nullptr(不跳过任何分片)。 - 剔除 JOIN:克隆查询并调用
removeJoin移除 JOIN 部分,因为 JOIN 中可能包含其他表的条件,只有针对左侧(分布式)表的条件才应参与分片跳过分析。 - 条件合并与常量替换:将
PREWHERE与WHERE用and合并为单一条件,随后调用replaceConstantExpressions对条件做常量折叠。 - 常量条件求值:调用
evaluateExpressionOverConstantCondition(condition_ast, sharding_key_expr, limit)在单行数据上下文中基于分片键表达式对条件求值,得到命中的分片键值块(blocks)。 - 结果判定:
- 求值块为空(无法得到确定答案)→ 返回
nullptr,不做跳过(保守策略,保证正确性优先); - 求值块非空 → 遍历每个块,调用
createSelector(cluster, result)把分片键值映射为具体的分片编号,得到"需要访问的分片集合",据此生成裁剪后的ClusterPtr。
- 求值块为空(无法得到确定答案)→ 返回
值得注意的是,代码中有明确的if (!blocks) return nullptr;分支——"无法得到确定答案时宁可不优化"。本次修复的实质就是确保**"常量条件确定命中零个分片"与"条件无法求值"这两种情况被正确区分**:前者应返回真实的空结果(而不是因为跳过所有分片而伪造的空结果),后者应保守地回退到广播查询。这种"优化不得改变语义"的原则,也是后续所有分片跳过相关改动必须守住的红线。
optimize_skip_unused_shards 系列设置完整参考
围绕分片跳过,ClickHouse 提供了一整套相互配合的设置,全部定义在 src/Core/Settings.cpp。理解这套参数,是正确使用该优化、规避本次修复所针对问题的基础:
| 设置名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
optimize_skip_unused_shards | Bool | false | 总开关。开启后,对WHERE/PREWHERE中带分片键条件的 SELECT 跳过未使用分片,并激活相关分布式优化(如按分片键聚合)。前提假设数据确实按分片键分布,否则查询会返回错误结果 |
optimize_skip_unused_shards_limit | UInt64 | 1000 | 分片键值数量的上限。若IN (...)等条件中的分片键值数超过该上限,则自动关闭分片跳过。值过多时处理代价大而收益存疑——反正查询大概率会发给所有分片 |
optimize_skip_unused_shards_rewrite_in | Bool | true | 对远端分片重写IN子句,剔除不属于该分片的值(依赖optimize_skip_unused_shards开启) |
allow_nondeterministic_optimize_skip_unused_shards | Bool | false | 是否允许分片键中使用非确定性函数(如rand、dictGet——后者因存在更新语义有坑)。关闭时仅对确定性分片键启用跳过 |
force_optimize_skip_unused_shards | UInt64 | 0 | 当无法跳过未使用分片时是否拒绝执行查询:0不抛异常;1仅在表定义了分片键时禁用查询;2无论表是否定义分片键都禁用查询并抛异常 |
optimize_skip_unused_shards_nesting | UInt64 | 0 | 控制分片跳过生效的分布式查询嵌套层级(Distributed表套Distributed表的场景):0始终生效;1仅第一层;2至第二层。仍要求optimize_skip_unused_shards开启 |
force_optimize_skip_unused_shards_nesting | UInt64 | 0 | 与上同理,控制force_optimize_skip_unused_shards的嵌套层级:0始终生效;1仅第一层;2至第二层 |
规避与最佳实践
结合本次修复与上述参数语义,使用分片跳过时的实践要点如下:
- 默认关闭是安全的:
optimize_skip_unused_shards默认值为false。只有当数据确实通过 Distributed 表按sharding_key分布写入时,才建议开启,否则会得到错误结果(设置注释中对此有明确警告)。 - 用
force_optimize_skip_unused_shards做正确性兜底:在关键业务集群上,可将其设为1或2,让"无法确定性地跳过分片"这一情况直接抛异常,从而把本次修复针对的"错误空结果"暴露为显式错误,而不是静默返回错误数据:
SET optimize_skip_unused_shards = 1; SET force_optimize_skip_unused_shards = 2; -- 无法跳过时直接报错,而非返回错误结果- 警惕常量条件:经过宏替换、参数化或视图展开后形成常量条件的查询,是本次修复的核心场景。升级到包含修复的版本后,这类查询会返回与直接在各分片本地执行一致的真实结果。
- 合理设置
optimize_skip_unused_shards_limit:当IN (...)中的分片键值数量超过限制时,优化会被静默关闭(日志会提示Number of values for sharding key exceeds optimize_skip_unused_shards_limit=...,见 src/Storages/StorageDistributed.cpp)。盲目调大该值会拖慢查询分析阶段,需权衡收益。 - 嵌套场景用 nesting 参数精确控制:多级
Distributed表的场景下,用optimize_skip_unused_shards_nesting/force_optimize_skip_unused_shards_nesting限定生效层级,避免外层裁剪与内层实际数据分布不一致。
修复二:CatBoost 模型首次执行死锁
故障现象与根因
第二个修复针对的问题是:首次执行 CatBoost 模型(加载模型并调用模型评估函数)时发生死锁,该问题最初由 issue #13832 报告,由 PR #21844 修复并经 #22049 回移到 v20.8 LTS 分支。
死锁的典型背景是:ClickHouse 的 CatBoost 集成允许用户把训练好的 CatBoost 模型(.bin文件)注册为外部模型,并通过catboostEvaluate系列函数在 SQL 中直接进行模型推理。模型在首次被查询引用时才惰性加载,而首次加载需要同时完成"读取模型文件 + 初始化评估库 + 编译/加载运行时代码"等多步操作。当并发请求同时触发首次加载、或加载路径与查询执行路径对同一把锁/同一资源产生竞争时,就可能形成死锁。
虽然 v20.8 时代的该集成代码在当前仓库主干中已不可见(见下文"现状"小节),但"惰性初始化 + 并发首触"这一经典竞态模式,仍是理解该修复的关键:修复的方向通常是在加载路径上保证初始化只发生一次且全程加锁顺序一致,避免部分初始化状态下被其他线程观测到。
CatBoost 在 ClickHouse 中的集成方式(源码残留证据)
尽管 CatBoost 集成代码已从当前主干移除,仓库中仍保留多处与该集成直接相关的痕迹,可作为理解其历史设计的证据:
- 专属错误码:src/Common/ErrorCodes.cpp 定义了
CANNOT_LOAD_CATBOOST_MODEL(错误码 382,模型加载失败)与CANNOT_APPLY_CATBOOST_MODEL(错误码 383,模型应用失败)两个独立错误码,说明该集成具备完整的"加载—应用"两阶段错误分类,便于用户区分模型文件问题与推理执行问题。 - 服务端配置占位:src/Core/ServerSettings.cpp 中的注释明确指出 "The CatBoost integration is removed, but a leftover
catboost_lib_pathmust not prevent the server from starting."——即旧的catboost_lib_path(指向 CatBoost 动态库路径的服务端配置项)虽已被移除,但解析逻辑仍会容忍该遗留配置,保证老用户升级后服务器可以正常启动,不会被残留配置卡住。 - 权限兼容占位:src/Access/Common/AccessType.h 保留了与 CatBoost 相关的访问权限类型占位,注释说明其目的是让升级前已授予的权限在升级后仍可解析,且不改变其他权限类型的数值编号,避免破坏
system.privileges/system.grants的既有数据。
这三个痕迹清晰呈现了 ClickHouse 对"移除一项功能"的工程化处理:错误码保留(兼容监控与排查脚本)、配置项宽容解析(兼容老配置文件)、权限类型占位(兼容已授权数据),全程把升级破坏面降到最低。
现状:CatBoost 集成移除后的兼容处理
从当前仓库源码可以推断,CatBoost 集成已从主干移除,catboostEvaluate等函数在当前版本中不再可用。但上述兼容处理意味着:
- 若你在旧版本(如 v20.8 时代)配置过
catboost_lib_path或授予过相关权限,升级到移除该集成的版本不会导致启动失败或权限解析异常; - 相关错误码
CANNOT_LOAD_CATBOOST_MODEL(382)与CANNOT_APPLY_CATBOOST_MODEL(383)仍被保留,若旧监控/告警脚本按错误码归类,无需修改即可继续工作。
对于仍在 v20.8 LTS 分支上使用 CatBoost 建模的用户,本次修复消除了首次执行死锁的稳定性隐患;对于计划升级到新版本的用户,则应将模型推理逻辑从 ClickHouse 内迁移到外部推理服务,并清理相关配置。
升级与验证建议
- 升级路径:v20.8 系列的维护用户应升级到
v20.8.16.20-lts(或更高补丁版本)。LTS 分支的补丁发布仅包含修复,行为变化风险低。 - 修复一验证:升级后执行带常量条件的分布式查询(如
SELECT count() FROM distributed_table WHERE 0、WHERE 1 = 2或由视图/宏展开产生的常量条件),核对结果与各分片本地执行结果一致,确认不再出现错误的空结果。 - 修复二验证:若仍在使用 CatBoost 模型,可在低峰期执行一次模型推理查询,观察首次加载是否正常完成、并发下是否死锁;同时确认
catboost_lib_path等旧配置不再导致告警。 - 回归检查:对开启了
optimize_skip_unused_shards的生产查询,对比升级前后结果集与system.query_log中实际触达的分片数量,确认优化行为符合预期且结果未变。
总结
v20.8.16.20-lts作为一个典型的 LTS 补丁版本,用两项修复覆盖了 ClickHouse 两个反差极大的领域:一端是分布式查询优化器里"常量条件求值"这类精细的语义边界——优化必须在任何时候都不得改变查询结果,拿不准时就保守回退;另一端是机器学习集成中"惰性加载与并发"这类经典的并发缺陷——首触初始化必须做到线程安全。理解前者,需要掌握 src/Core/Settings.cpp 中一整套分片跳过参数的语义与默认值;理解后者,可以从 src/Common/ErrorCodes.cpp、src/Core/ServerSettings.cpp 与 src/Access/Common/AccessType.h 中看到 ClickHouse 在功能演进中兼顾兼容性的工程细节。对维护者而言,这两类经验——"优化不许破坏语义"与"移除功能也要平滑过渡"——同样适用于自己负责的分布式系统与平台建设。
【免费下载链接】ClickHouseClickHouse® is a real-time analytics database management system项目地址: https://gitcode.com/GitHub_Trending/cli/ClickHouse
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考