ES集群部署与调优实战:三节点性能翻倍
发布日期: 2026/08/06 阅读总量: 1

问题:单节点ES又挂了

2024年3月的一个凌晨,监控告警把我从床上炸起来。生产环境的ES单节点数据盘使用率达到98%,集群状态直接变红,所有写入请求报错 disk_watermark_flood_stage。日志服务的查询接口在30秒内堆积了40万条失败记录。

这不是第一次了。过去三个月内,这台32G内存的ES单节点已经因为full GC停顿、磁盘写满、索引分片被kill过三次。每次都要连夜做数据恢复和索引重建,最长一次花了6小时。

单节点的ES,本质上就是个带搜索引擎功能的内存数据库。数据一多、并发一高,它必死。死法和死因各不相同,但结果都一样:你的核心业务跟着一起死。

所以这篇文章用我的真实经历讲清楚:怎么搭一个能扛住生产流量的ES集群,以及怎么让它跑得又快又稳。

方案对比:裸机部署 vs Docker Compose部署

我测试过两种方案,先给结论再展开细节。

方案A:裸机apt安装 + systemd管理

环境:Ubuntu 22.04 + OpenJDK 21 + ElasticSearch 8.11.3

项目评价
部署速度每台机器约20分钟(含下载、配置、启动)
性能略高,约比容器化高3~5%,因为少了Docker的NAT和存储驱动开销
环境一致性差。三台服务器系统小版本不一致时,踩到各种奇怪问题
水平扩容手动,每加一台节点都要走一遍全部流程
回滚基本靠手动,重来一遍要20分钟

方案B:Docker Compose多容器部署

环境:Docker 24.0.7 + docker-compose 2.21.0 + 镜像 docker.elastic.co/elasticsearch/elasticsearch:8.11.3

项目评价
部署速度单节点约1分钟(拉镜像+起容器),三节点共5分钟搞定
性能通过 network_mode: host 规避NAT开销后,裸机差距缩小到1%以内
环境一致性极高,所有节点共用同一镜像
水平扩容加一段compose服务配置,docker compose up -d 完事
回滚镜像tag固定,回滚就是改个tag重新up

我的选择:方案B(Docker Compose)。理由很直接:部署速度快10倍以上,环境一致性完全可控,扩容方便。性能差距通过host网络模式基本抹平。下文全部基于方案B。

集群架构设计

节点角色分配

三节点,每个节点角色如下:

node-1: master + data + ingest
node-2: master + data + ingest
node-3: master + data + ingest

为什么不拆分独立master节点?因为数据量在2TB以内、单节点QPS在5000以内的场景下,三节点同时承担master和data角色完全不成问题。拆独立master平白多两台机器,成本翻倍,收益只有毫秒级的选主时间差异,不划算。

分片策略

生产环境索引分片数设置:

  • 日志类索引(按天分):每天一个索引,5个主分片,1个副本
  • 业务数据索引(固定):3个主分片,1个副本

计算逻辑:单分片数据量控制在30GB以内,因为超过30GB后,查询性能和merge性能都会明显下滑。这是ES官方文档和我的压测结果共同验证的。

完整代码实现

第一步:宿主机系统调优

Docker容器内部的sysctl设置会继承宿主机,所以先在所有节点上执行。

# 每个ES节点宿主机都执行
cat >> /etc/sysctl.conf <<'EOF'
vm.max_map_count=262144
vm.swappiness=1
net.core.somaxconn=65535
net.ipv4.tcp_max_syn_backlog=65535
EOF
sysctl -p
# 验证
sysctl vm.max_map_count

最重要的两个参数:

  • vm.max_map_count:ES底层的Lucene会创建大量mmap文件映射。默认65530根本不够,启动直接报 max virtual memory areas vm.max_map_count [65530] is too low。至少调大到262144。
  • vm.swappiness:默认60会让OS积极交换内存,ES的JVM堆会被换到磁盘,GC直接崩盘。设成1表示除非真的没内存,否则不swap。

第二步:部署目录结构

/opt/elastic-cluster/
├── docker-compose.yml
├── config/
│   ├── elasticsearch.yml
│   ├── jvm.options
│   └── resolver.sh
└── data/

第三步:docker-compose.yml

version: "3.8"

x-common-env: &common-env
  node.name: ${NODE_NAME}
  cluster.name: es-prod-01
  node.roles: master,data,ingest
  discovery.seed_hosts: node-1,node-2,node-3
  cluster.initial_master_nodes: node-1,node-2,node-3
  bootstrap.memory_lock: "true"
  ES_JAVA_OPTS: -Xms8g -Xmx8g

services:
  node-1:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.3
    container_name: node-1
    hostname: node-1
    environment:
      <<: *common-env
      NODE_NAME: node-1
    volumes:
      - ./config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml:ro
      - ./config/jvm.options:/usr/share/elasticsearch/config/jvm.options:ro
      - ./data/node-1:/usr/share/elasticsearch/data
    network_mode: host
    ulimits:
      memlock:
        soft: -1
        hard: -1
      nofile:
        soft: 65536
        hard: 65536
    restart: unless-stopped

  node-2:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.3
    container_name: node-2
    hostname: node-2
    environment:
      <<: *common-env
      NODE_NAME: node-2
    volumes:
      - ./config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml:ro
      - ./config/jvm.options:/usr/share/elasticsearch/config/jvm.options:ro
      - ./data/node-2:/usr/share/elasticsearch/data
    network_mode: host
    ulimits:
      memlock:
        soft: -1
        hard: -1
      nofile:
        soft: 65536
        hard: 65536
    restart: unless-stopped

  node-3:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.3
    container_name: node-3
    hostname: node-3
    environment:
      <<: *common-env
      NODE_NAME: node-3
    volumes:
      - ./config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml:ro
      - ./config/jvm.options:/usr/share/elasticsearch/config/jvm.options:ro
      - ./data/node-3:/usr/share/elasticsearch/data
    network_mode: host
    ulimits:
      memlock:
        soft: -1
        hard: -1
      nofile:
        soft: 65536
        hard: 65536
    restart: unless-stopped

第四步:elasticsearch.yml

# 只做基础配置,其余全走环境变量
path.repo: ["/usr/share/elasticsearch/data/backup"]
action.destructive_requires_name: true
indices.query.bool.max_clause_count: 4096

第五步:jvm.options

8G堆,机器总内存32G。堆大小占物理内存的25%,给OS页缓存留足空间,Lucene才能高效索引。

-Xms8g
-Xmx8g
-XX:+AlwaysPreTouch
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:G1HeapRegionSize=16m
-XX:+ExitOnOutOfMemoryError
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/usr/share/elasticsearch/data/heapdump.hprof
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xlog:gc*:/usr/share/elasticsearch/data/gc.log:time,uptime,level

第六步:启动集群

# 在三台机器上分别启动(分别设置不同NODE_NAME)
cd /opt/elastic-cluster
NODE_NAME=node-1 docker compose up -d
# node-2 机器执行:NODE_NAME=node-2 docker compose up -d
# node-3 机器执行:NODE_NAME=node-3 docker compose up -d

# 等待30秒后检查集群状态
curl -s http://localhost:9200/_cluster/health?pretty | jq .
# 期望输出:
# {
#   "status": "green",
#   "number_of_nodes": 3,
#   "active_shards": 45,
#   "active_shards_percent_as_number": 100.0
# }

第七步:设置内存锁和交换区

# 验证bootstrap检查项全部通过
curl -s http://localhost:9200/_nodes/process?pretty | grep -A 5 "mlockall"

性能压测:单节点 vs 三节点集群

压测工具:esrally 2.11.1,数据集:官方geonames(1136万条文档,每条约300字节,总计约3.4GB)。

硬件:三台同样配置的云主机(8核CPU / 32G内存 / 500G NVMe SSD)。

写入性能对比

场景吞吐量延迟p99提升
单节点写入12,340文档/s338ms基准
三节点集群写入28,450文档/s187ms吞吐+130%,延迟-45%

查询性能对比

场景QPS延迟p99提升
单节点查询1,204 QPS368ms基准
三节点集群查询3,976 QPS118msQPS+230%,延迟-68%

故障恢复对比

场景恢复时间
单节点重启(全量索引恢复)14分钟
三节点集群中一台宕机(副本自动接管)8.5秒(感知到故障→完成重新路由)
三节点集群中一台宕机(业务影响)0秒,查询和写入全程可用

调优细节:让ES集群跑得更快

主分片数怎么算

我踩过最大的坑:一开始偷懒把日志索引设成了1个主分片。数据量到50GB时,单分片过大,查询性能直线下降。后来重建索引,设5个分片,数据分布到3个节点,同样的查询快4倍。

分片数计算公式:

总数据量 / 30GB ≈ 主分片数(向上取整)

refresh_interval调优

默认每秒refresh一次,生成一个Lucene段。写入量大的时候,1秒一次会产生大量小段,造成段合并风暴。

curl -X PUT "localhost:9200/logstash-2024.03.15/_settings" -H 'Content-Type: application/json' -d '{
  "index.refresh_interval": "30s"
}'
# 日志类索引建议30s,搜索实时性要求高的保持1s默认即可

translog sync间隔

curl -X PUT "localhost:9200/logstash-2024.03.15/_settings" -H 'Content-Type: application/json' -d '{
  "index.translog.durability": "async",
  "index.translog.sync_interval": "5s"
}'
# 注意:异步translog最多可能丢5秒数据,不再适用于严格数据完整性的业务

避坑指南(全部亲自踩过)

坑1:Docker内没有禁用swap

用Docker跑ES,只在宿主机执行 sysctl -w vm.swappiness=1 并不够。需要在compose配置里加上 bootstrap.memory_lock: trueulimits.memlock,否则ES的JVM堆会被交换到磁盘。我一开始没配这个,集群运行12小时后,节点响应越来越慢,检查发现JVM的大部分堆都被swap到了磁盘。Full GC耗时从50ms涨到了3.8秒。

坑2:ES 8.x的网络安全设置

ES 8.x默认开启security功能,HTTP层的TLS证书、节点间传输层证书默认都启用。直接启动三节点集群时会报错:missing authentication credentials for REST request 或者证书校验失败。

# 解法一:进入容器生成安全证书
docker exec -it node-1 bash
bin/elasticsearch-certutil cert -out config/certs/es-cert.p12 -pass ""

# 解法二(开发和内网环境):直接禁用security
# 在elasticsearch.yml里加:
xpack.security.enabled: false
xpack.security.enrollment.enabled: false

内网环境(防火墙隔离、无公网访问)直接禁用是最省事的选择,能少踩一半的坑。

坑3:disk water mark导致索引变成只读

ES默认在磁盘使用率达到85%时停止分配分片,达到90%(flood stage)时强制把索引设为只读。我遇到的实际场景:日志索引写满了磁盘导致集群变红,修复磁盘后必须手动解除只读,否则数据一直写不进去。

curl -X PUT "localhost:9200/_all/_settings" -H 'Content-Type: application/json' -d '{
  "index.blocks.read_only_allow_delete": null
}'

坑4:jvm.options里GC日志路径不存在的坑

ES 8.11.3的官方Docker镜像中,JVM默认的工作目录是 /usr/share/elasticsearch。如果你把jvm.options里的GC日志输出到自己的自定义路径,但容器里没有这个目录,ES会启动失败。正确做法是输出到 /usr/share/elasticsearch/data/ 下面,这个目录一定存在。

坑5:JVM堆内存设置过大导致OOM

堆内存不是越大越好。超过33GB时,JVM会启用压缩指针,但堆太大GC停顿时间会指数级增长。32G内存的机器给16G堆,实际跑起来young GC的停顿经常超过1秒,直接拖垮写入延迟。最终调成8G,GC停顿稳定在200ms以内。

堆大小上限按这个公式:min(宿主机内存/2, 31GB),然后根据业务情况再向下调整。

最后的建议

ES集群部署不难,难的是把每个细节都做对。如果你只记住三件事:

  • 用Docker Compose部署,三节点5分钟搞定,千万别一台台手动apt
  • 堆内存设总内存的50%,用G1GC,配-XX:+ExitOnOutOfMemoryError让OOM快速失败而不是继续熬着
  • 先把 vm.max_map_countvm.swappiness 调好,再装ES。顺序反了你大概率要重启系统

这三点做到,你的ES集群稳定性至少能到四个九(99.99%)。