两次 Elasticsearch 踩坑实录:Backblaze B2 快照 501 与中文热词里的英文截断

两次 Elasticsearch 踩坑实录:Backblaze B2 快照 501 与中文热词里的英文截断

最近给一个 Telegram 群消息分析的 dashboard 做基础设施,踩了两个挺有代表性的坑。一个是 Elasticsearch 升级后,Backblaze B2 快照突然传不上去(501);另一个是 中文热词功能里,英文单词被莫名其妙截断(fable 变成 fabl)。

两个问题表面毫不相干,但回头看,它们其实是同一类"惊喜"的两种形态:

  • 一个是版本升级引入了新的默认行为,打破了原本能跑的集成;
  • 一个是**“省事"的封装(开箱即用的分析器)悄悄塞进了你不想要的行为**。

记录一下现象、排查过程(包括走过的弯路)和最终解法。


坑一:Elasticsearch 9.4.2 升级后,Backblaze B2 快照报 501

背景

集群是两节点 ES,用 Backblaze B2(S3 兼容对象存储)做快照灾备,走官方的 repository-s3。在 9.0.0 上一切正常:仓库注册、_verify、打快照、SLM 定时备份都没问题。

后来做滚动升级到 9.4.2。升完第一个节点(还没碰第二个),问题就来了。

现象

POST _snapshot/b2/_verify升级到 9.4.2 的那个节点上失败,另一个还在 9.0.0 的节点正常:

1
2
3
s3_exception: A header you provided implies functionality that is not implemented
              (Service: S3, Status Code: 501)
Unable to upload object [es-snapshots/tests-xxx/data-xxx.dat] using a single upload

幸好是滚动升级,只升一台就暴露了——否则两台一起升,灾备会静默失效。

排查(两条弯路)

报错信息 A header you provided implies functionality that is not implemented 很笼统,字面意思是"你发的某个 header,我没实现”,方向很多。

弯路 1:以为是 AWS SDK 的默认 checksum。 新版 AWS SDK v2 会默认给上传请求加 CRC32 checksum 头,很多 S3 兼容存储不认。标准解法是把 checksum 计算设成 when_required。于是往节点丢了 JVM 参数:

1
2
-Daws.requestChecksumCalculation=when_required
-Daws.responseChecksumValidation=when_required

确认它确实加载了(GET _nodes/jvminput_arguments 里有),但完全没效果。原因:ES 的 repository-s3 在构建 S3 客户端时显式设置了 checksum 行为,覆盖了全局的 JVM/SDK 配置

教训:全局系统属性/环境变量的优先级低于应用在 client builder 里的显式设置。应用要是自己设了,你在外面怎么调都没用。

弯路 2:以为是 chunked encoding。 试了客户端设置 s3.client.default.disable_chunked_encoding: true,依然 501。

上网查 + 看 ES 设置文档。 两个关键线索:

  1. B2 其实在 2025 年 7 月已经支持了 checksum 头——所以 checksum 根本不是病根(也解释了弯路 1 为什么白费)。
  2. Proxmox 社区有人提到 B2 的兼容点是 “Skip If-None-Match header”——B2 不支持 If-None-Match。而 ES 文档里正好有个仓库设置 unsafely_incompatible_with_s3_conditional_writes

根因

ES 在较新版本给 repository-s3 加了 S3 条件写(conditional writes,即 If-None-Match),用来防止并发写坏仓库。每次上传都带 If-None-Match 头,而 B2 不支持这个头 → 回 501

9.0.0 没有这个特性,所以一直好;9.4.2 默认开了,于是炸了。 一次小版本升级,引入了一个新的默认安全行为,刚好踩中 B2 的兼容短板。

解决

一个仓库级开关,直接用 API 改,不用重启任何节点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
PUT _snapshot/b2
{
  "type": "s3",
  "settings": {
    "bucket": "your-bucket",
    "endpoint": "s3.us-west-002.backblazeb2.com",
    "base_path": "es-snapshots",
    "unsafely_incompatible_with_s3_conditional_writes": true
  }
}

改完 _verify 两节点立刻通过。unsafely_ 前缀是 ES 在提醒你:关掉条件写就失去了并发损坏保护——但这层保护主要针对"多个集群往同一个仓库写"的场景,单集群独占一个仓库时风险可接受,何况 B2 本来就不支持,没得选。

经验

  • 能跑的集成,会在小版本升级里悄悄坏掉,因为新版本可能引入新的默认行为(这里是条件写)。升级前读 breaking changes,升级后实测关键路径(比如真打一次快照)。
  • 滚动升级先升一台、立刻验证,能在波及全集群前抓住问题。
  • 笼统的报错会把你带偏。 “header not implemented” 看着像 checksum,实际是 If-None-Match。先用搜索 + 官方设置文档收敛假设,别埋头试。
  • 升级前先有一份验证过能恢复的备份——讽刺的是,这次坏的恰恰是备份本身,好在旧快照还在。

坑二:中文热词里,英文单词被截断(fable → fabl)

背景

dashboard 有个"热词趋势"功能:用 ES 的 significant_text 聚合,找出某时间段里相对历史异常高频的词。中文要做到词级(而不是单字),给 text 字段挂了 smartcn(analysis-smartcn 插件)做分词。

现象

热词列表里,中文词没问题(世界杯、巴拉圭、伊朗),但英文词被砍了尾巴:

期望 实际
fable fabl
mythos mytho
claude claud
status statu
opus opu
anthropic anthrop

排查

直接用 _analyze 看 smartcn 分析器到底干了什么:

1
2
POST _analyze
{ "analyzer": "smartcn", "text": "fable mythos claude status opus 世界杯 人工智能" }

输出:

1
fabl / mytho / claud / statu / opu / 世界杯 / 人工智能

破案了——smartcn 这个开箱即用的分析器,内部是 smartcn 分词器 + Porter 英文词干器 + 停用词过滤。那个 Porter 词干器会把英文单词按词干规则砍后缀(fable→fabl、mythos→mytho、status→statu)。对中文无影响,但混排里的英文就遭殃了。

再验证去掉 Porter 的方案——只用分词器 + 小写:

1
2
3
POST _analyze
{ "tokenizer": "smartcn_tokenizer", "filter": ["lowercase"],
  "text": "fable mythos claude status opus 世界杯 人工智能" }

输出:

1
fable / mythos / claude / status / opus / 世界杯 / 人工智能

英文完整,中文分词和之前一模一样(因为分词器没变,只是去掉了后面的词干步骤)。

根因

smartcn 分析器为了"开箱即用",把 Porter 英文词干器也打包了进去。在纯中文场景没事,但中英混排的内容里,它会把英文词干化,产生 fabl 这种残缺词。

解决

定义一个自定义分析器 cjk_word = smartcn_tokenizer + lowercase(不带 Porter):

1
2
3
4
5
6
7
8
9
"analysis": {
  "analyzer": {
    "cjk_word": {
      "type": "custom",
      "tokenizer": "smartcn_tokenizer",
      "filter": ["lowercase"]
    }
  }
}

一个绕不开的坑:ES 不允许原地修改已有字段的分析器;而且分析器属于静态 index settings,加它必须先 _close 索引。所以对现有索引,流程是:_closePUT _settings(加分析器)→ _open → 加一个新的子字段 text.zh(用 cjk_word)→ _update_by_query 重新分词回填。新索引则在建表时就带上这个分析器和子字段。

significant_text 改成在 text.zh 上跑(配合 source_fields 重分析原文)。

附带收获:换个显著性算法,停用词噪声直接消失

修英文的过程中,顺手对比了 significant_text 的几种显著性算法(都没加停用词表,看纯算法效果):

1
2
默认 JLH:  的 是 不 就 这 都 伊朗 也 个 claude 大 能 了 没 用 ai ...   ← 一堆停用词
gnd:       伊朗 codex claude fifa 世界杯 霍尔木兹 mythos spcx 巴拉圭 fable opus ...  ← 全是真词

gnd(Google Normalized Distance)天生就压低无处不在的常见词,效果远胜默认的 JLH。换成它之后,即使不维护停用词表,中文虚词(的/是/不/说/去)也自然消失了。这比靠停用词表"打地鼠"治本得多——停用词表反而留作 URL 碎片之类的廉价兜底。

顺带一提:中文停用词的事实标准是 goto456/stopwords 仓库(哈工大、百度等),但它们对 不/都/大 这类常见单字反而都漏收;而像"说/去/买"这种动词,任何停用词表都不会收(它们算内容词)。所以靠停用词表清噪声有天花板,选对显著性算法才是关键。

经验

  • “开箱即用"的封装会塞进你没料到的行为。 smartcn 分析器顺手带了 Porter 词干器,在混排内容里就出问题。遇事先 _analyze,别猜。
  • 分析器不能原地改:多字段 + reindex(_update_by_query 可原地重分词)。改静态分析设置还得先关索引。
  • 做"热词/显著词"这类功能,显著性算法(gnd vs JLH)对结果质量的影响,比停用词表大得多

总结:几条可复用的经验

  1. 升级会改默认行为。 能跑的东西在小版本升级后坏掉,往往是某个默认开关变了(B2 那次是条件写)。升级后务必实测关键路径,滚动升级先升一台验证。
  2. 报错信息常常误导。 “header not implemented” 像 checksum,其实是 If-None-Match。用搜索 + 官方文档收敛假设,比埋头逐个试参数高效。
  3. 全局配置敌不过应用的显式设置。 JVM/SDK 层的 checksum 配置,被 ES 在 client builder 里覆盖了,怎么调都没用。排查时先搞清楚配置的优先级链。
  4. “开箱即用"有代价。 smartcn 分析器图省事把 Porter 也带上了。封装隐藏了行为,_analyze / _verify 这类"把黑箱打开看一眼"的工具是排查利器。
  5. 先准备好能恢复的备份再动手。 尤其讽刺的是,有一次坏的恰恰是备份系统本身。
使用 Hugo 构建
主题 StackJimmy 设计