Elasticsearch索引优化与集群调优实战
发布日期: 2026/08/16 阅读总量: 0

凌晨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/s2300 bulk/s
拒绝率(429)12%0.1%
P99写入延迟386ms96ms
bulk平均大小5MB20MB
refresh间隔1s30s

查询性能

指标改造前改造后
平均查询延迟420ms88ms
P99查询延迟3200ms260ms
分片数(单索引)5020
段数量(单索引)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上消耗掉大量时间。