--- title: "Easysearch BKD Merge 异常排查实录:最终定位到旧版 GraalVM JIT 运行时" date: 2026-04-02 lastmod: 2026-04-02 description: "一次看似像 Easysearch BKD merge 逻辑 bug 的问题,最终通过代码级探针、JVM 对照实验和 Elasticsearch 复现,被收敛为旧版 Oracle GraalVM 21 构建相关的 JVMCI/Graal JIT 运行时问题,而不是 Easysearch 本身逻辑缺陷。" tags: ["Easysearch", "Elasticsearch", "Lucene", "GraalVM", "JDK"] summary: "最近一次高并发写入压测中,我们遇到了一个非常诡异的 BKD merge 崩溃。从报错看,很像 Easysearch 2.1.2 在 merge 阶段把 segment 读成了错误状态。典型错误是这样的: java.lang.ArrayIndexOutOfBoundsException: Index -3 out of bounds for length 8 java.lang.ArrayIndexOutOfBoundsException: Index -4 out of bounds for length 8 异常栈最终落在 Lucene BKD 相关路径上: BKDReader.readNodeData() BKDWriter.merge() Lucene90PointsWriter.merge() 如果只看栈,很容易把问题归到 Easysearch 的 BKD merge 逻辑。但排查到最后,结论恰恰相反。 问题不在 Easysearch 的代码,而在 JDK 运行时。 更精确地说,是某个特定 Oracle GraalVM 21 构建中的 JVMCI/Graal JIT 路径,把 Lucene BKD 的热点代码执行错了。 为什么这个问题难查 # 它有几个特别迷惑人的特征: 只在高并发写入压测下触发 服务重启后的前几轮最容易复现 同一进程里,删了索引重新压,后面复现率反而下降 不是固定字段,多个数字类型字段都中过招 ZSTD 和 best_compression 两种 codec 下都能复现 实际命中过的字段包括 @timestamp、size、status、_seq_no。所以这不是某个字段、某种 codec 或某个 mapping 的偶发问题。" --- 最近一次高并发写入压测中,我们遇到了一个非常诡异的 BKD merge 崩溃。从报错看,很像 Easysearch 2.1.2 在 merge 阶段把 segment 读成了错误状态。典型错误是这样的: ```text java.lang.ArrayIndexOutOfBoundsException: Index -3 out of bounds for length 8 java.lang.ArrayIndexOutOfBoundsException: Index -4 out of bounds for length 8 ``` 异常栈最终落在 Lucene BKD 相关路径上: - `BKDReader.readNodeData()` - `BKDWriter.merge()` - `Lucene90PointsWriter.merge()` 如果只看栈,很容易把问题归到 Easysearch 的 BKD merge 逻辑。但排查到最后,结论恰恰相反。 **问题不在 Easysearch 的代码,而在 JDK 运行时。** 更精确地说,是某个特定 `Oracle GraalVM 21` 构建中的 `JVMCI/Graal JIT` 路径,把 Lucene BKD 的热点代码执行错了。 --- ### 为什么这个问题难查 它有几个特别迷惑人的特征: - 只在高并发写入压测下触发 - 服务重启后的前几轮最容易复现 - 同一进程里,删了索引重新压,后面复现率反而下降 - 不是固定字段,多个数字类型字段都中过招 - `ZSTD` 和 `best_compression` 两种 codec 下都能复现 实际命中过的字段包括 `@timestamp`、`size`、`status`、`_seq_no`。所以这不是某个字段、某种 codec 或某个 mapping 的偶发问题。 --- ### 第一层排除:merge reader 不是第一现场 一开始我们确实怀疑 merge reader,毕竟异常直接出现在 merge 路径上。但日志顺序很快给出了相反的证据。在 merge 真正崩溃之前,source segment 已经先出现了这些异常信号: 1. `point-sort-restore-multiple-zero-ords` 2. `source-write-point-doc-mismatch` 3. `pointCount > docCount` 4. `pack-index-negative-code` 5. `reader-invalid-start-pos` 6. 最后才是 `ArrayIndexOutOfBoundsException` 这意味着两件事:merge reader 不是第一现场,source segment 在写出阶段就已经坏了。merge reader 只是读到了已经损坏的 BKD index,并在那个阶段暴露了异常。 --- ### 第二层排除:Easysearch 自己的 BKD 写入逻辑也没有先出错 继续往前追溯,我们发现问题比 `OneDimensionBKDWriter.add()` 还要早。真正的异常出现在排序/回填链路上: - `PointValuesWriter` - `MutablePointTreeReaderUtils.sort()` - `StableMSBRadixSorter` 关键证据来自两个探针: - `point-sort-restore-multiple-zero-ords` - `unwrittenSlotCount == source-write-point-doc-mismatch delta` 这说明在某次排序/回填过程中,有一部分槽位根本没有被写入,默认值 `0` 被 restore 回填到 `ords[]`,再通过 `docIDs[0]` 放大成大量 `docID=0`,最终导致 `pointCount > docCount`,source segment 进入错误状态。 到这一步,排查重点已经不是“Easysearch 的 BKD merge 逻辑存在缺陷”,而是 **Lucene points 排序链路的执行结果和源码语义不一致**。 --- ### 真正的转折点:抓到了 `reorder()` 自身的 coverage 异常 真正把方向扭转过来的,不是又一次复现,而是一个更早的探针: - `point-sort-reorder-coverage-mismatch` 这个探针验证的是:`StableMSBRadixSorter.reorder()` 是否真的按源码应有的次数完整执行。 我们抓到的典型样本之一如下: - `targetSegment=_x` - `field=status` - `k=7` - `expectedLoopCount=9800` - `actualIterationCount=8204` - `firstCoverageMismatchBucket=201` - `firstCoverageExpected=9788` - `firstCoverageActual=8192` 更关键的是,同一条日志里还带出了这个信息: ```text skippedSourceSamples=[201:[{ord=8192,bucket=201,docID=9090,byteAtK=200}, ...]] ``` 这条信息非常重要,因为它说明:bucket `201` 理论上应该处理 `9788` 条,实际只处理了前 `8192` 条,但从 `ord=8192` 往后的样本,读出来仍然还是 `bucket=201`。这直接推翻了“后半段数据被污染后改桶”的旧解释,指向了一个更直接的结论:**`reorder()` 自己的 coverage 被截断了**。 另一个样本中出现了同类边界:`firstCoverageExpected=31822`,`firstCoverageActual=16384`。 到这里,一个很不自然的特征浮现出来:`8192`、`16384`——这些明显的 2 的幂边界,更像是运行时或 JIT 执行异常,而不是普通业务逻辑 bug。 --- ### 哪段代码最可疑 此时怀疑对象已经不是泛泛的“BKD 整体有问题”,而是 Lucene 中的这段热点循环: ```java for (int i = 0; i < HISTOGRAM_SIZE; ++i) { final int limit = endOffsets[i]; for (int h1 = fixedStartOffsets[i]; h1 < limit; h1++) { final int b = getBucket(from + h1, k); final int h2 = startOffsets[b]++; save(from + h1, from + h2); } } restore(from, to); ``` 代码位于 `org.apache.lucene.util.StableMSBRadixSorter#reorder(...)`。 按源码语义,这段代码应该完整扫描每个 bucket 的范围,并最终把全部结果 restore 回去。但我们抓到的事实是:`expectedLoopCount != actualIterationCount`,某些 bucket 只跑到 `8192` / `16384` 就停了,随后出现未写槽位,restore 把默认 `0` 回填,最终 source segment 进入错误状态。 如果这是 Java 源码本身的稳定逻辑 bug,它在解释执行时也应该稳定触发,而不应该强烈依赖某个 JDK/JIT 组合。后面的 JVM 对照实验基本排除了这个可能性。 --- ### 最强证据:只换 JDK / JIT 路径,结果就变了 这次排查中最有说服力的,不是某一条日志,而是对照实验。 #### 基线组:旧版 Oracle GraalVM 21,默认 JVMCI/Graal JIT 环境: - `Oracle GraalVM 21+35.1` - `21+35-jvmci-23.1-b15` - `Linux aarch64 / ARM64` - `UseJVMCICompiler = true` 结果:很快复现,命中了 `point-sort-reorder-coverage-mismatch`、`point-sort-reorder-underfilled`、`point-sort-restore-multiple-zero-ords`,随后 merge 报 `ArrayIndexOutOfBoundsException: Index -4 out of bounds for length 8`。 #### 对照组:关闭 JVMCI/Graal JIT 或纯解释执行 只改 JVM 参数,不改代码和压测口径: - `-XX:-UseJVMCICompiler` - `-Xint` 结果一致:都没有再出现上述探针和异常。 这三组对照的意义很直接:如果这是 Easysearch 或 Lucene 的纯 Java 逻辑 bug,解释执行也应该能稳定复现。但现实是基线组复现,关闭 JVMCI 和纯解释执行都不复现。**问题显然高度依赖 JIT 路径。** #### 版本对照:较新的 GraalVM 21 构建在当前测试中未复现 这里需要补充一条重要的边界条件。我们后来又测试了一个较新的 GraalVM 版本: ```text java version "21.0.9" 2025-10-21 LTS Java(TM) SE Runtime Environment Oracle GraalVM 21.0.9+7.1 (build 21.0.9+7-LTS-jvmci-23.1-b79) ``` 在当前压测中,这个版本没有再出现 merge 错误。 因此结论必须写得更精确:已知会复现的是较早的 `21+35-jvmci-23.1-b15`,已知在当前测试中未复现的是较新的 `21.0.9+7-LTS-jvmci-23.1-b79`。更准确的工程判断不是“GraalVM 21 整体都有问题”,而是**某个特定 GraalVM 21 构建有问题,较新的构建很可能已经修复或规避了该问题**。这里仍需保持严谨:只能说“在当前压测中未复现”,还不能直接说“已经被完整证明没有问题”。 #### 平台边界:不能写成 ARM 专属 除了前面详细展开的 `Linux aarch64 / ARM64` 主要实验环境外,有用户反馈在以下环境中也出现过同类问题: - 操作系统:`openEuler` - 内核:`4.19.90-2112.8.0.0131.oe1.x86_64` - 架构:`x86_64` 这是用户的测试环境,不是我们能够独立完整复现并逐项展开的。但这条信息已经足够说明:当前**不能**把问题简单写成“ARM 平台专属”。更准确的说法是:我们在 `ARM64` 上系统性复现并完成了主要对照实验,另外也有 `openEuler x86_64` 测试环境的同类现象反馈,因此平台边界目前还没有被完全钉死。 --- ### 更强的同机对照:换成 Oracle HotSpot 21.0.10 后,全量写入跑完也没有问题 为了进一步排除“是不是所有 Java 21 都会这样”,我们在同一台服务机上把 `/infini/easysearch/jdk` 从 `Oracle GraalVM 21` 换成普通 `Oracle HotSpot 21.0.10`,恢复默认 JVM 参数,用同样的写入压测继续验证。 其中一轮的结果很有说服力: - 索引:`nginx_zstd3_40mt4` - codec:`ZSTD` - `threads=16` - `bulk_size=1000` - `target_docs=181463624` 最终 `after_count=181463624`,`delta_written=181463624`,全量文档写入完成,服务端没有出现任何 BKD merge 错误。 这条结果至少说明:同一台机器、同一套 Easysearch、同样的数据规模和写入模型,只要把 JDK 从 `Oracle GraalVM 21` 换成 `Oracle HotSpot 21.0.10`,问题就不再出现。 到这一步,工程判断已经比较清晰了:不是 Easysearch 自身逻辑导致,也不是所有 Oracle JDK 21 都会出错,更像是特定 `Oracle GraalVM 21` 构建相关的 JVMCI/Graal JIT 路径问题。 --- ### 最关键的外部对照:Elasticsearch 8.19.5 也复现了 如果说前面的结论还能被质疑为“Easysearch 某些实现差异触发的”,那么后面的外部对照基本排除了这个方向。 我们在同一台服务器上部署了 `Elasticsearch 8.19.5`(Lucene 9.12.2),JDK 也切到相同的 `Oracle GraalVM 21`,执行同类写入压测。结果 Elasticsearch 也复现了同样的 BKD merge 崩溃。 关键异常完全一致: ```text java.lang.ArrayIndexOutOfBoundsException: Index -4 out of bounds for length 8 ``` 栈也一样落在 `BKDReader.readNodeData`、`BKDWriter$MergeReader.collectNextLeaf`、`BKDWriter$MergeReader.next`。 这条证据的力度很强:不是 Easysearch 独有的问题,不是当前这套 Lucene 代码路径独有的问题,`Elasticsearch 8.19.5 + Lucene 9.12.2` 在同类 GraalVM 21 环境下也会出现同类异常。到这一步,再把问题归因于 Easysearch 本身的代码逻辑,已经缺乏依据了。 --- ### 这次排查最终说明了什么 把整条证据链串起来,当前阶段的结论已经比较清楚。 **已验证的事实:** - 问题不是 merge reader 先制造坏数据,source segment 在更早阶段就已经进入错误状态 - 不是单字段问题,也不是 `ZSTD` 或 `best_compression` 专属 - 已抓到 `StableMSBRadixSorter.reorder()` 自身的 coverage 异常 - 关闭 `UseJVMCICompiler` 后问题不复现,`-Xint` 下也不复现 - 同机切到 `Oracle HotSpot 21.0.10` 后,Easysearch 全量写入跑完未见 BKD merge 异常 - `Elasticsearch 8.19.5 + Lucene 9.12.2` 在同类 GraalVM 21 环境下也复现 - 较新的 `21.0.9+7-LTS-jvmci-23.1-b79` 在当前压测中未复现 - 某用户的 `openEuler x86_64` 测试环境中也出现过同类错误,因此不能写成 ARM 专属 **工程结论:** 从工程证据来看,**Easysearch 本身的代码逻辑没有问题**。 当前最符合事实的结论是:问题高度相关于**特定** `Oracle GraalVM 21` 构建,更具体地,是该构建相关的 JVMCI/Graal JIT 路径。它把 Lucene BKD 相关热点代码执行到了错误状态。已知较早构建 `21+35-jvmci-23.1-b15` 可复现,已知较新的 `21.0.9+7-LTS-jvmci-23.1-b79` 在当前测试中未复现。平台边界目前尚未完全钉死,不能再简单写成仅限 `ARM64`。 换句话说,这不是“Easysearch 的 BKD merge 实现有 bug”,而是**特定 JDK/JIT 运行时把本来正确的 Lucene BKD 代码执行错了**。 --- ### 建议版本与规避方案 如果你在生产或测试环境中运行 Easysearch 或 Elasticsearch,并且使用的是某些 `Oracle GraalVM 21` 构建,且启用了默认的 JVMCI/Graal JIT,那么在高并发写入、频繁 merge、BKD 热点路径被充分打热的场景下,需要特别警惕这类问题。 现阶段比较明确的建议是: 1. **避免继续使用已经验证可复现的旧版构建**:`Oracle GraalVM 21+35.1` 或 `21+35-jvmci-23.1-b15` 2. **优先升级到当前测试中未复现的版本**:`Oracle GraalVM 21.0.9+7.1`(即 `21.0.9+7-LTS-jvmci-23.1-b79`) 3. 如果短期内不方便升级 GraalVM,直接切换到普通 `Oracle HotSpot 21.0.10` 直接落到版本号上会更清晰: - 已确认应避开:`21+35-jvmci-23.1-b15` - 当前更推荐:`21.0.9+7-LTS-jvmci-23.1-b79` 原因很简单:前者我们已经复现了,后者在当前压测中没有复现。当然,这里的“推荐”是基于当前测试结果,不代表上游已经正式确认该问题已被修复。 --- ### 最后 这次排查最大的价值,不是“又复现了一次 BKD merge 崩溃”,而是把一个看起来像 Easysearch 代码 bug 的现象,收敛成了一个有明确边界的运行时问题。 它至少说明两件事: - **栈顶报错的位置不一定是真正的第一现场** - **真正有说服力的不是猜测,而是对照实验** 这次结论之所以成立,不是因为主观判断,而是因为我们已经拿到了足够强的工程证据:同机 HotSpot 不复现,关闭 JVMCI 不复现,解释执行不复现,Elasticsearch 也复现,较新的 GraalVM 21.0.9+7.1 在当前测试中未复现,且某用户的 openEuler x86_64 测试环境也出现过同类错误。 所以,这一次,问题确实不在 Easysearch,而在特定版本的 JDK/JIT 运行时。