集群的性能问题最终大多会落到两件事上:磁盘和 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 调优,这套覆盖写入、查询、备份与运维的专题告一段落,愿它能在你的集群遇到瓶颈时提供一条清晰的排查路径。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。