按 _id 排序导致的 OpenSearch parent 断路器熔断
目录
一台生产环境的 OpenSearch 单节点(2.10.0,16 vCPU、252 GiB 内存,堆 32 GiB)存着四个区域的日志,可见索引有 4,000 多个。一次 parent 断路器(circuit breaker)熔断之后,连认证请求都被拒绝,OpenSearch Dashboards 对所有用户返回 401 Unauthorized,持续约 15 分钟,直到节点重启;重启后还要恢复约 4,200 个分片,这段时间同样查不了。
现象与排查#
先按时间线看事情怎么发生,再看 401 是从哪来的,最后看查询和写入受到的影响。
时间线#
下表时间为 UTC:
| 时间 | 事件 |
|---|---|
| 03:14 之前 | 一个分析日志的脚本分页时用 _id 作为 search_after 的排序键,加载了 _id 的 fielddata,触发了 fielddata 断路器,5 个分片的结果被静默丢掉。已经加载的 fielddata 留在了堆里 |
| 约 03:49 | 两个分析查询几乎同时对同一个应用日志索引族做跨一个月的聚合,一个 terms,一个 filters。parent 断路器触发,30 个分片里 7 个失败 |
| 03:50–03:51 | 所有请求都被拒绝,包括 Dashboards 的认证请求。这段时间分析脚本没有再发查询,fielddata 却从 12.7 GiB 涨到 15.2 GiB,说明还有别的查询在加载;parent 统计的实际内存最高 30.9 GiB |
| 03:59 | 请求短暂恢复,堆占用 92% |
| 04:0x | 尝试清掉 _id 的 fielddata,排查用的只读账号没有 indices:admin/cache/clear 权限,请求被拒绝 |
| 04:05:48 | 节点重启,执行人未知。集群先是 red,约 4,200 个分片慢慢恢复,之后回到 yellow |
| 14:08 | 堆占用 65%,_id 的 fielddata 又回到 1.4 GiB,导致事故的条件还在 |
401 的来源#
401 通常指向认证凭据过期或配置问题。直接调用 OpenSearch 的 REST API,返回的是 circuit_breaking_exception:约 03:49 那次,parent 断路器按实际内存计算的用量是 30.5 GiB,超过了上限,而那个请求本身只申请了 5 KiB,说明堆在此之前已经贴着上限。
Dashboards 的安全插件处理请求时,会先调用 OpenSearch 的 authinfo 接口取当前用户的身份信息,这一步出任何错误,插件都直接返回 unauthorized(见 security-dashboards-plugin 的 authentication_type.ts)。parent 断路器的上限默认是 JVM 堆的 95%,这里约 30.4 GiB(见 Circuit breaker settings);超过之后新请求大多被拒绝,authinfo 也在其中,用户在 Dashboards 上看到的就是 401。
对查询和写入的影响#
熔断前后,还有一些查询没有报错,只返回了部分分片的结果:响应里 _shards.failed 大于 0,hits.total 偏小。OpenSearch 默认允许返回部分结果(allow_partial_search_results 默认为 true,见 Search API),调用方不检查 _shards.failed 就发现不了。
写入没有看到明显的缺口。一个区域的请求日志按事件时间每 5 分钟的条数,随深夜流量平滑下降,03:55 那一档比趋势低约 6%,判断不了是少量丢失还是正常波动;一条 Logstash 管道在 04:00–04:10 出现了 +45% 的补写尖峰,说明熔断期间的数据缓冲在管道里,重启后补了进来。有没有丢数据,要对比 Kafka offset 或上游计数才能确认,这一步还没有做。
根因#
根因分三步查清:先看堆里缓存了什么,再看 _id 的 fielddata 是怎么加载的,最后看哪些配置让几次查询变成了整个节点的故障。
堆里缓存了什么#
03:59 请求短暂恢复时,_cat/fielddata 按字段列出了 fielddata 缓存的大小(见 CAT field data),_nodes/stats/breaker 给出了断路器的统计:
| 项 | 值 |
|---|---|
_id 的 fielddata |
12.3 GiB |
responseBody.keyword 的 fielddata |
2.4 GiB |
| parent 断路器(估算 / 上限) | 29.76 / 30.40 GiB |
| fielddata 断路器(估算 / 上限) | 14.84 / 12.80 GiB |
| 累计触发次数 | parent 3,391 次,fielddata 3,869 次 |
fielddata 断路器并没有缺席:03:14 之前按 _id 分页那次它就触发过,到 03:59 累计触发了 3,869 次。但它拦的是新的加载,已经放进缓存的 fielddata 不会因此被淘汰;这台节点的 indices.fielddata.cache.size 是默认的 -1b,缓存没有上限(见 Field data cache),12.3 GiB 的 _id 就一直占在堆里。parent 断路器按整个堆的实际用量计算,堆被 fielddata 占掉一大块之后,再来几个跨一个月的聚合就越过了它的上限。
responseBody.keyword 那 2.4 GiB 来自一个来源不明的 terms 聚合:keyword 字段做 terms 聚合时要建 global ordinals,这份映射同样放在 fielddata 缓存里,字段基数越高,占的堆越多(见 Eager global ordinals)。
_id 的 fielddata 加载#
Lucene 的字段有两类存储:倒排索引(inverted index)按词找文档,适合检索;Doc Values(列式存储)按文档存放字段值,适合排序与聚合。元数据字段 _id 默认没有 Doc Values,OpenSearch 文档说它在聚合、排序和脚本里的使用受到限制,需要按 _id 排序或聚合时,建议把它的值复制到一个开了 doc_values 的字段(见 _id)。
03:14 之前,一个分析日志的脚本用 search_after 分页,拿 _id 作为排序键。对没有 Doc Values 的 _id 排序时,OpenSearch 从倒排索引现场构建 fielddata 放进堆里,同时在 deprecation 日志里记一条「Loading the fielddata on the _id field is deprecated」(见 IdFieldMapper.java)。fielddata 按 Lucene 段加载:每个段第一次用到这个字段时,把段里这个字段的全部 term 读进内存(见 PagedBytesIndexFieldData.java),和这次查询匹配到多少文档无关。_id 每个文档一个值且互不相同,占用和扫到的文档数成正比,扫的索引越多、时间跨度越大,占用就越大,这次涨到了 12.3 GiB。
放大故障的配置#
几次查询能拖垮整个节点,和下面几项配置有关:
indices.id_field_data.enabled是默认的true,允许对_id加载 fielddata(见 IndicesService.java)。indices.fielddata.cache.size是默认的-1b,不设上限;在 2.10.0 里它是静态设置,改它要重启(见 PR #19152)。search_backpressure.mode是默认的monitor_only,只统计资源消耗,不取消查询(见 Search backpressure)。search.cancel_after_time_interval是默认的-1,搜索请求没有超时(见 Search settings)。- 堆设成了 32 GiB。64 位 JVM 只在堆小于 32 GB 时默认启用压缩指针(compressed oops),
-Xmx32g正好越过这个门槛,对象指针从 4 字节变成 8 字节,同样的数据占的堆更多(见 Oracle 的说明);同时 swap 里有 6.6 GiB,内存没有锁定。 - 单节点,认证、写入和查询共用同一个堆,parent 断路器一触发,所有请求一起受影响。
处理#
这次是靠重启节点恢复的,执行人未知。重启前试过清掉 _id 的缓存,排查用的只读账号没有清缓存的权限;而重启要恢复 4,000 多个分片,代价比清缓存大得多。重启 10 小时后,_id 的 fielddata 又回到了 1.4 GiB,所以下面两组设置仍然需要,目前都还没有执行,是建议。
不用重启的设置#
下面三项是动态集群设置,可以直接下发:
PUT _cluster/settings
{
"persistent": {
"indices.id_field_data.enabled": false,
"search_backpressure.mode": "enforced",
"search.cancel_after_time_interval": "2m"
}
}
indices.id_field_data.enabled: false:关闭_id的 fielddata。之后再有请求按_id排序,会被拒绝并提示「Fielddata access on the _id field is disallowed」(见 IdFieldMapper.java),这次的内存入口就被堵上了。search_backpressure.mode: enforced:节点连续 3 次 CPU 超过 90% 或堆超过 70% 时被判定为过载,这时取消最耗资源的搜索任务(见 Search backpressure)。search.cancel_after_time_interval: 2m:协调节点上所有搜索请求的默认超时,到时间后停掉请求并取消相关任务(见 Search settings)。
后两项只能取消还在运行的搜索任务,已经缓存的 fielddata 不会因此释放,要用有清缓存权限的账号手动清掉(见 Clear cache):
POST _cache/clear?fielddata=true&fields=_id
需要重启的设置#
下面几项是静态设置,放在一次计划内的重启里改:
# jvm.options.d/heap.options
-Xms31g
-Xmx31g
# opensearch.yml
bootstrap.memory_lock: true
indices.fielddata.cache.size: 20%
- 堆改为 31 GiB:回到压缩指针的门槛以内,对象指针重新压缩成 4 字节(见 Oracle 的说明)。
bootstrap.memory_lock: true:把堆锁在内存里,不再被换出到 swap;systemd 还要给服务设LimitMEMLOCK=infinity(见 OpenSearch 博客)。indices.fielddata.cache.size: 20%:给 fielddata 缓存设上限,超过就开始淘汰。这个值要小于 fielddata 断路器的上限,后者默认是堆的 40%(见 Field data cache)。2.10.0 里它不能动态修改,放进_cluster/settings会让整条请求失败(见 PR #19152)。
查询规范#
这次的触发因素里,有分析日志时运行的脚本和查询。下面的规则对 Dashboards 可视化、运维脚本和分析任务同样适用。
深分页用 PIT 加 search_after#
OpenSearch 文档把 Point in Time(PIT)加 search_after 列为首选的分页方式,深分页尤其如此(见 Paginate results);PIT 从 2.4.0 起就有(见 2.4.0 release notes)。search_after 需要一个唯一的排序字段做 tiebreaker,这正是脚本选 _id 的原因。按 _id 文档的建议,把 _id 复制到一个开了 doc_values 的字段再排序(见 _id),或者按时间字段加一个唯一的业务字段排序。
聚合和结果检查#
- 排序和聚合用开了
doc_values的字段,比如@timestamp(见 doc_values)。请求体、响应体、traceId 这类高基数的 keyword 字段不要做 terms 聚合,它们的 global ordinals 同样占 fielddata 缓存。 - 只查需要的日期对应的索引,不要跨一个月做聚合。
- 检查响应里的
_shards.failed,大于 0 时hits.total不可信。 - 遇到 401 或 500 就停下来,不要循环重试:断路器触发时,所有请求都会这样返回。
教训#
- Dashboards 对所有用户返回 401、凭据又没有变化时,先直接调一次 REST API,看返回的是不是
circuit_breaking_exception。 - fielddata 断路器只拦新的加载,不会回收已经进缓存的 fielddata。缓存没设上限时,它挡不住堆被一点点占满,最后由 parent 断路器拒绝所有请求。
总结#
这次故障的链条是:分析日志的脚本按 _id 排序分页,_id 的 fielddata 涨到 12.3 GiB,留在没有上限的缓存里;约 03:49 两个跨一个月的聚合把堆推过 parent 断路器的上限,认证请求也被拒绝,Dashboards 对所有用户返回 401,直到节点重启。重启 10 小时后,_id 的 fielddata 又回到 1.4 GiB,条件仍然存在。建议先下发三项动态设置并清掉 _id 缓存,再在一次计划内的重启里把堆改为 31 GiB、锁定内存、给 fielddata 缓存设上限;查询侧不按 _id 排序,深分页改用 PIT 加 search_after。