问题:单节点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文档/s | 338ms | 基准 |
| 三节点集群写入 | 28,450文档/s | 187ms | 吞吐+130%,延迟-45% |
查询性能对比
| 场景 | QPS | 延迟p99 | 提升 |
|---|---|---|---|
| 单节点查询 | 1,204 QPS | 368ms | 基准 |
| 三节点集群查询 | 3,976 QPS | 118ms | QPS+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: true 和 ulimits.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_count和vm.swappiness调好,再装ES。顺序反了你大概率要重启系统
这三点做到,你的ES集群稳定性至少能到四个九(99.99%)。