Elasticsearch报错CircuitBreakingException [parent] Data too large的排查与解决方法(断路器限额、JVM堆压力与fielddata定位)

Elasticsearch返回429并带上circuit_breaking_exception,说明节点还没OOM,是它自己先掐掉了这次请求。这个报错真正的麻烦不在于限额太小,而在于同一条报错串可能来自五个不同的断路器,改错了地方,堆该爆还是会爆。

报错长这样,HTTP状态码是429不是503。

{
  "error": {
    "type": "circuit_breaking_exception",
    "reason": "[parent] Data too large, data for [<http_request>] would be [123848638/118.1mb], which is larger than the limit of [123273216/117.5mb], real usage: [120182112/114.6mb], new bytes reserved: [3666526/3.4mb]",
    "bytes_wanted": 123848638,
    "bytes_limit": 123273216,
    "durability": "TRANSIENT"
  },
  "status": 429
}

日志里那一行信息更全,因为它会带出各个子断路器的占用。

Caused by: org.elasticsearch.common.breaker.CircuitBreakingException: [parent] Data too large, data for [<transport_request>] would be [...], which is larger than the limit of [...], usages [request=0/0b, fielddata=..., in_flight_requests=..., accounting=...]

先看方括号里那个名字

报错串最开头的[parent]、[fielddata]、[request]、[in_flight_requests],直接告诉你是谁在拦。这一步省不掉,因为五个断路器的限额和处置方式完全不一样。

parent是总闸。它的默认限额取决于一个静态开关:indices.breaker.total.use_real_memory为true时按JVM堆的95%算,为false时只看各子断路器上报的累加值、按70%算。默认值是true,也就是说parent盯的是真实堆占用。request管单次请求里聚合、排序这类临时结构,默认60%堆、overhead是1。fielddata管字段数据缓存,默认40%堆、overhead是1.03。in_flight_requests管传输层和HTTP层还没处理完的请求体,默认100%堆、overhead是2,实际被parent的95%压着。还有一个不算内存的:script.max_compilations_rate限制脚本编译速率,默认150/5m,超了报的是另一种错。

这几个百分比都是堆的比例,所以堆给多大直接决定了限额的绝对值。堆大小本身有两条硬约束:不超过物理内存的一半,另一条是不超过31g,超过之后compressed oops失效,同样的数据要多占一截内存。见过一台64g内存的机器给堆分了40g,理由是"内存大就多用点",结果断路器限额看着很宽裕,实际节点三天两头被系统OOM killer带走。

查当前占用,别猜

curl -s "http://127.0.0.1:9200/_nodes/stats?filter_path=nodes.*.breakers" | python -m json.tool
curl -s "http://127.0.0.1:9200/_cat/nodes?v=true&h=name,node*,heap*"

返回里每个断路器都有estimated、limit、tripped、overhead四个字段。estimated是当前估算占用,limit是限额,tripped是从节点启动以来被触发的次数。之前排过一次线上聚合超时,tripped计数一小时涨了三百多,而heap.percent长期在88上下,顺着这条线索查到的是一个text字段被开了fielddata。

curl -s "http://127.0.0.1:9200/_cluster/settings?include_defaults=true&filter_path=*.breaker*"

fielddata:最容易被打开的那个开关

text字段默认不开fielddata,一旦有人为了排序或聚合把它打开,高基数字段会把整列的词项全量装进堆里。之前那个案例就是这么来的:mapping里给一个日志message字段加了"fielddata": true,单节点堆16g,一个terms聚合就干掉了9g。

# 先止血,清掉已经装进堆的缓存(会打断正在跑的查询)
curl -XPOST "http://127.0.0.1:9200/_cache/clear?fielddata=true"

# 根治:改用 keyword 子字段做聚合
PUT /my-index/_mapping
{
  "properties": {
    "message": {
      "type": "text",
      "fields": { "kw": { "type": "keyword", "ignore_above": 256 } }
    }
  }
}

报错串里如果写的是[fielddata]而不是[parent],直接奔这个方向查,不用去动总闸。

聚合与深分页:request断路器的两个大户

terms聚合的size看着小没用,shard_size默认是size的1.5倍再加10,分片一多,每个分片各自维护的候选桶堆起来就很可观。真要全量遍历,用composite聚合分页,别把size调到几千。

深分页则是另一回事。from加size超过max_result_window(默认10000)会被拒,但在被拒之前,每个分片已经把from之前的所有文档都查出来排好序了,这些中间结果全都在request断路器的账上。翻到几万页的导出任务,是这个报错最常见的来源。换成PIT加search_after,从根上没有这份开销。

curl -XPOST "http://127.0.0.1:9200/my-index/_pit?keep_alive=5m"
curl -XGET "http://127.0.0.1:9200/_search" -H 'Content-Type: application/json' -d '
{
  "size": 1000,
  "pit": { "id": "<上一步返回的id>", "keep_alive": "5m" },
  "sort": [{ "ts": { "order": "asc" } }],
  "search_after": [ 1735689600000 ]
}'

bulk写太大,会撞in_flight_requests

这个断路器按请求体的content-length记账,overhead是2,也就是一个100MB的bulk请求先按200MB算。批量写入时把单批控制在5到15MB之间、按文档数再兜一层上限,比事后调限额管用。批量大小没有万能值,得按文档实际大小实测。

调限额这步,想清楚再动

# 可以热改(dynamic)
curl -XPUT "http://127.0.0.1:9200/_cluster/settings" -H 'Content-Type: application/json' -d '
{ "persistent": { "indices.breaker.request.limit": "50%" } }'

# 静态的,改 elasticsearch.yml 后要重启节点
# indices.breaker.total.use_real_memory: true

把parent往上抬是最省事也最危险的一步:断路器估的是某一刻的快照,并发请求叠起来照样能打穿堆,节点就从429变成真的OutOfMemoryError然后掉出集群。官方给的经验值是堆占用长期超过85%就该减压,而不是抬限额。真要抬,也只是给排查争取几个小时,期间该做的还是拆大查询、减fielddata、加节点。

还有一类情况怎么调限额都没用:断路器只管JVM堆,堆外的内存它看不见。脚本编译、线程栈、以及Lucene自己用的那部分堆外内存涨起来,节点照样会被系统杀掉,日志里却没有circuit_breaking_exception。判断办法是看节点的RSS和堆的差值是否在持续扩大。

版本上的差别

断路器的默认值在7.x到9.x之间没怎么动过,但支持状态变了。Elasticsearch 7.17.29、8.18.8、9.0.8,这几条线已经先后停止维护,7.x在2026年1月到期,8.18和9.0在2025年10月到期;还在支持的是Elasticsearch 8.19.23、9.5.5、9.4.8,其中8.19这条线会维护到2027年7月。老线上跑的7.x集群遇到这个报错,改配置前先看一眼还能不能升。

数据截至2026年10月,版本号按当时在用的几条线整理。