Skip to content

ClickHouse 版本升级避坑清单 ​

5825 字 · 约 16 分钟

记录 ClickHouse 跨版本的行为变更 —— 不是新功能,是那些「上个版本能用、这个版本不能用」「改了配置却不生效」的东西。 持续更新。有补充欢迎提 issue。

为什么需要这份东西 ​

升级这个动作很便宜。在 ckman 上不过是点一下按钮 —— 那个按钮是我做的,我知道它有多简单。

昂贵的从来不是动作,是决定要不要按下去。 而代价几乎都是隐性的:

  • merge 性能悄悄变差 —— 默认算法换了,指标要过几天才看得出来
  • znode 静默堆高 —— 两个默认值一合流,Keeper 用几周时间走向百万级
  • 查询语法忽然不支持,或者语义变了 —— 不报错,只是结果不一样了

这三类有个共同点:不翻 changelog、不追源码,往往捉不到。

而更麻烦的是,ClickHouse 并不是不做兼容,是把兼容的成本转嫁给了用户。 开关往往是存在的 —— %M 可以拨回分钟、insert_deduplication_version 甚至在 26.7 做了拒绝启动的迁移守卫 —— 但发现这些开关的代价是读源码。上面那个 %M 的开关,我是在 FunctionFormatDateTime 的构造函数里翻出来的,不在文档里。

所以这份清单做的事情很简单:把「读源码才能知道的事」,变成「查一下就能知道的事」。

每条尽量给到四样东西:症状、影响版本、怎么确认自己中招了、怎么处理。能追到源码和 PR 的标出出处,只有实测结论的标明是实测 —— 证据分级,别让读者替我承担判断成本。


一、按症状查 ​

症状影响版本类型静默详情
formatDateTime 的分钟位置输出了月份名23.4+格式符语义变更⚠️↓
改了 background_fetches_pool_size 不生效22.5+配置作用域↓
设置了 replicated_max_parallel_fetches 但没有任何效果21.10+配置废弃↓
S3 不可达时起不来,报「metadata 找不到」但 metadata 明明在本地23.0+副作用消失↓
进程起来了、端口通了,但一查表就报错24.8+默认值变更↓
26.3 建的 text index 在 26.4 打不开,且无法 DROP26.3 → 26.4标识符重命名↓
async_insert_deduplicate=0 显示为关,实际仍在去重26.2+旧开关被架空⚠️↓
Keeper znode 暴涨到百万级,async_blocks 占 90%26.2.4+两次默认值叠加↓
23.8 直接升 26.3,复制异常 / 无法回退23.8 → 26.3升级路径约束↓
Nullable(String) / Array(String) 在回退后读不了26.3序列化格式↓
22.8 升 23.8 后 merge 变得极慢22.8 → 23.8默认算法变更↓
升级后想回滚,旧版本读不了新写的数据22.8 → 23.8格式单向变更⚠️↓
国产 ARM 上一跑就 SIGILL,且看不到任何原因不限版本指令集基线→
华为 MRS + HTTP:并发 INSERT 数据串到别的表MRS 特有交互缺陷⚠️→
ARM + HTTP 下 clickhouse-go 批量写入内存堆积不限版本架构 × 协议组合↓

「静默」那列标 ⚠️ 的,是不抛错、不中断的那几条 —— 查询照常返回、任务照常跑完,只是结果不对。 这类最值得在升级前逐条对,因为自动化链路不会替你停下来。


二、按版本查(升级 checklist) ​


三、详情 ​

formatDateTime 的 %M 从「分钟」变成了「月份名」 ​

影响版本:23.4 及以后 变更类型:格式符语义变更 是否静默:⚠️ 不抛错、不中断。 自动化链路会带着错误结果一路跑下去

%M 的含义分钟怎么写
< 23.4分钟,[0, 59]%M
≥ 23.4月份名,January–December%i

会发生什么 ​

升级之后,'%Y-%m-%d %H:%M:%S' 的输出变成:

2025-12-28 22:December:16

月份名直接杵在分钟的位置上。查询成功返回,ClickHouse 不给任何提示。

有人盯着看就立刻发现;没人看的地方 —— 物化视图、分区键、归档报表 —— 就一直错下去,而且已经写进去的数据不会自己修。

但它有开关 ​

读一下 FunctionFormatDateTime 的源码,分支写得很明白:

cpp
// Depending on a setting
// - Full month [January-December] OR
// - Minute of hour range [0, 59]
case 'M':
{
    Instruction<T> instruction;
    if (mysql_M_is_month_name)
    {
        instruction.setMysqlFunc(&Instruction<T>::mysqlMonthOfYearTextLong);
        instructions.push_back(std::move(instruction));
        out_template += "September"; /// longest possible month name
    }
    else
    {
        static constexpr std::string_view val = "00";
        add_time_instruction(&Instruction<T>::mysqlMinute, val);
        out_template += val;
    }
    break;
}

这个注释挺意味深长 —— 依赖配置,表示 [January-December] 的月份,或者 [0-59] 的分钟。

也就是说,即使在最新代码里,%M 仍然可以表示分钟,只要把开关拨回去。开关从构造函数追得到:

cpp
explicit FunctionFormatDateTimeImpl(ContextPtr context)
    : mysql_M_is_month_name(context->getSettingsRef()[Setting::formatdatetime_parsedatetime_m_is_month_name])
    , mysql_f_prints_single_zero(context->getSettingsRef()[Setting::formatdatetime_f_prints_single_zero])
    , mysql_f_prints_scale_number_of_digits(context->getSettingsRef()[Setting::formatdatetime_f_prints_scale_number_of_digits])
    , mysql_format_ckl_without_leading_zeros(context->getSettingsRef()[Setting::formatdatetime_format_without_leading_zeros])
    , mysql_e_with_space_padding(context->getSettingsRef()[Setting::formatdatetime_e_with_space_padding])
{
}

即 formatdatetime_parsedatetime_m_is_month_name。实测:

sql
SELECT formatDateTime(now(), '%Y-%m-%d %H:%M:%S')
SETTINGS formatdatetime_parsedatetime_m_is_month_name = 0
   ┌─formatDateTi⋯ %H:%M:%S')─┐
1. │ 2025-12-28 22:49:16      │
   └──────────────────────────┘

分钟回来了。

顺带:这不止一个开关

上面那个构造函数一口气读了五个 formatdatetime_* 设置:

设置管什么
formatdatetime_parsedatetime_m_is_month_name%M 是月份名还是分钟
formatdatetime_f_prints_single_zero%f 的零值输出形态
formatdatetime_f_prints_scale_number_of_digits%f 按 scale 输出位数
formatdatetime_format_without_leading_zeros%c %k %l 是否去掉前导零
formatdatetime_e_with_space_padding%e 是否空格补位

有开关,说明每一个都对应过一次行为变更。 这一族里可能还藏着别的坑。

怎么确认 ​

升级前全量检索,重点是会固化进表定义的那些:

sql
SELECT database, name, engine, create_table_query
FROM system.tables
WHERE create_table_query ILIKE '%formatDateTime%'
  AND create_table_query LIKE '%\%M%';

应用侧 SQL 和 BI 报表里的 ClickHouse 看不见,得另外捞。

怎么处理 ​

两条路,看你的处境:

  1. 改写法:把取分钟语义的 %M 全部换成 %i。干净,但要改全。
  2. 拨开关:formatdatetime_parsedatetime_m_is_month_name = 0,保持旧行为。适合存量 SQL 太多、一时改不完的场景,也适合升级窗口里先止血。

已经落进物化视图或分区键的,改定义只能止血 —— 存量数据要单独评估。

为什么要改 ​

向 MySQL 的 DATE_FORMAT 对齐 —— 见 #47246。

MySQL 里 %i 就是分钟、%M 是月份名,ClickHouse 此前的 %M(分钟)和 MySQL 语义相反。既然 formatDateTime 走的是 MySQL 风格的格式符,对齐是合理的 —— 但对已经在用旧语义的人来说,这是一次无声的语义翻转。

所以这条不是官方乱改,是一次迟到的对齐。 那个兼容开关就是为这批人准备的。

出处 ​

  • #47246 — 向 MySQL DATE_FORMAT 对齐

async_insert_deduplicate=0 显示为关,实际仍在去重 ​

影响版本:26.2 及以后 变更类型:旧开关被新开关架空 是否静默:⚠️ 是。设置值显示为 0,行为却是开启的。

26.2 引入统一开关 deduplicate_insert,它覆盖旧的 insert_deduplicate 和 async_insert_deduplicate。也就是说 —— async_insert_deduplicate = 0 这个值已经不代表实际行为,它只是一个被架空的历史遗留开关。

排障时如果信了它,追查会在这里断掉。必须新旧一起查:

sql
SELECT name, value, default, changed
FROM system.settings
WHERE name IN ('async_insert', 'wait_for_async_insert', 'deduplicate_insert',
               'insert_deduplicate', 'async_insert_deduplicate')
ORDER BY name;

官方确认见 Issue #91596。

→ 完整追查过程


Keeper znode 暴涨,90% 在 async_blocks ​

影响版本:v26.2.4.23-stable ~ 26.5(含整个 26.3 LTS) 变更类型:两次默认值变更叠加

26.2 把 deduplicate_insert 默认改为 enable,26.3 把 async_insert 默认开启(并回移到 26.2.4.17)。两个默认值合流之后,用户什么都不用做,集群就会持续往 async_blocks 写节点 —— 每条表分片路径 10000 个,保留一周。

async_blocks 上限 ≈ 活跃表分片路径数 × 10000

606 条路径 = 606 万 znode 的理论上限。现场实测 94 小时涨 48.7 万,Keeper 平均延迟 165~185 ms。

止血:把 replicated_deduplication_window_for_async_inserts 调小或设 0;若写入方本来就攒大批(sinker / Kafka 引擎 / ETL),更优解是对该 profile 关掉 async_insert。

根治:升到 v26.6.1.1193-stable,默认切换统一 hash。

→ 126 万 znode 是怎么长出来的 —— 完整追查、三个止血方案的取舍、清理时差点丢 quorum 的教训


ARM + HTTP:clickhouse-go 批量写入内存堆积 ​

影响范围:ARM(鲲鹏 / KylinV10)+ HTTP 协议 + clickhouse-go 批量写入 状态:clickhouse-go #1637,未解决

现场表现:sinker 运行两小时内存涨到 60G,而日增只有 1 亿条。pprof 显示内存集中在 Int64 和 String 的列编码上。

只有这一个组合会触发:

架构协议结果
x86_64HTTP✅ 正常(200M)
aarch64TCP✅ 稳定运行数年
aarch64HTTP🔴 两小时 60G

上游回复:TCP 路径确定使用了变量复用,HTTP 路径不确定。推测与 ARM 的弱内存序和更大的 cache line 导致 Go GC 回收不及时有关,但未经证实。

规避方式:ARM 环境走 TCP 协议。

→ 完整排查过程 —— 以及为什么我们一开始以为 ARM 上只能用 HTTP


22.8 → 23.8:merge 变慢,而且可能退不回去 ​

影响版本:22.8 → 23.8变更类型:默认 merge 算法变更 + 存储格式单向变更

这一跳要处理两组性质完全不同的默认值。

第一组:merge 性能 ​

xml
<merge_max_block_size_bytes>0</merge_max_block_size_bytes>
<allow_vertical_merges_from_compact_to_wide_parts>0</allow_vertical_merges_from_compact_to_wide_parts>

23.x 改了默认的 merge 算法。直接升级,merge 性能会变得非常差。 把这两项按上面的值固定住,可以保持 22.8 的 merge 行为。

这组是可逆的 —— 发现变慢了再改回来也来得及,代价只是这段时间 merge 效率低。

第二组:回退兼容性 ⚠️ ​

xml
<compress_marks>0</compress_marks>
<compress_primary_key>0</compress_primary_key>
<ratio_of_defaults_for_sparse_serialization>1.0</ratio_of_defaults_for_sparse_serialization>

这组不是性能问题,是回退能力问题。 23.x 默认会压缩 mark 文件和主键索引,并对高默认值占比的列启用稀疏序列化 —— 这些格式 22.8 读不了。

这组开关必须在升级前设,事后设无效

格式类开关控制的是**「新写入的数据用什么格式」**。

一旦新版本已经用新格式写了数据,再把开关关掉也救不回已经写出去的部分 —— 那些 part 的 mark、主键索引、稀疏列仍然是新格式,旧版本依旧读不了。

所以判断标准不是「升级时要不要设」,而是 「新版本第一次写数据之前有没有设」。

两组的区别,是升级评估里最该分清的一件事 ​

第一组第二组
性质性能退化回退能力丧失
发现时机升级后观察 merge 指标可能几周后想回滚时才发现
事后补救改回来即可已写数据无法挽回
决策时点可以边跑边调必须在第一次写入前决定

→ 同样的结构在下一跳重演:23.8 → 26.3 升级路径评估 里有五项同类的格式单向变更,其中 25.10 的 storage_metadata_write_full_object_key 连开关都没有。


23.8 不能直接升 26.3,升了也退不回来 ​

影响版本:23.8 → 26.3变更类型:升级路径约束 + 存储格式单向变更

升不上去:25.10 的 Keeper 变更要求来源版本 ≥ 23.9;25.2 的 format_alter_operations_with_parentheses 默认 true 会破坏与 24.3 之前节点的复制。可行路径是 23.8 LTS → 24.3 LTS → 26.3 LTS。

退不回来:25.8 / 25.10 / 25.11 / 26.1 / 26.3 共五项存储格式默认值变更,其中 25.10 的 storage_metadata_write_full_object_key 不可关闭,且只向前兼容 25.x。回退目标最低只能到 25.x,不能是 23.8。

⚠️ propagate_types_serialization_versions_to_nested_types 里的 nested 不是指 Nested 类型,而是容器内部类型 —— Nullable(String)、Array(String) 都受影响。

→ 完整评估:23.8 → 26.3 升级路径


改了 background_fetches_pool_size 不生效 ​

影响版本:22.5.1.20.2079 及以后 变更类型:配置作用域 触发条件:从 22.5 之前升级上来,配置仍留在 users.xml

这个参数是干什么的 ​

fetch 的线程池扫描到 queue 中有任务需要处理时,会按顺序从 queue 里取任务执行。这个过程是多任务并行的,并行度取决于线程池的大小,由 background_fetches_pool_size 配置。

演进 ​

版本变更
20.12.1.5236引入。此前 fetch 与 merge 共用一个线程池,引入该配置的本意是把复制拉取的线程独立出来。默认值 3
21.2.10.48官方意识到 3 个线程在生产环境数据量较大时根本不够用,默认值改为 8
22.5.1.20.2079由用户级配置提升为全局配置 —— 此前在 users.xml 中修改,此后在 config.xml 中修改
23.11.1.2711官方认为默认值 8 仍不能满足某些大数据量场景,改为 16,沿用至今

从官方反复调整默认值这件事本身可以看出:所谓默认值并不一定适用于所有场景,应该根据实际数据量动态调整。

会发生什么 ​

22.5 之后,改在 users.xml 里不报错,只是不生效。表现为副本同步队列持续堆积,而你以为参数已经调过了。

怎么确认 ​

先查值到底有没有生效 —— 改了不生效,先别问「为什么不生效」,先问「它到底生效了没有」。ClickHouse 的 settings 散在 system.settings、system.server_settings、system.merge_tree_settings 几张表里,不确定就都查一遍。

system.replication_queue 的 reason 字段会直接写明 fetch 线程数已达上限,不用猜。

怎么处理 ​

移到 config.xml 的 server 级。

出处 ​

  • ClickHouse Issue #43351 — background_fetches_pool_size is capped by the profile setting

replicated_max_parallel_fetches 设了没用 ​

影响版本:21.10 及以后 变更类型:配置废弃

网上不少资料说设置 replicated_max_parallel_fetches 可以修改 fetch 的并发。该配置在 21.10 之后已经过时,不再使用 —— 你可以设置,但设置后没有效果。

这条和上一条构成一对:同样是「改了不生效」,但原因完全不同 —— 上一条是改对了参数、改错了位置,这一条是参数本身已经作废。排查时值得先确认自己改的那个参数在当前版本还活着。

改 background_fetches_pool_size,见 上一条。


skip_access_check 救不了 S3 不可达 ​

影响版本:23.0 及以后 变更类型:副作用消失(22.x 的救场能力本是 bug,23.x 修掉了)

22.x 上「把 S3 endpoint 故意改错 + skip_access_check=true」能让卡住的 server 起来,这个偏方在运维圈广为流传。23.x 之后完全失效,而且报错信息会把你带偏 —— 它说「S3 上的 metadata 找不到」,但你的 metadata_path 明明配在本地。

原因是 ClickHouse 的 metadata 有两层:disk-level 在本地,part-level 在 S3 上。skip_access_check 只影响启动流程的第 2 步(access check),而真正卡住的是第 5 步(part attach),那一步必须 GET S3 上的 checksums.txt。

版本无关的救援姿势:物理移走表的 SQL 元数据文件,启动,等 S3 恢复后再 ATTACH。

→ 完整分析:metadata 明明在本地,为什么 ClickHouse 说 S3 上找不到


async_load_databases 24.8 起默认开启 ​


text index 的 tokenizer / 索引名改过三次 ​


​


没找到你那条?自己捞 ​

这份清单只收录我实际踩过的。你的版本组合大概率不在里面 —— 但默认值变更这一类,是可以自己算出来的。

设置散在三张表里 ​

这是排查时最先踩的坑:改了不生效,去查 system.settings 发现没这个名字,就以为配错了 —— 其实它在另一张表里。

表管什么
system.settings会话 / 查询级(users.xml 的 profile)
system.merge_tree_settingsMergeTree 表引擎级
system.server_settings服务端级(config.xml)

不确定就三张都查。 而 background_fetches_pool_size 那条坑的本质,就是它从第一张挪到了第三张。

导出两个版本,diff 一下 ​

bash
for v in 23.8 24.3 26.3; do
  docker run --rm clickhouse/clickhouse-server:$v \
    clickhouse local --query "
      SELECT name, toString(\`default\`) AS def, type
      FROM system.settings ORDER BY name FORMAT TSV
    " > settings-$v.tsv
done

diff settings-23.8.tsv settings-26.3.tsv

system.merge_tree_settings 同理。system.server_settings 可能需要起完整 server 而不是 local,按你的版本试。

系统表自己也会变

system.server_settings 是 23.x 之后才有的,is_obsolete 这类列也是后加的 —— 跨度大的版本对比时,先确认两边都有你要的列,否则查询直接报错。

这件事本身有点讽刺:你要用来排查版本差异的工具,自己也有版本差异。

diff 出来之后怎么读 ​

不是所有差异都危险。按风险排:

差异类型风险说明
默认值变了🔴 最高你什么都没改,行为就变了。本清单里绝大多数坑属于这一类
设置换了表 / 作用域🔴 高改在旧位置不报错,只是不生效 —— 最难查的一种
设置被移除🟡 中配置里还留着的话,可能启动失败,也可能被静默忽略
设置被标记 obsolete🟡 中可以设置,但没有效果(replicated_max_parallel_fetches 就是)
新增设置🟢 低通常是新特性,不动它就没事

重点看前两类。 尤其是那些你从来没手工配过的 —— 正因为没配过,它变了你也不会知道。

升级后自查:我有哪些配置已经白写了 ​

上面那套 Docker diff 是升级前做评估的。升级之后还有一步,门槛低到可以立刻在生产上跑:

sql
SELECT name, value, description
FROM system.settings
WHERE changed AND is_obsolete;
  • changed —— 这个值被显式设过,不等于默认
  • is_obsolete —— 这个设置已被标记废弃

两个条件一交,捞出来的就是:你以为在生效、实际已经是空转的配置。

ClickHouse 对这类设置通常不报错,只是静默忽略 —— 所以你精心调过的参数在升完版之后变成 no-op,不会有任何提示。

三张表都该过一遍:

sql
SELECT name, value, description FROM system.settings            WHERE changed AND is_obsolete;
SELECT name, value, description FROM system.merge_tree_settings WHERE changed AND is_obsolete;
-- system.server_settings 有没有 is_obsolete 列,按你的版本确认

两个限制

  1. system.settings 的 changed 只反映当前会话上下文。 服务端级(config.xml)的设置在 system.server_settings 里,要单独查。
  2. is_obsolete 这列是后加的,老版本上会直接报 Unknown identifier —— 又一次撞上「用来排查版本差异的工具,自己也有版本差异」。

它只覆盖三种「改了不生效」里的一种 ​

这份清单攒到现在,「改了不生效」已经有三种不同的形态:

形态例子这条 SQL 捞得到吗
改对了参数,改错了位置background_fetches_pool_size 22.5 挪到全局❌ 参数还在,只是你改的地方不对
参数本身已废弃replicated_max_parallel_fetches 21.10✅
被新开关架空async_insert_deduplicate 被 deduplicate_insert 覆盖❌ 它没被标 obsolete,只是被覆盖了

排查时值得按这三种顺序过一遍:先确认参数在当前版本还活着(这条 SQL),再确认改对了地方(查三张表看值有没有生效),最后确认它没被别的开关覆盖(新旧开关一起查)。


⚠️ 这个方法捞不到什么 ​

知道一个方法的盲区,比知道它能做什么更重要。 以下几类 diff 系统表完全看不见:

捞不到例子
语义变更formatDateTime 的 %M 从分钟变月份名 —— 设置名没变、默认值没变,变的是函数行为
函数行为与返回值同上。system.functions 能看到增删,看不到行为
存储格式 / 序列化compress_marks、storage_metadata_write_full_object_key 这类,diff 得到的只是一个布尔值翻转,看不出它意味着"回不去了"
协议与客户端行为托管版的差异更是完全不可见
默认值组合效应26.2 的默认去重 + 26.3 的默认异步,单看每一条都平平无奇,撞一起才是百万 znode

所以它是起点,不是终点:帮你把「需要关注的候选」从几千个设置收敛到几十个,剩下的判断还得靠读 changelog、读源码,以及在测试环境实际跑一遍。


关于这份清单 ​

这里的条目来自一线生产,不是读 changelog 读出来的 —— 每一条背后都有一次真实的排查,有些还附带着一次事故。所以它不追求覆盖全部 Backward Incompatible Change(官方 release note 已经在做那件事),只收录会真的把人绊倒的那些。

客户信息均已脱敏,规模数字保留。

持续更新。如果你踩到了这里没有的坑,欢迎提 issue 补充 —— 尤其是静默型的那些,它们最值得被写下来。

内容来自一线生产环境,客户信息均已脱敏