JVM 堆与 GC 调优:压缩指针、GC 选型、堆外内存与 OOM 排查

系统讲解 Elasticsearch 的 JVM 与 GC 调优:堆大小与压缩指针的约束、G1 与 ZGC 的选型取舍、堆外内存与文件缓存的关系、熔断器保护机制,以及 OOM 与长时间停顿的排查路径。

集群的性能问题最终大多会落到两件事上:磁盘和 JVM。磁盘不够加盘、JVM 出问题却往往表现为五花八门的症状——查询突然变慢、节点无响应、bulk 大批失败、日志里冒出 OutOfMemoryError。Elasticsearch 把 Lucene 的段缓存放进堆外,把对象放堆内,两者共用物理内存,这种设计让堆大小成为一个需要精算的参数。本文从 JVM 基础讲起,覆盖堆大小与压缩指针、GC 选型、堆外内存、熔断保护,最后落到 OOM 与长停顿的排查。

1. JVM 与堆内存基础

一句话总结: Elasticsearch 运行在 JVM 上,堆内存放对象、堆外内存放 Lucene 段缓存,两者之和必须小于物理内存。

1.1 堆内与堆外的分工

  • 堆内(heap):文档对象、聚合的中间结果、请求上下文、集群状态、索引元数据。
  • 堆外(off-heap):Lucene 加载的段数据(doc values、词典、倒排索引),以及操作系统的页缓存。

这个分工的关键推论是:堆开得越大,留给页缓存的空间就越小。而 Lucene 的查询性能高度依赖页缓存命中率,因此把堆开到物理内存的 70% 通常是负优化。

1.2 内存分配的经验法则

节点物理内存 64GB
  堆内存          → 24GB ~ 28GB(不超过 50% 且不超过 31GB)
  页缓存与堆外     → 剩余 36GB 以上

对于专用数据节点,把堆设为物理内存的 50% 且控制在 31GB 以内,是长期验证下来最稳的起点。

1.3 JVM 参数的注入方式

# /etc/elasticsearch/jvm.options.d/heap.options
-Xms24g
-Xmx24g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200

-Xms 与 -Xmx 必须相等,避免堆动态伸缩带来的额外 GC。把自定义参数放在 jvm.options.d/ 下的独立文件里,升级时不会被覆盖,也便于按节点类型区分。

2. 堆大小与压缩指针

一句话总结: 堆超过约 31GB 会关闭压缩指针,对象引用从 4 字节变 8 字节,内存效率反而下降。

2.1 压缩指针的原理

# 验证压缩指针是否开启
java -Xmx24g -XX:+PrintFlagsFinal -version 2>/dev/null | grep UseCompressedOops

输出 UseCompressedOops := true 表示开启。开启后每个对象引用只占 4 字节而非 8 字节,同样大小的堆能容纳更多对象,缓存友好度也更好。

2.2 31GB 这条线

假设堆从 30GB 调到 32GB,对象引用体积翻倍,实际能放的对象数量可能还不如 30GB 时多。因此要么在 31GB 以内,要么干脆开到 48GB 以上,中间地带(32GB 到 48GB)是最差的配置区间。

堆 ≤ 31GB   → 压缩指针开启,内存效率高   ✅
31 ~ 48GB   → 压缩指针关闭,效率反而下降  ❌
≥ 48GB      → 堆容量足够大,可以接受      ⚠️

2.3 何时该用大堆

大堆的典型场景是专用主节点(集群状态可能占几个 GB)、承载海量分片的节点(每分片有固定内存开销)、以及执行超大规模聚合的节点。普通数据节点没必要突破 31GB。

2.4 用 jvm.options 统一约束

# 数据节点:物理内存 128GB
-Xms31g
-Xmx31g

# 专用主节点:物理内存 32GB
-Xms8g
-Xmx8g

3. GC 选型与停顿

一句话总结: 默认 G1 已能满足多数场景,追求极低停顿可用 ZGC,但需要更新版本与更多 CPU。

3.1 G1 的行为特征

# 观察 G1 的关键日志字段
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45
-XX:G1ReservePercent=25
  • MaxGCPauseMillis:目标停顿,G1 会据此调整回收的 region 数量。设得过低会导致回收不充分、堆占用持续攀升。
  • InitiatingHeapOccupancyPercent:触发并发标记的堆占用比例,调低可以更早开始标记,避免 Full GC。
  • G1ReservePercent:预留空间,防止晋升失败导致的 Full GC。

3.2 打开 GC 日志

# JDK 11+ 的 GC 日志配置
-Xlog:gc*,gc+heap=info:file=/var/log/elasticsearch/gc.log:time,uptime,level,tags:filecount=10,filesize=64m

日志滚动与体积上限必须设置,否则 GC 日志会把磁盘写满。time 与 uptime 同时输出,便于把 GC 事件与业务时间线对齐。

3.3 判断 GC 是否健康

# 从 GC 日志统计老年代回收频率与停顿分布
grep "Pause Young (Mixed)" /var/log/elasticsearch/gc.log | tail -20
grep -oE "Pause Young \(Mixed\).*[0-9]+\.[0-9]+ms" /var/log/elasticsearch/gc.log | awk '{print $NF}' | sort -n | tail -5

健康的标准是:Young GC 频繁但停顿短(几十毫秒),Mixed GC 与并发标记周期正常,没有 Full GC。一旦出现 Pause Full,就要立刻排查。

3.4 ZGC 的适用性

-XX:+UseZGC
-XX:+ZGenerational

ZGC 适合堆很大且停顿敏感的场景。但要注意 ZGC 需要预留更多堆空间,且在高分配速率下对 CPU 要求更高。对多数 Elasticsearch 集群,G1 仍然是更稳妥的默认选择。

3.5 避免 Full GC 的常见手段

Full GC 通常由两类原因引起:一是晋升失败(老年代空间不足),二是显式触发(如 System.gc())。前者要靠控制堆内对象的存活量解决——最有效的做法是减少高基数聚合、避免一次拉取海量文档、控制 terms 聚合的 size。

4. 堆外内存与文件缓存

一句话总结: 堆外内存主要是 Lucene 段数据与页缓存,它不受 GC 管理,但会与堆争抢物理内存。

4.1 段数据与页缓存

# 观察节点上的内存使用分布
curl -s "localhost:9200/_nodes/stats/os,process?pretty" | head -60

关键指标是 os.mem.used_percent、process.mem.total_virtual_in_bytes 与 os.cpu.load_average。若 used_percent 长期接近 100% 而堆占用不高,说明页缓存被挤压。

4.2 关闭 swap

# 临时关闭
sudo swapoff -a

# 永久关闭:注释 /etc/fstab 里的 swap 行,并把 vm.swappiness 设为 1
echo 'vm.swappiness=1' | sudo tee -a /etc/sysctl.conf

若因特殊原因无法完全关闭 swap,把 bootstrap.memory_lock: true 打开并配置 memlock 上限,让 JVM 堆锁定在物理内存中:

# elasticsearch.yml
bootstrap.memory_lock: true

4.3 直接内存与 Netty

# 限制直接内存上限,便于定位问题
-XX:MaxDirectMemorySize=1g

若日志里出现 java.lang.OutOfMemoryError: Direct buffer memory,说明堆外缓冲耗尽,通常与并发请求数或响应体积有关。

4.4 内存映射文件计数

sudo sysctl -w vm.max_map_count=262144

这是官方推荐的基线值,容器环境需要在宿主机上设置。

5. 熔断器与内存保护

一句话总结: 熔断器在查询或聚合将要撑爆堆之前主动拒绝请求,是 JVM 的最后一道防线。

5.1 父子熔断器

curl -X PUT "localhost:9200/_cluster/settings" -H 'Content-Type: application/json' -d '
{
  "persistent": {
    "indices.breaker.total.limit": "70%",
    "indices.breaker.request.limit": "40%",
    "indices.breaker.fielddata.limit": "40%",
    "indices.breaker.accounting.limit": "100%"
  }
}'

indices.breaker.total.limit 默认是堆的 70%,限制所有熔断器之和。请求级熔断器限制单次请求可用的堆,防止一个超大聚合把整个节点拖垮。

5.2 触发熔断的表现

# 查看熔断器的当前使用量与触发次数
curl -s "localhost:9200/_nodes/stats/breaker?pretty" | head -60

若某个熔断器的 tripped 计数持续增长,说明有请求在反复撞墙,需要定位是哪些查询。

5.3 与 GC 的联动

默认 70% 对多数场景够用。若集群频繁 Full GC 但熔断器不触发,说明阈值偏高;若正常聚合频繁被拒,说明阈值偏低或该扩堆、扩节点。

5.4 队列与线程池保护

curl -s "localhost:9200/_cat/thread_pool?v&h=node_name,name,active,queue,rejected"

rejected 增长说明请求超过了处理能力,需要考虑扩容或优化查询。

6. OOM 与停顿排查

一句话总结: OOM 与长停顿的排查路径是「看日志、看堆直方图、看 GC 日志、看热点查询」四步。

6.1 识别 OOM 的类型

grep -i "OutOfMemoryError" /var/log/elasticsearch/elasticsearch.log | tail -5
  • Java heap space:堆内对象过多,通常是超大聚合或字段基数失控。
  • Direct buffer memory:堆外缓冲不足,通常是并发请求过多。
  • unable to create new native thread:线程数超限,通常是线程池配置或句柄限制。

6.2 堆转储分析

# jvm.options.d/heapdump.options
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/lib/elasticsearch/dumps

堆转储文件可能非常大(接近堆大小),务必确保磁盘空间充足,并在分析完成后及时清理。

6.3 用 jmap 观察堆分布

# 找到 Elasticsearch 进程号
jps -l | grep Elasticsearch

# 查看类直方图(轻量,可在线上执行)
jmap -histo:live <pid> | head -30

-histo:live 会触发一次 GC,对线上有一定影响,建议在低峰期执行。

6.4 长时间停顿的定位

# 查看最近的 GC 停顿
grep -E "Pause (Young|Full)" /var/log/elasticsearch/gc.log | tail -10

# 查看系统层面的内存压力与 IO 等待
vmstat 1 5
iostat -x 1 3

若 GC 停顿不长但节点仍无响应,问题可能在页缓存抖动(大量随机读盘)或磁盘 IO 饱和,而不是 JVM。

6.5 热点查询与堆占用

# 找出耗时最长的查询
curl -s "localhost:9200/*/_search?pretty" -H 'Content-Type: application/json' -d '
{
  "size": 0,
  "query": { "match_all": {} },
  "aggs": {
    "by_index": { "terms": { "field": "_index", "size": 10 } }
  }
}'

真正吃堆的是高基数 terms 聚合、深分页与脚本排序。把它们改成近似聚合、使用 search_after 分页、避免脚本,往往比调 GC 参数收益大得多。

7. 监控与调优实践

一句话总结: JVM 监控要盯住堆占用、GC 频率与停顿、熔断触发、线程池拒绝四组指标。

7.1 核心监控指标

# 通过 nodes stats 获取 JVM 指标
curl -s "localhost:9200/_nodes/stats/jvm?pretty" | head -50

关键字段:jvm.mem.heap_used_percent、jvm.gc.collectors.young.collection_time_in_millis、jvm.gc.collectors.old.collection_count。

7.2 分角色的参数基线

数据节点(128GB 物理内存)
  -Xms31g -Xmx31g -XX:+UseG1GC -XX:MaxGCPauseMillis=200

主节点(32GB 物理内存)
  -Xms8g -Xmx8g -XX:+UseG1GC

协调节点(64GB 物理内存)
  -Xms16g -Xmx16g -XX:+UseG1GC

7.3 调优的优先级

很多「JVM 问题」的根因不在 JVM:字段映射用了 text 导致 fielddata 爆堆、聚合 size 设为 10000 导致桶对象海量、深分页导致协调节点持有大量文档。这些问题的正解是改查询与映射,而不是加大堆。

7.4 升级与参数复核

Elasticsearch 各版本对 GC 与堆的默认策略持续演进,升级前应查阅对应版本的发布说明,并重新跑一次基准压测验证参数仍然合适。

8. 总结

环节要点
堆内堆外堆放对象,堆外放段与页缓存,两者之和必须小于物理内存
堆大小不超过物理内存 50% 且不超过 31GB,剩余留给页缓存
压缩指针超过约 31GB 失效,32 到 48GB 是最差区间,要么低于要么远超
GC 选型默认 G1 够用,ZGC 停顿更低但需 JDK 17 以上与更多 CPU
GC 日志生产必开并设置滚动,Full GC 出现即需排查
堆外与 swap必须关闭 swap,锁定堆内存,关注 max_map_count 与直接内存
熔断保护父熔断器管总量,子熔断器分管请求与字段数据,429 是保护性失败
OOM 排查先看错误类型,再堆转储与直方图,最后定位热点查询
监控基线盯堆使用率、GC 频率与停顿、熔断触发、线程池拒绝四组指标
调优顺序先查询与数据模型,再分片与线程池,最后才是 JVM 参数

JVM 调优的本质不是把参数调到极致,而是让堆内与堆外各司其职、让 GC 在可控范围内工作、让熔断器在崩溃之前拦住请求。真正省事的做法是把查询与数据模型做对,JVM 参数只需要守住那几条基线。至此,从路由分配到 JVM 调优,这套覆盖写入、查询、备份与运维的专题告一段落,愿它能在你的集群遇到瓶颈时提供一条清晰的排查路径。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「elasticsearch」更多文章

  1. 可搜索快照与冻结层:把冷数据放进对象存储还能查
  2. 分页与深度分页:from/size、search_after、PIT 与 scroll
  3. 嵌套与父子关联查询:nested、join 字段与性能取舍