ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Elasticsearch线上OOM排查全记录:从聚合查询到集群治理

Elasticsearch线上OOM排查全记录:从聚合查询到集群治理 凌晨2点17分手机连着震了七八下我眯着眼瞄了一眼屏幕Prometheus的告警弹窗排了一整排全是同一个标题Elasticsearch集群elasticsearch-prod-03节点堆内存使用率持续超过95%。紧接着第二条告警跟进节点进程退出集群状态变成yellow部分索引replica未分配。等我把电脑打开Kibana里已经一片飘红——一个节点OOM了。Elasticsearch线上OOM这事儿遇过一次就知道什么叫“查不完的日志、挨不完的骂”。它不像应用服务OOM重启就好ES的OOM伴随着分片迁移、副本恢复、集群状态抖动重启一个节点常常会引发连锁反应。这篇文章我复盘一次完整的线上ES集群OOM排查过程从现象、排查链路、根因、修复到预防一条龙讲清楚希望给正在和堆内存搏斗的你一些可参考的经验。1. OOM发生的那个凌晨现象、告警与优先止血1.1 第一眼看的不是日志而是监控大盘ES的OOM不是一秒钟发生的。堆内存使用率是逐步爬坡的在最终崩溃之前通常有十几分钟到几十分钟的“缓刑期”。这个缓刑期里最该做的事不是开终端查日志而是打开监控大盘把集群的全局状态看一遍。我当时重点看五张图集群状态green/yellow/red变化时间点各节点堆内存使用率曲线节点GC次数与耗时尤其是Full GC索引写入QPS与bulk拒绝率查询QPS与搜索延迟分位数。为什么先看这些因为ES的OOM本质上不是“内存满了突然爆掉”而是“内存快要满了但还在继续分配JVM在GC上耗尽CPU最终抛出OutOfMemoryError或触发自杀式退出”。如果能在崩溃前看到堆内存的陡增曲线就能顺着时间轴找到对应时间点的查询或写入异常。我这次的情况是主节点堆内存维持在78%上下很平稳但三个数据节点中的一个堆内存从60%飙升到95%只用了40分钟左右。GC曲线显示Full GC次数从每分钟2次涨到每分钟30次每次耗时从200ms涨到3秒以上。Kibana监控里已经能看到节点的CPU被GC线程吃满这基本是OOM的前兆了。1.2 日志里的绝望三连OutOfMemoryError、Full GC、unassigned shards监控看完赶紧上服务器翻日志。ES的日志路径一般是/var/log/elasticsearch/如果是容器部署则在挂载目录下。我先看的不是elasticsearch.log而是JVM的GC日志这一步很多新手会忽略。我当时用jstat -gcutil pid 1000连续输出了十几次看到的结果很绝望S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 0.00 88.62 99.37 97.12 95.10 4521 32.456 87 45.219 77.675 0.00 0.00 89.15 99.61 97.43 95.42 4521 32.456 88 46.850 79.306 0.00 0.00 90.02 99.83 97.70 95.63 4521 32.456 89 48.903 81.359老年代O列直接99%而且Full GC次数在持续刷新。这说明JVM已经没有能力回收老年代的对象了新对象还在不断晋升OOM只是时间问题。紧接着翻elasticsearch.log果然看到最不想看到的几行java.lang.OutOfMemoryError: Java heap space以及更常见但同样致命的[o.e.m.j.JvmGcMonitorService] [node-3] [gc][old][5835][1452] duration [3.2s], collections [1]/[3.2s], total [48.9s]/[21.3h], memory [2.9gb]-[2.9gb]/[3.9gb], all pools [gc][old]注意后面那个memory [2.9gb]-[2.9gb]/[3.9gb]——GC之后堆内存纹丝不动说明老年代里全是不可回收的对象。这种对象要么是缓存要么是查询产生的中间结果集要么是fielddata。再看集群状态{ cluster_name: elasticsearch-prod, status: yellow, unassigned_shards: 16, number_of_pending_tasks: 5 }16个未分配分片这是节点挂掉后副本来不及分配导致的。这时候千万别急着做分片分配的重试先把节点救回来否则分片在主节点之间反复迁移内存会再炸一轮。1.3 快速止血重启节点与分片分配延迟设置OOM现场最忌讳的操作是不做任何保护直接重启节点。节点启动后会立刻开始恢复本地的分片数据如果此时集群里还有大量未分配分片在迁移两个动作叠加刚启动的节点会再次被内存冲爆。我当时的止血操作分三步第一步先把分片分配延迟调大避免重启期间分片疯狂迁移PUT /_cluster/settings { persistent: { cluster.routing.allocation.enable: none } }这个操作会暂停所有分片分配。注意是persistent级别的意味着集群重启后依然生效处理完记得改回来。第二步重启OOM节点。如果有条件重启前先jmap导出一份堆转储文件哪怕只导出存活对象也行这是后续定位根因最重要的素材jmap -dump:live,formatb,file/data/es_heap_dump.hprof pid注意ES进程的启动用户和目录权限导出时选好挂载盘几个GB的堆转储文件别写到系统盘里。第三步节点起来后先观察5分钟等堆内存稳定在合理水位我一般看是否低于70%再逐步放行分片分配PUT /_cluster/settings { persistent: { cluster.routing.allocation.enable: all } }同时可以恢复分片分配并发度限制的默认值PUT /_cluster/settings { persistent: { cluster.routing.allocation.node_concurrent_recoveries: 2 } }这一套下来集群在40分钟内恢复到green但我知道这只是把火扑灭了火源还没找到。不把根因挖出来下一次OOM只是时间问题。2. 根因排查从堆转储到GC日志一步步逼近真凶2.1 先还原现场堆内存里到底装了什么重启能解决“进程挂了”的问题但解决不了“为什么会挂”。我用MATMemory Analyzer Tool打开导出的堆转储文件直接看Dominator Tree按Retained Heap排序结果前几名是这样的org.apache.lucene.util.BytesRef数组——大量fielddata数据org.elasticsearch.search.aggregations.bucket.terms.LongTerms$Bucket——聚合桶对象org.elasticsearch.common.util.BigArrays$ByteArrayBuilder——大数组缓冲。三个类名指向同一个方向聚合查询。我再点开GC Roots引用链发现其中一个大对象来自一个名为log_analysis_*的索引。这个索引是业务方做日志分析用的每天的写入量不大但查询模式极其恶劣。到这里我心里其实已经有了初步判断但光靠堆转储还不够我需要更具体的证据链来回答“是什么查询导致的”“为什么熔断器没拦住”。2.2 熔断器为什么会失守指标级联分析ES的熔断器设计是为了防止OOM的第一道防线但它不是万能的。我在排查过程中把监控指标和熔断器配置做了逐一对照熔断器类型默认触发线本次实际配置是否拦截indices.breaker.total.limit95%95%未触发indices.breaker.fielddata.limit40%38%未触发indices.breaker.request.limit60%60%未触发indices.breaker.inflight_requests.limit100%100%未触发熔断器全都没触发但堆还是爆了为什么因为熔断器是按“估算值”做判断的不是按真实堆占用判断的。request熔断器估算的是单个查询请求中生成的临时对象大小fielddata熔断器估算的是字段数据加载到堆中的大小。但ES里有几类内存分配是熔断器“管不到”的聚合结果的桶对象构建桶的中间数据存在BigArrays里有些版本对这部分估算是滞后的bulk请求的缓冲写入请求的batch数据在解析阶段就占用了堆内存这部分走的是inflight_requests熔断默认阈值100%基本等于不限制分片恢复时的批量读取节点重启后recovery过程需要把translog和segment数据读入内存这部分独立于查询熔断器Lucene的segment memory包括FST、dictionary等这部分是堆外内存为主但部分数据结构会映射到堆内。换句话说当查询并发高、写入量大、还有节点在做恢复时多个内存消耗源会叠加而每个熔断器只看自己那一亩三分地谁都觉得自己没超合在一起就把堆给撑爆了。2.3 挖掘查询模式Kibana慢日志和ES慢日志双管齐下定位到聚合方向后我打开Kibana的Discover页面查询那几个log_analysis_*索引的查询记录同时把ES的慢日志阈值临时调低捕捉真实的慢查询语句。慢日志配置一般集中在elasticsearch.yml也可以动态调整PUT /log_analysis_*/_settings { index.search.slowlog.threshold.query.warn: 2s, index.search.slowlog.threshold.query.info: 500ms, index.search.slowlog.threshold.fetch.info: 1s, index.indexing.slowlog.threshold.index.info: 500ms }半小时后慢日志里抓到一个高频出现的查询模板简化后长这样{ size: 0, aggs: { top_tags: { terms: { field: tags, size: 10000 } }, daily_buckets: { date_histogram: { field: timestamp, fixed_interval: 1m }, aggs: { top_tags_per_min: { terms: { field: tags, size: 5000 } } } } } }看到这个查询我基本确认了OOM的直接原因单次查询返回最多1万个桶terms size 10000再叠加每分钟一个分桶每个分桶再返回5000个桶这相当于一次查询在内存里构建了几十万个桶对象桶之间还有父子关系如果业务方把这个查询做成定时任务比如每5分钟跑一次那么同一时刻可能有多批聚合查询在并发执行内存直接翻几倍。这就是典型的“聚合内存炸弹”。它不会像暴力查询一样迅速打满CPU但会在10到30分钟内把老年代一点一点塞满最终OOM。3. OOM的几类经典元凶不止是聚合查询3.1 高频terms聚合与过量桶前面这个案例是terms聚合导致的内存膨胀。ES的terms聚合在内存中的开销主要由两个因素决定size参数决定返回多少个桶字段的基数cardinality决定需要遍历多少个唯一值。比较坑的地方在于ES的terms聚合是“先构建完整的桶集合再截取前N个”。也就是说你设了size: 5但字段如果有100万个唯一值ES仍然会先构建100万个桶的候选集只不过最终只保留5个。这在字段基数高的情况下内存开销和size没有线性关系而是和字段的基数强相关。如果字段还是text类型且启用了fielddata那情况会更糟。text分词后的每个词条都会成为一个候选桶内存消耗呈指数级上升。我见过一个团队在生产索引里用text字段直接做terms聚合一个查询把32GB堆干到了95%以上。这个问题的排查方法很简单查看聚合字段的mapping确认确实是keyword类型而不是textfielddata: true。如果是后者优先考虑重建索引或改用keyword子字段。3.2 date_histogram的bucket爆炸date_histogram是另一个容易埋雷的聚合。它的内存问题来自于时间跨度×interval。如果索引里有一年的数据你却用fixed_interval: 1m做聚合单次查询就会产生52万个桶。每个桶哪怕只占几百字节那也是上百MB的内存。更麻烦的是date_histogram往往和terms聚合嵌套使用形成“时间桶×标签桶”的笛卡尔积。我见过一个业务方在每分钟一个时间桶的基础上嵌套了50个标签维度的terms聚合单次查询的bucket总数超过百万。这种情况下堆再大也扛不住。应对方案通常有几种缩小查询时间范围从应用层强制限制最长聚合窗口增大interval从1m改成1h甚至1d看业务是否接受改用composite聚合做分页拉取避免一次性构建全部桶对高基数标签字段做significant_terms或rare_terms等轻量级聚合替代。3.3 大size查询和深度分页除了聚合另一种常见OOM元凶是极端查询。比如有人会在Kibana里执行不带size限制的match_all查询或者用from size做深度分页。ES的size默认是10但如果有人在代码里写了size: 10000甚至更大同时有多个并发查询每个查询的命中文档都要在协调节点上完成排序和截断。from size越大协调节点的堆内存压力越大。早期版本的ES甚至因为深度分页导致过频繁的OOM后来官方才引入了search_after和PITPoint In Time来解决这个问题。如果业务确实需要拉取大量数据比如做数据导出建议改成scroll或composite聚合或者用search_after方式分批读取。坚决不能在生产环境直接执行大size查询。3.4 script脚本与自定义评分还有一类冷门但很致命的内存杀手script字段和script评分。如果业务方在查询里写了一些遍历文档字段的脚本比如给每个文档动态计算得分ES需要为每个命中文档创建脚本执行上下文这个上下文对象在JVM里占用相当可观的内存。我遇到过一个案例业务方在query里用了script_score内部还调用了doc[field].value去读取某个大字段单次查询的脚本上下文对象有几十MB。并发量一上来GC直接崩溃。这类问题的排查难度在于慢日志只显示查询语句不会仔细打印脚本内部的执行细节。只能通过堆转储分析看是否有大量org.elasticsearch.script相关的对象。4. 修复不只是改配置我的落地操作清单4.1 动态调整熔断器与JVM参数但别靠这个根治先说临时手段。熔断器阈值可以适当下调给自己争取排查时间但不建议一次性调太低否则正常的查询也会被误杀。我是这样调的PUT /_cluster/settings { persistent: { indices.breaker.total.limit: 70%, indices.breaker.fielddata.limit: 30%, indices.breaker.request.limit: 40%, indices.breaker.inflight_requests.limit: 60% } }注意熔断器调控的是“估算值”不是真实堆内存。下调阈值后ES在分配内存前会更保守但要是请求已经进入执行阶段该爆还是会爆。所以这只是拖延时间的手段。JVM层面ES官方推荐堆大小设置为系统物理内存的50%且不超过31GB。因为在32GB以上JVM会关闭压缩指针内存浪费严重。如果节点内存是64GB堆设31GB其余留给Lucene的堆外缓存。我当时检查发现一个节点物理内存64GB堆却被设成40GB。这有两个后果一是JVM压缩指针失效内存利用率下降二是给Lucene留的堆外空间太小segment cache不足产生大量的磁盘IO反过来拖慢查询增加堆内临时对象的存活时间。调整堆大小需要改jvm.options-Xms31g -Xmx31g然后把-XX:UseConcMarkSweepGC这类已经废弃的GC参数换掉新版本建议默认的G1即可。4.2 从应用层优化聚合查询标本兼治这是最关键的一步。我拉上业务方一起过了一遍所有聚合查询做了几项硬性调整限制聚合桶数量。把原来size: 10000的terms聚合改成size: 20业务方确认报表只需要Top 20。这一步直接把单次聚合的内存消耗降了两个数量级。禁用超大date_histogram。在查询模板层加了一道校验时间范围超过24小时强制使用1h粒度超过7天强制使用1d粒度。这个逻辑写在业务侧的查询构造器里而不是靠ES端拦截。用composite聚合替代深分页聚合。如果业务方确实需要全量聚合结果改成composite分页拉取每次只返回1000个桶内存占用几乎可以忽略。关闭不必要的fielddata。检查了一下mapping确实有少量text字段开启了fielddata。这个是历史遗留问题我直接通过reindex把字段改成keyword类型并且删掉了fielddata配置。4.3 索引生命周期管理减少不必要的分片压力这个案例里还有一个隐形问题log_analysis_*索引保留了12个月的数据但业务实际只会查询最近7天。每天一个新索引每个索引5个主分片1副本总分片数超过3600个。分片越多每个分片占用的堆内存越小但分片本身也是要占用内存的每个分片在节点上都会有一份segment metadata和translog信息。分片数量雪崩式增长直接把堆内存吃掉了很大一部分。我建议业务方引入ILMIndex Lifecycle Management策略热数据7天内保留5个主分片开启force merge到1段温数据7~30天降到1个主分片索引只读冷数据30天以上关闭或删除。实际执行后索引分片数量从3600降到300以内堆内存基数立刻就降下来了。4.4 扩容不如瘦身分片容量规划要重新算在修完查询和索引策略后团队里有人提议直接把集群从3个节点扩到6个节点用物理资源硬扛。我的意见是这个案子的问题不在容量而在单次查询的内存消耗模型扩容只是把爆发点从节点A移到节点B同样的查询模式换个时间照样炸。正确做法是先瘦身再评估容量限制聚合查询的最大桶数压缩内存模型降低分片数量减少固定内存开销压测后观察堆内存峰值确认在50%以下再决定是否需要扩容让业务方给每个大查询加上应用层的超时和限流防止单用户拖垮全集群。最终我们只做了一件事——把堆大小从40GB修正为31GB这是修正错误配置不是扩容同时做了查询瘦身和索引瘦身。上线后运行两周堆内存峰值没有超过55%。5. 一场OOM留下的长期治理清单5.1 监控策略要覆盖熔断器、GC、分片状态的全链路经历这次OOM后我把监控项总结成一套可复用的清单。相比只盯堆内存使用率这套清单能提前暴露风险监控对象关键指标建议阈值告警级别堆内存老年代使用率85%持续5分钟P0GCFull GC次数5次/分钟P1GCFull GC耗时单次1sP0熔断器tripped次数任何一次P1分片状态未分配分片数0P1节点状态失联节点数0P0查询延迟p99搜索延迟3sP2写入通道bulk拒绝率0P1这是我自己在实战中提炼的监控清单比默认的ES监控告警更贴近真实故障场景。5.2 把压测和容量评估纳入发版流程这次OOM的另一个教训是业务方的聚合查询上线前几乎没有做任何压测。对他们来说“查询能返回结果”就等于“没问题”但线上数据量和本地环境完全不同数据量翻10倍后内存消耗可能翻30倍。现在我在团队里定了一条规矩任何涉及聚合、深度分页、脚本的查询上线前必须在预发环境用生产数据量的1:1副本执行一次压测重点观察协调节点的堆内存峰值和Full GC频率。如果堆内存峰值超过60%这个查询不能上线。5.3 跨系统对比Spark和Doris的OOM治理给我的启发排查过程中我也看了Spark和Doris的OOM案例作为对比。Spark的OOM往往和executor内存模型、数据倾斜有关Doris的OOM则出现在大表JOIN和聚合查询中。它们的共同点是OOM的核心原因往往不是“内存不够”而是“计算模型对内存的消耗没有上限”。ES也一样。我们在ES里遇到的绝大多数OOM都不是机器内存买小了而是查询或写入模型对内存的需求没有上限。所以治理思路应该优先收敛内存模型其次才是加机器。5.4 日常运维的几条铁律最后分享一下我现在在日常运维ES集群时坚持的几条铁律永远不要在生产环境执行不带条件的聚合。所有聚合查询必须限定时间范围并且限制桶数量。定期检查索引分片数量。发现分片总数超过集群节点数×20就要考虑索引瘦身或分片合并。开启慢日志并保留至少7天。OOM发生后再想找慢查询如果日志已经被覆盖你将失去最重要的排查依据。给每个ES节点留足堆外内存。堆只占物理内存的一半剩余留给Lucene的segment cache和操作系统page cache。不要在深夜单独处理ES的OOM告警。如果条件允许尽量让了解查询业务的人一起上线OOM的根因往往不在ES本身而在上层业务查询构造器里。走过这一轮我把ES的堆内存当成一种“会被瞬时消耗的资源”来看待而不再是一个静态配置项。任何查询、任何索引变更上线前先问一句这个操作在最坏情况下会把堆内存推高多少想清楚这个问题OOM就会离你很远。
返回列表