2024 年我们的 Elasticsearch 集群存了 80 亿日志,每天新增 5 亿,搜索 P99 涨到 8s,写入也开始顶不住,磁盘 70% 告警。投了一个月做索引设计 + 分片优化 + 冷热分离 + 查询调优,P99 从 8s 降到 200ms,磁盘节约 40%,写入 QPS 从 5w 提到 15w。本文复盘 ES 优化全套手段。
问题背景
集群:Elasticsearch 7.17,12 节点(6 主 + 6 数据 + 3 协调)
机器:32C 128G,2TB NVMe x 6
索引:logs-* (每天 1 个,80 亿文档)
分片:每索引 24 主分片 + 1 副本
存储:80 亿 × 3KB = 240TB(含副本)
性能问题:
- 搜索 P99 8s,关键词搜索 30s+
- 写入吞吐 5w/s,峰值打不住
- 磁盘 70% 告警
- 查询 OOM:大聚合直接挂
需要全面优化
优化 1:索引设计
PUT _index_template/logs-template
{
"index_patterns": ["logs-*"],
"template": {
"settings": {
"number_of_shards": 12,
"number_of_replicas": 1,
"refresh_interval": "30s",
"translog.durability": "async",
"translog.sync_interval": "10s",
"translog.flush_threshold_size": "1gb",
"index.codec": "best_compression",
"index.merge.policy.max_merge_at_once": 5,
"index.merge.policy.segments_per_tier": 5,
"index.queries.cache.enabled": true,
"index.requests.cache.enable": true,
"index.search.idle.after": "30s",
"index.lifecycle.name": "logs-policy"
},
"mappings": {
"dynamic": "strict",
"properties": {
"@timestamp": { "type": "date" },
"level": { "type": "keyword" },
"service": { "type": "keyword" },
"trace_id": { "type": "keyword", "doc_values": false },
"user_id": { "type": "long" },
"message": {
"type": "text",
"analyzer": "standard",
"fields": {
"keyword": { "type": "keyword", "ignore_above": 256 }
}
},
"host": { "type": "keyword" },
"ip": { "type": "ip" },
"duration_ms": { "type": "integer" },
"tags": { "type": "keyword" }
}
}
}
}
// 关键点:
// 1. dynamic: strict - 不让自动加字段(避免 mapping 膨胀)
// 2. 不分词字段用 keyword,需要全文搜的用 text + multi-field
// 3. trace_id 不需要聚合 → doc_values: false 节省 30% 空间
// 4. best_compression 压缩节省 60% 磁盘
优化 2:分片策略
分片数计算原则:
- 单分片大小:20-50GB(过小:协调开销;过大:慢)
- 总分片数 ≤ 节点数 × 1000(Cluster level limit)
- 主分片数:数据量 / 30GB
我们的情况:
- 单日数据:5 亿 × 1.5KB(压缩后)= 750GB
- 单分片目标 30GB → 25 个主分片
- 副本 1,总 50 分片 / 天
- 6 数据节点,每节点 8 分片(均衡)
错误做法:
- 单索引一年:9000 亿数据 + 100 个分片(单分片 9TB,完全没法管)
- 按用户分:每用户 1 索引(分片数爆炸)
正确做法:
- 按天分:logs-2024-03-15(25 分片)
- 老数据滚动归档(冷热分离)
优化 3:冷热分离(ILM)
PUT _ilm/policy/logs-policy
{
"policy": {
"phases": {
"hot": {
"actions": {
"rollover": {
"max_size": "750gb",
"max_age": "1d",
"max_docs": 500000000
},
"set_priority": { "priority": 100 }
}
},
"warm": {
"min_age": "7d",
"actions": {
"allocate": {
"include": { "data_tier": "warm" },
"number_of_replicas": 0
},
"forcemerge": { "max_num_segments": 1 },
"shrink": { "number_of_shards": 6 },
"set_priority": { "priority": 50 }
}
},
"cold": {
"min_age": "30d",
"actions": {
"allocate": {
"include": { "data_tier": "cold" }
},
"freeze": {},
"searchable_snapshot": {
"snapshot_repository": "s3-repo"
}
}
},
"delete": {
"min_age": "180d",
"actions": {
"delete": {}
}
}
}
}
}
// 节点角色配置(elasticsearch.yml)
// 热节点(NVMe SSD,32C 128G)
node.roles: [data_hot, data_content]
// 温节点(SATA SSD,16C 64G)
node.roles: [data_warm]
// 冷节点(机械盘,8C 32G)
node.roles: [data_cold]
优化 4:写入吞吐
// 客户端 Bulk 写入
RestHighLevelClient client = ...;
BulkProcessor bulkProcessor = BulkProcessor.builder(
(request, listener) -> client.bulkAsync(request, RequestOptions.DEFAULT, listener),
new BulkProcessor.Listener() {
public void beforeBulk(long executionId, BulkRequest request) {}
public void afterBulk(long executionId, BulkRequest request, BulkResponse response) {
if (response.hasFailures()) {
log.error("bulk failures: {}", response.buildFailureMessage());
}
}
public void afterBulk(long executionId, BulkRequest request, Throwable failure) {
log.error("bulk exception", failure);
}
})
.setBulkActions(5000) // 5000 条 flush
.setBulkSize(new ByteSizeValue(10, ByteSizeUnit.MB)) // 或 10MB
.setFlushInterval(TimeValue.timeValueSeconds(5)) // 5s 强 flush
.setConcurrentRequests(4) // 4 个并发 bulk
.setBackoffPolicy(BackoffPolicy.exponentialBackoff(TimeValue.timeValueMillis(100), 5))
.build();
// 配合 ES 端
PUT logs-*/_settings
{
"refresh_interval": "30s", // 默认 1s 太频繁
"index.translog.durability": "async",
"index.translog.sync_interval": "10s",
"index.translog.flush_threshold_size": "1gb",
"index.number_of_replicas": 0 // 写入期间临时去副本
}
// 写完恢复副本
PUT logs-*/_settings
{
"index.number_of_replicas": 1,
"refresh_interval": "5s"
}
// 效果:
// - bulk 5w → 15w/s
// - CPU 利用率从 60% 提到 85%
// - 副本暂时关闭,写入快 2x
优化 5:查询优化
// 错误 1:使用 wildcard 慢查询
GET logs-*/_search
{
"query": {
"wildcard": { "message": "*error*" } // 全文档扫描,慢
}
}
// 正确:用 match
GET logs-*/_search
{
"query": {
"match": { "message": "error" } // 用倒排索引
}
}
// 错误 2:深度分页
GET logs-*/_search
{
"from": 100000, "size": 20 // 协调节点要排序 100020 条,慢且 OOM
}
// 正确:search_after / scroll(导出场景)
GET logs-*/_search
{
"size": 20,
"query": {...},
"sort": [{"@timestamp": "desc"}, {"_id": "asc"}],
"search_after": ["2024-03-15T10:00:00", "doc-id-12345"]
}
// 错误 3:大聚合无 size
GET logs-*/_search
{
"size": 0,
"aggs": {
"by_user": { "terms": { "field": "user_id" } } // 默认 10,但全文档扫
}
}
// 改进:加 filter 缩范围
GET logs-*/_search
{
"size": 0,
"query": {
"bool": {
"filter": [
{ "range": { "@timestamp": { "gte": "now-1h" } } },
{ "term": { "service": "order-api" } }
]
}
},
"aggs": {
"by_user": { "terms": { "field": "user_id", "size": 100 } }
}
}
// 错误 4:should 没 minimum_should_match
"should": [..., ..., ...] // 默认任一匹配即可,但计算所有 score(慢)
// 改:用 filter 替代(不算 score)
"filter": [...]
// 或 minimum_should_match: 1 + bool 拆开
优化 6:JVM + 系统参数
# jvm.options
-Xms31g
-Xmx31g # 不超过 32G(压缩指针失效)
-XX:+UseG1GC
-XX:G1HeapRegionSize=32m
-XX:G1ReservePercent=25
-XX:InitiatingHeapOccupancyPercent=30
# /etc/sysctl.conf
vm.swappiness = 1
vm.max_map_count = 262144
# /etc/security/limits.conf
elasticsearch soft nofile 65536
elasticsearch hard nofile 65536
elasticsearch soft memlock unlimited
elasticsearch hard memlock unlimited
# elasticsearch.yml
bootstrap.memory_lock: true # 禁 swap
thread_pool.write.queue_size: 1000
thread_pool.search.queue_size: 1000
indices.queries.cache.size: 10%
indices.fielddata.cache.size: 20%
indices.requests.cache.size: 2%
indices.memory.index_buffer_size: 30%
优化 7:Mapping 瘦身
// 默认 mapping 浪费严重:
// 1. text 字段默认带 norms(查询长度归一化),日志不用
// 2. keyword 默认有 doc_values(用于聚合排序),不聚合的字段关掉
// 3. _source 可关(但失去原文)
// 4. _all(7.x 已删)
PUT logs-template
{
"mappings": {
"properties": {
"message": {
"type": "text",
"norms": false, // 关掉 norms 省 30% 磁盘
"index_options": "freqs" // 只存词频,不存位置(短语搜索不可用)
},
"trace_id": {
"type": "keyword",
"doc_values": false // 不参与聚合,关 doc_values
},
"raw_data": {
"type": "text",
"index": false // 不索引,只存储
},
"debug_info": {
"enabled": false // 完全不索引不存储(占位字段)
}
},
"_source": {
"enabled": true,
"excludes": ["debug_*"] // 不存某些字段(节省存储)
}
}
}
// 效果:
// - mapping 优化后,80 亿日志从 240TB → 95TB
// - 写入快 25%(少计算 norms / doc_values)
压测 + 优化效果
指标 优化前 优化后 变化
=====================================================
搜索 P50 1.2s 50ms -96%
搜索 P99 8s 200ms -97%
写入吞吐 5w/s 15w/s +200%
磁盘占用 240TB 95TB -60%
节点 CPU 80% 40% -50%
JVM GC pause p99 300ms 30ms -90%
集群健康 黄色频繁 绿色 稳定
业务影响:
- 日志搜索从"基本不能用"到"秒级响应"
- 节省 60% 存储成本(年省 ¥300w)
- 大促日志洪峰能扛住
监控告警
# Prometheus + elasticsearch_exporter
- alert: ESClusterRed
expr: elasticsearch_cluster_health_status{color="red"} == 1
for: 1m
- alert: ESJvmHeapHigh
expr: elasticsearch_jvm_memory_used_bytes / elasticsearch_jvm_memory_max_bytes > 0.85
for: 5m
- alert: ESDiskHigh
expr: 1 - elasticsearch_filesystem_data_available_bytes / elasticsearch_filesystem_data_size_bytes > 0.80
for: 5m
- alert: ESPendingTasks
expr: elasticsearch_cluster_pending_tasks > 100
for: 5m
- alert: ESSlowSearch
expr: rate(elasticsearch_indices_search_query_time_seconds[5m]) > 1
for: 5m
避坑清单
- 单分片 20-50GB,不要太小也不要太大
- dynamic mapping 必须 strict,避免字段爆炸
- 不聚合的 keyword 关 doc_values,省 30% 空间
- refresh_interval 写多读少调到 30s
- 写入期间关副本,完成后再开
- ILM 必上,冷热分离省钱
- JVM heap ≤ 31G(压缩指针),不要给到 64G
- 查询用 filter 替代 query 部分(可缓存)
- 深分页用 search_after,不要 from+size
- 慢查询日志开,定期分析 top N
总结
Elasticsearch 是个看起来"装上就能用"实则"用不好就翻车"的工具。这次 80 亿日志的优化让我深刻理解:ES 性能是设计出来的,不是调出来的。Mapping 一开始就要严格,分片数一开始就要算对,后期改这些都很贵。冷热分离 + ILM 是省钱关键,180 天的日志在 S3 上 vs 在 NVMe 上,差 100 倍成本。最大的认知改变:ES 不是数据库,不要拿它当 OLTP 用 — 大聚合慢、深度分页慢、频繁更新慢,这是架构决定的。日志、搜索、推荐场景才是 ES 的真主场。如果你的 ES 用得很难受,大概率是用错了场景,该换工具(ClickHouse / Doris)就换。
—— 别看了 · 2026