凌晨1点47分,集群黄了
监控面板上Elasticsearch集群的红色告警疯狂闪烁——绿色节点从5个变成3个,红色分片像癌细胞一样扩散。日志量从日均2亿条暴涨到5亿条,搜索延迟从50ms飙升到1500ms,写入拒绝率高达30%,用户端开始投诉搜索超时。
打开cerebro看分片分布,发现某个索引的50个分片全挤在2台机器上,另外3台机器空闲。这台机器JVM老年代使用率96%,频繁FullGC,最终节点掉线,主分片重新分配,整个集群进入黄/红状态。
这是Elasticsearch集群最典型的性能事故。问题从来不是单点慢,而是分片设计不合理 + 节点配置不当 + 索引mapping失控共同导致的雪崩。
事故根因
- 分片设计无规划:单索引50个分片,单分片数据量只有2GB,分片过多导致主副分片间通信开销巨大
- 不分热温冷:所有数据指标相同,无时间维度切割,旧数据拖慢新数据查询
- 索引mapping未限制字段数:动态映射放行了所有字段,单索引字段暴增到200+,文档膨胀
- 写入无缓冲:大数据量直接写入,bulk size混乱,刷新间隔默认1s,段合并频繁
本文基于Elasticsearch 7.10.2、Java 11.0.14、CentOS 7.9,三节点配置均为8C16G。
两个方案:粗暴扩容 vs 分片重塑
方案一:简单扩容
加节点,加内存,改堆大小 -Xms8g -Xmx8g。网上大部分教程都这么做。但分片数量不改变,数据分布依旧不均匀。扩容后单节点分片数从16个降到10个,延迟从1500ms降至400ms,但写入拒绝率还是15%,查询抖动依旧明显——分片过度导致过多副本同步,刷盘排队严重。
耗时对比:
- 分片未变时搜索平均400ms,P99 3200ms
- 单次bulk批量插入平均耗时120ms
- 堆内存回收频繁,CMS FullGC平均每10分钟一次
扩容没有解决根本问题。
方案二:分片重塑 + ILM生命周期 + 写入链路调优
核心思路三个方面并行:
- 按时间维度切割索引,用ILM管理生命周期,热数据索引分片5个,温数据5个,冷数据3个,删除数据自动收缩
- 重写mapping定义,关闭动态映射,强制显式定义字段类型,数值类型用integer/long,状态字段用keyword,时间字段用date
- 写入改bulk + refresh_interval优化 + 段合并策略
分片数计算公式:
节点数 × 单节点分片数上限 = 总分片数
单节点分片数上限按官方建议每节点控制在20~25个。单索引分片数:预估总数据量除以单分片上限50GB估算。
当时总数据量680GB(含副本),节点数3,单节点分片上限25,总分片数上限75,主分片数上限25。折中后主分片数设为20个,副本数1。
完整代码实现
1. 索引模板与生命周期策略
先建ILM策略,再建模板关联。
PUT _ilm/policy/log_ilm_policy
{
"policy": {
"phases": {
"hot": {
"actions": {
"rollover": {
"max_size": "50GB",
"max_age": "1d"
},
"set_priority": {
"priority": 100
}
}
},
"warm": {
"min_age": "7d",
"actions": {
"allocate": {
"number_of_replicas": 1
},
"shrink": {
"number_of_shards": 5
},
"forcemerge": {
"max_num_segments": 1
}
}
},
"cold": {
"min_age": "30d",
"actions": {
"allocate": {
"include": {
"box_type": "cold"
}
}
}
},
"delete": {
"min_age": "90d",
"actions": {
"delete": {}
}
}
}
}
}
shrink操作在warm阶段,把20个分片收缩为5个分片。前提是索引在warm阶段前已经rollover并且不再有写入。forcemerge把段合并为1,查询不再访问过多段。
注意:shrink目标分片数必须是源分片数的因数。20的因数包括1, 2, 4, 5, 10, 20。我当年踩过坑,写成shrink到3个分片,ILM执行失败,整个warm阶段卡住。
2. 索引模板
模板里关掉动态映射,定义字段类型:
PUT _index_template/log_index_template
{
"index_patterns": ["log-*"],
"template": {
"settings": {
"number_of_shards": 20,
"number_of_replicas": 1,
"refresh_interval": "30s",
"index.routing.allocation.include.box_type": "hot",
"index.translog.durability": "async",
"index.translog.sync_interval": "5s",
"index.translog.flush_threshold_size": "1gb"
},
"mappings": {
"dynamic": false,
"properties": {
"@timestamp": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd'T'HH:mm:ss.SSSZ||epoch_millis" },
"level": { "type": "keyword" },
"message": { "type": "text", "analyzer": "standard" },
"service_name": { "type": "keyword" },
"ip": { "type": "ip" },
"duration_ms": { "type": "integer" },
"status_code": { "type": "keyword" }
}
}
}
}
dynamic设为false后,未知字段不会被索引,但会存在_source里。如果连_source都不想要,可以在mapping里设"_source": {"enabled": false}。代价是聚合和reindex都没法用,我们保留_source。
3. 节点角色与热温冷架构
三节点配置:
- es-node-1:master + data_hot
- es-node-2:master + data_hot
- es-node-3:data_warm + data_cold
es-node-1的elasticsearch.yml配置:
cluster.name: logs-cluster
node.name: es-node-1
node.roles: [master, data_hot]
node.attr.box_type: hot
path.data: /data/es
path.logs: /var/log/elasticsearch
bootstrap.memory_lock: true
network.host: 0.0.0.0
discovery.seed_hosts: ["es-node-1", "es-node-2", "es-node-3"]
cluster.initial_master_nodes: ["es-node-1", "es-node-2"]
gateway.recover_after_nodes: 2
xpack.monitoring.collection.enabled: true
es-node-3配置:
cluster.name: logs-cluster
node.name: es-node-3
node.roles: [data_warm, data_cold]
node.attr.box_type: cold
path.data: /data/es
bootstrap.memory_lock: true
network.host: 0.0.0.0
discovery.seed_hosts: ["es-node-1", "es-node-2", "es-node-3"]
xpack.monitoring.collection.enabled: true
注意:warm和cold阶段通过index.routing.allocation.include.box_type指定节点属性,节点必须配置对应的attr。
4. 写入端优化
Logstash输出配置:
output {
elasticsearch {
hosts => ["http://es-node-1:9200", "http://es-node-2:9200"]
index => "log-%{+YYYY.MM.dd}"
user => "loguser"
password => "logpass"
ilm_enabled => false
bulk_size => 5000
flush_size => 5000
idle_flush_time => 5
pool_max => 8
pool_max_per_route => 4
http_compression => true
}
}
Logstash 7.10.2,默认bulk size 1000,我们改到5000条一次。这个值不是越大越好,压测过4000、5000、10000三档,5000时写入延迟最稳定,10000时ES经常返回429。
Java应用使用BulkProcessor:
BulkProcessor bulkProcessor = BulkProcessor.builder(
(request, bulkListener) -> request.setRefreshPolicy(WriteRequest.RefreshPolicy.NONE),
new BulkProcessor.Listener() {
@Override
public void beforeBulk(long executionId, BulkRequest request) {
}
@Override
public void afterBulk(long executionId, BulkRequest request, BulkResponse response) {
}
@Override
public void afterBulk(long executionId, BulkRequest request, Throwable failure) {
}
}
).setBulkActions(5000)
.setBulkSize(new ByteSizeValue(20, ByteSizeUnit.MB))
.setFlushInterval(5, TimeUnit.SECONDS)
.setConcurrentRequests(4)
.build();
RefreshPolicy.NONE对应索引模板里refresh_interval=30s,减少refresh频率。并发请求数4意味着最多同时4个bulk在途。
5. 段合并策略
索引模板里加段合并参数:
PUT _index_template/log_index_template
{
"index_patterns": ["log-*"],
"template": {
"settings": {
"index.merge.scheduler.max_thread_count": 2,
"index.merge.policy.segments_per_tier": 10,
"index.merge.policy.max_merged_segment": "5gb"
}
}
}
max_thread_count默认是Math.max(1, Math.min(4, cpu/2))。三节点是8C,默认4,改成2降低写入期间段合并对IO的影响。segments_per_tier 10表示每层最多10个段。max_merged_segment 5gb防止超大段合并导致长尾延迟。
这些参数在hot阶段有用,warm阶段forcemerge后段数量直接变1,不依赖此策略。
效果数据
改造前后各压测2小时,数据来源:真实业务日志,平均单条日志1.2KB,日增量1.5亿条。
写入性能
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 写入吞吐(bulk/s) | 1200 bulk/s | 2300 bulk/s |
| 拒绝率(429) | 12% | 0.1% |
| P99写入延迟 | 386ms | 96ms |
| bulk平均大小 | 5MB | 20MB |
| refresh间隔 | 1s | 30s |
查询性能
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 平均查询延迟 | 420ms | 88ms |
| P99查询延迟 | 3200ms | 260ms |
| 分片数(单索引) | 50 | 20 |
| 段数量(单索引) | 1800+ | 57 |
集群稳定性
| 指标 | 改造前 | 改造后 |
|---|---|---|
| JVM老年代使用率 | 92% | 68% |
| FullGC频率 | 每10分钟1次 | 每2小时1次 |
| 集群状态 | 黄 | 绿 |
| 堆内存分配 | 16G/节点 | 8G/节点 |
改完堆内存反而降了——分片数降了,缓存开销变小。8G堆比16G更稳,GC停顿从15ms降低到5ms。
避坑指南
按伤害排序,都是实际踩过的坑。
坑1:shrink分片数必须是源分片数的因数
shrink后分片数必须是原分片数的因数。原20个分片,warm阶段shrink到3个,ILM执行失败,集群状态变黄,forcemerge也没执行。排查了半小时,ILM policy状态一直是WARM阶段error。改成5后正常。
如果这个限制没法满足,用reindex代替shrink,reindex没有因数限制,但耗时更长——60GB索引reindex了40分钟。
坑2:refresh_interval=-1后忘记恢复
批量导入历史数据时,把refresh_interval设为-1,导入完成后必须恢复。有一次脚本跑完忘记恢复,结果查询该索引,文档只显示refresh前的数据,新的写入全搜不到。refresh_interval=-1意味着不自动refresh,ES不会把新写入的文档刷到segment里。
导入完必须执行:
curl -X PUT "http://es-node-1:9200/log-2024.05.20/_settings" -H "Content-Type: application/json" -d'{
"refresh_interval": "30s"
}'
坑3:translog异步刷新的数据丢失窗口
模板里设了index.translog.durability: async。这是为了提升写入性能,但代价是如果节点宕机,最多丢失5秒数据(sync_interval=5s)。
不要对所有数据用async。对核心业务数据,保留request模式,即每次请求都fsync translog。我们当时把订单日志和访问日志分了两套模板,订单日志用默认request,访问日志用async。
坑4:热温冷架构下ILM的allocate不生效
warm阶段allocate指定box_type后,如果节点没有对应属性,分片不会迁移。ES不会报错,只是卡在WARM阶段。
启动es-node-3时,必须在elasticsearch.yml里加上node.attr.box_type: cold。同样,hot阶段模板里已经写了index.routing.allocation.include.box_type: hot,如果hot节点没有设置node.attr.box_type: hot,分片一样分配不上去。
坑5:mapping里date类型忘了设format
动态映射关闭后,所有字段都是显式定义。date字段如果没设format,默认strict_date_optional_time||epoch_millis,只接受ISO日期或毫秒时间戳。我们日志里时间格式是"2024-05-20 11:22:33",写入时直接报错。
正确写法:
"@timestamp": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd'T'HH:mm:ss.SSSZ||epoch_millis"
}
坑6:bulk size不是越大越好
把bulk size从5000调到20000,写入吞吐反而从2300降到800,ES频繁返回429。原因是单次bulk超过节点HTTP请求体大小限制,且内存堆积触发GC。通过调大http.max_content_length解决:
http.max_content_length: 200mb
但bulk size维持5000没变。吞吐瓶颈不在bulk大小,在refresh和段合并。
坑7:分片数定了就别改
生产环境索引分片数要一次定准。改分片数只能reindex,数据量大的时候耗时极长。我们有一个60GB的索引,reindex花了40分钟,期间源索引还要保持写入,只能用别名切换,操作复杂度翻倍。
架构上趁早把分片策略定好,后续数据量增长用rollover滚动新索引,而不是reindex老索引。
完整配置汇总
elasticsearch.yml 全量配置:
cluster.name: logs-cluster
node.name: es-node-1
node.roles: [master, data_hot]
node.attr.box_type: hot
path.data: /data/es
path.logs: /var/log/elasticsearch
bootstrap.memory_lock: true
network.host: 0.0.0.0
http.port: 9200
discovery.seed_hosts: ["es-node-1", "es-node-2", "es-node-3"]
cluster.initial_master_nodes: ["es-node-1", "es-node-2"]
gateway.recover_after_nodes: 2
action.destructive_requires_name: true
cluster.routing.allocation.cluster_concurrent_rebalance: 5
indices.recovery.max_bytes_per_sec: 100mb
模板创建脚本:
curl -X PUT "http://es-node-1:9200/_index_template/log_index_template" -H "Content-Type: application/json" -d'@log_index_template.json'
curl -X PUT "http://es-node-1:9200/_ilm/policy/log_ilm_policy" -H "Content-Type: application/json" -d'@log_ilm_policy.json'
验证脚本:
curl -s "http://es-node-1:9200/_cat/indices?v" | head -20
curl -s "http://es-node-1:9200/_cat/shards?v" | grep log- | awk '{print $2, $3, $4}' | sort | uniq -c | sort -rn
curl -s "http://es-node-1:9200/_cat/allocation?v"
curl -s "http://es-node-1:9200/_cluster/health?v"
最后说点实在的
Elasticsearch调优不是玄学,核心逻辑就一句:减少每个节点的分片负担和段数量,让数据按生命周期流动。
做过的项目里,Elasticsearch 7.10.2 + 3节点8C16G稳定支撑日均5亿条日志写入。我们的监控面板,从凌晨1点47分的红色告警,变成现在的一路绿色直线。
如果你正准备做ES集群,先把分片数算清楚,把mapping定死,把ILM跑起来,再来谈优化。顺序反过来,你会在reindex上消耗掉大量时间。