一、凌晨2点的磁盘告警
运维把电话打到我手机上时,Elasticsearch 7.10.2 集群的 data 节点磁盘使用率已经 97%,再往上就是 read_only_allow_delete。3 天前刚扩过盘,128GB 又满了。
查了日志,有个订单索引 order_v1 每天写入 5 亿条,单条文档 800 字节,但磁盘上实际占了 2.4KB —— 放了 3 倍。查询那边也好不到哪去:按订单号精确查询 P99 是 820ms,超时率 47%,监控图上通红一片。
这个索引当初图省事,直接 PUT 的时候没写 mapping,全靠 ES 默认动态映射。现在线上在跑,订单查询接口一个接一个 504,用户开始投诉。
这篇文章就讲我怎么把这块烂摊子收拾干净的。整个过程包括:两种方案的对比 → 实现代码 → 效果数据 → 我踩过的坑。版本是 ES 7.10.2,JDK 11,3 个 data 节点(16C/64G,SSD)。
二、问题拆解:ES 为什么吃掉了 3 倍磁盘
先看根因。ES 的默认动态映射(dynamic mapping)对每个 string 字段会同时生成 text 和 keyword 两份索引。加上 _source 存原文、doc_values 默认开启、所有字段进倒排索引,三重叠加,磁盘和内存全在燃烧。
更蠢的是,订单数据里有个 remark 字段,存用户备注,最长 2000 字。这字段被默认映射成了 text,做全文索引分词。但业务上根本没人搜备注 —— 查订单都是按 order_id、user_id、status 查的。
再看写入。默认 refresh_interval 是 1s,也就是每秒刷一次段,生成一个小段文件。5 亿条 × 每秒一次,一天下来段文件数量奔着几万去,合并线程被拖死,写入吞吐卡在 3 万条/秒,查询还要扫描这些碎段。
三个核心问题明确:
- 无脑动态映射:text + keyword 双份索引,倒排膨胀
- doc_values 全开:所有字段都生成列存,包括哪些根本不做排序聚合的字段
- refresh 太频繁 + 分片规划不合理:段数量爆炸,合并抢占 I/O
三、两种方案对比
方案 A:不改 mapping,只加节点 + 定期 force_merge
最简单的做法,加机器,或者等业务低峰期刷 force_merge 合并段。这个方案的优点是不用动代码,但治标不治本:
- 磁盘继续涨 —— source 和 doc_values 该占的还占着
- force_merge 要占额外 1 倍磁盘空间做合并操作,128G 的盘根本腾不出来
- 查询慢的问题完全没解决 —— 倒排索引还是那么大,P99 还是 800ms +
方案 B:重建 mapping + 索引模板 + 集群层调优
从根上解决。新建 order_v2,映射精确到每个字段:能 keyword 的不用 text,不需要排序聚合的关掉 doc_values,不能搜的字段直接 "index": false,_source 去掉大字段。同时把 refresh_interval 调到 30s,分片数按实际数据量重算。配合索引模板,以后新建索引不再犯同样的错。
代价是:需要全量 reindex 一次。但对 5 亿条数据来说,凌晨 3 小时窗口完全够跑。
| 对比项 | 方案 A(加节点 + force_merge) | 方案 B(重建 mapping + 调优) |
|---|---|---|
| 磁盘占用 | 不变,仍然 2.4KB/条 | 降到 960B/条,减少 60% |
| 查询 P99 | 仍然 800ms+ | 降至 93ms |
| 写入吞吐 | 3 万/秒 | 4.8 万/秒 |
| 运维成本 | 每 3 天扩一次盘 | 一劳永逸,配额改成 30 天 |
四、完整实现
4.1 先分析现有数据的字段分布
动手之前,先摸清楚现有索引到底有哪些字段,避免新 mapping 漏字段。用 _field_caps API 统计一下:
# 查看现有索引所有字段的类型分布
curl -s -X POST "http://localhost:9200/order_v1/_field_caps?fields=*" | \
jq '.fields | keys | .[]' | wc -l # 一共多少个字段
curl -s -X POST "http://localhost:9200/order_v1/_field_caps?fields=*" | \
jq '.fields | to_entries[] | {field: .key, types: [.value | .[] | .type]}'
4.2 重建 mapping(核心)
# 创建优化后的索引,mapping 精确到字段
curl -s -X PUT "http://localhost:9200/order_v2" -H 'Content-Type: application/json' -d '{
"settings": {
"number_of_shards": 9,
"number_of_replicas": 1,
"refresh_interval": "30s",
"merge.scheduler.max_thread_count": 4,
"index.routing.allocation.total_shards_per_node": 4,
"codec": "best_compression"
},
"mappings": {
"date_detection": false,
"dynamic": "strict",
"properties": {
"order_id": {
"type": "keyword",
"doc_values": false,
"index": true
},
"user_id": {
"type": "keyword",
"doc_values": true
},
"status": {
"type": "keyword",
"doc_values": true
},
"sku_id": {
"type": "keyword",
"doc_values": false
},
"amount": {
"type": "double"
},
"pay_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
},
"create_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
},
"province": {
"type": "keyword",
"doc_values": false
},
"city": {
"type": "keyword",
"doc_values": false
},
"channel": {
"type": "keyword",
"doc_values": true
},
"remark": {
"type": "text",
"index": false,
"doc_values": false,
"norms": false
}
},
"_source": {
"includes": ["order_id", "user_id", "status", "sku_id", "amount", "pay_time", "create_time", "province", "city", "channel"]
}
}
}'
几个关键配置解释一下:
dynamic: strict:禁止未知字段写入,从源头杜绝映射爆炸。不知道字段的直接报错,宁可报错也别悄悄创建无用的 text+keyword。codec: best_compression:LZ4 换成 ES 的 best_compression 压缩(基于 Lucene 的 LZ4 + Huffman 变种),对 SSD 上的写入影响不大,但对日志这种文本类数据压缩率提升明显。refresh_interval: 30s:查询延迟多 29 秒,换来段数量减少 30 倍。_source.includes:大字段 remark 从 _source 里排除出去,减少磁盘和写入 I/O。order_id的doc_values: false:没人按 order_id 做聚合排序,关闭列存。注意,keyword 可以做 doc_values false 的同时 index true,因为倒排索引和列存是两个独立结构。
4.3 用 Reindex 迁移数据
# 从旧索引 reindex 到新索引,带 pipeline 剥离 _source 大字段
curl -s -X PUT "http://localhost:9200/order_v2/_settings" -H 'Content-Type: application/json' -d '{
"refresh_interval": "-1"
}'
# 异步 reindex,注意用 wait_for_completion=false 回传 task id
curl -s -X POST "http://localhost:9200/_reindex?wait_for_completion=false" -H 'Content-Type: application/json' -d '{
"source": {
"index": "order_v1",
"_source": ["order_id", "user_id", "status", "sku_id", "amount", "pay_time", "create_time", "province", "city", "channel", "remark"]
},
"dest": {
"index": "order_v2",
"version_type": "external"
},
"script": {
"lang": "painless",
"source": "ctx._source.remove(\"remark\")"
}
}'
reindex 完查看 Task 状态:
curl -s "http://localhost:9200/_tasks?actions=*reindex&detailed" | jq '.nodes | .. | .description? // empty'
验证数据量和数据一致性:
# 对比新旧索引文档数
curl -s "http://localhost:9200/order_v1/_count" | jq '.count'
curl -s "http://localhost:9200/order_v2/_count" | jq '.count'
4.4 切换别名(零停机)
线上不能停。老规矩,用 alias 切换,先给新索引加 alias,再原子性地把旧别名移除。
# 把 order_read 和 order_write 指向新索引
curl -s -X POST "http://localhost:9200/_aliases" -H 'Content-Type: application/json' -d '{
"actions": [
{ "remove": { "index": "order_v1", "alias": "order_read" } },
{ "add": { "index": "order_v2", "alias": "order_read" } },
{ "add": { "index": "order_v2", "alias": "order_write" } }
]
}'
切换完跑一条生产查询验证:
curl -s -X POST "http://localhost:9200/order_read/_search" -H 'Content-Type: application/json' -d '{
"query": {
"term": { "order_id": "SO20241230123456789" }
},
"track_total_hits": false,
"size": 1
}' | jq '{ took, hits: .hits.total, first: .hits.hits[0]._source }'
4.5 索引模板防止再犯
光修一个索引没用。新接入的业务方还是按默认模板建索引。写一个索引模板,锁定所有新索引的默认行为:
curl -s -X PUT "http://localhost:9200/_index_template/order_template" -H 'Content-Type: application/json' -d '{
"index_patterns": ["order_*", "log_*"],
"template": {
"settings": {
"number_of_shards": 9,
"number_of_replicas": 1,
"refresh_interval": "30s",
"codec": "best_compression"
},
"mappings": {
"dynamic": "strict",
"date_detection": false
}
},
"priority": 100
}'
这个模板只锁了全局默认,业务方必须显式声明字段映射,不然写入直接报 mapping exception。强制他们做正确的事。
五、集群层调优
索引层面搞定后,集群层还有三个配置值得改:
5.1 线程池与队列
# elasticsearch.yml 关键配置
thread_pool:
write:
size: 32
queue_size: 2000
search:
size: 64
queue_size: 3000
write 线程池默认 size = CPU 核数,把 size 调大到 32,配合 2000 的队列,能把写入吞吐喂上去。注意 queue_size 别拉太大,否则节点 OOM 或者 GC 时间飙升。
5.2 段合并策略
refresh 30s 一次后,段数量少了很多。但合并线程的配置也需要跟着改。ES 默认的 merge 线程数 = max(1, min(4, CPU/2)),在 16C 的机器上只开 4 个,合并大段容易卡 I/O。我调成 6 个,同时把 indices.merge.scheduler.auto_throttle 关掉,手动限制速率:
# 动态设置,不用重启
curl -s -X PUT "http://localhost:9200/_cluster/settings" -H 'Content-Type: application/json' -d '{
"persistent": {
"indices.merge.scheduler.max_thread_count": 6,
"indices.merge.scheduler.auto_throttle": false,
"indices.merge.scheduler.max_merge_count": 6
}
}'
这里的关键是:合并是 I/O 密集操作,SSD 和机械盘的表现完全不同。SSD 上可以放心调大线程数,机械盘就别动了,否则查询会被合并拖死。
5.3 堆内存与 GC
ES 节点 64G 内存,堆给了 31G(默认是物理内存一半,但我们有别的组件在跑)。把 GC 日志打开,观察老年代回收频率:
# 在 jvm.options 里加 GC 日志
# -Xms31g
# -Xmx31g
# 8:-Xlog:gc*,gc+age=trace,safepoint:file=/var/log/elasticsearch/gc.log:utctime,pid,tags:filecount=8,filesize=64m
# 查看最近 GC 情况
grep -c "Pause Full" /var/log/elasticsearch/gc.log | tail -1
调优之后 Full GC 基本上 10 分钟一次,每次 200ms 以内。之前每次 Full GC 2-3 秒,查询直接超时。
六、效果数据
以上改动上线后,我跑了 3 天的稳定对比。数据如下:
| 指标 | 调优前(order_v1) | 调优后(order_v2) | 提升幅度 |
|---|---|---|---|
| 单文档磁盘占用 | 2.4 KB | 960 B | -60% |
| 索引总大小(5 亿条) | 1.2 TB | 480 GB | -720 GB |
| 查询 P99(order_id 精确查询) | 820 ms | 93 ms | -88.7% |
| 查询 P50 | 120 ms | 18 ms | -85% |
| 查询超时率(1s 阈值) | 47% | 0.2% | — |
| 写入吞吐(bulk 批量) | 3 万条/s | 4.8 万条/s | +60% |
| 段文件数量 | 128,000 | 3,200 | -97.5% |
| Full GC 频率 | 每 5 分钟一次 | 每 10 分钟一次 | — |
| Full GC 平均耗时 | 2.3s | 180ms | — |
查询的压测方式是:用 esrally 跑 term 查询,50 并发,压 30 分钟,取 P50/P99。写入用自研的 bulk 脚本,每批 5,000 条,10 个并发,跑 1 小时取平均值。
磁盘告警这事到此结束。128GB 的盘在调优后能撑 30 天,比之前的 3 天强了一个数量级。
七、避坑指南(全是我踩过的)
以下是这轮优化过程中踩过的坑,按严重程度排序。每个都真实发生过。
坑 1:reindex 时忘了去掉大字段
reindex 的 _source 过滤写错了,少写了 remark,导致迁移后的 order_v2 里 _source 中带着完整的 2KB 备注字段。诡异的是 mapping 里并没有这个字段 —— ES 的 _source 是独立的原始文档存储,mapping 不可能拦住它。
我的教训:reindex 的源 filtering 和 script 双击都要检查,而且一定要在 reindex 完后抽样对比新旧文档大小。命令:
# 抽样对比新旧索引文档大小
curl -s "http://localhost:9200/order_v1/_search?size=1&filter_path=hits.hits._source" | jq '.hits.hits[0]._source | tostring | length'
curl -s "http://localhost:9200/order_v2/_search?size=1&filter_path=hits.hits._source" | jq '.hits.hits[0]._source | tostring | length'
坑 2:dynamic: strict 上线后写入报错
dynamic 设置成 strict 后,如果业务方没按 mapping 规范传字段,直接报 strict_dynamic_mapping_exception。第一天线上告警了 8 次。后来我加了一项兜底:把 dynamic 设成 false 而不是 strict。区别:strict 是报错,false 是忽略未知字段。对于订单这种核心数据要严谨,应该用 strict;但对于日志这种长期写入的索引,用 false 更稳妥,配合告警观察。
坑 3:force_merge 之后磁盘不减反增
在方案 A 里试过 _forcemerge?max_num_segments=1,跑完之后发现磁盘占用从 87% 升到 93%。原因:force_merge 会先把 5 万个段合并成 1 个大段,这个过程中所有段数据全量写一遍,需要额外的临时空间。在没有 1 倍剩余磁盘空间的条件下,别干这件事。
坑 4:codec 改成 best_compression 后查询变慢
best_compression 压缩率高,但 decompress 的 CPU 开销也高。改完之后 P99 反而从 100ms 涨到了 140ms。后来在 16C 的 data 节点上把 search 线程池调大到 64 才压回去。如果你对查询延迟极其敏感,codec 保持 LZ4 默认即可,不要跟风用 best_compression。
坑 5:refresh_interval 调大后,同一秒写入的文档查不到
改配置之前没通知业务方,导致运营后台的「实时订单」页面出现了延迟数据。refresh_interval=30s 意味着文档最大要等 30 秒才可见。对这个业务来说其实可以接受,但前提是提前告知。另外,ES 的 refresh 是全局的,调大后所有索引都会受影响,注意评估别的业务。
坑 6:分片数拍脑袋定了 9 个,结果不均匀
分片数 = 9 是根据 3 个数据节点 × 每个节点 3 个分片算的。但集群里还有其他业务索引占着节点,导致某些节点上的分片数远高于 3。后来用 index.routing.allocation.total_shards_per_node=4 强行限制每个节点上的分片总数,数据重新平衡后才稳定。
八、效果总结
三个月后再看这次优化的收益:
- 磁盘容量从 3 天告警一次变成 30 天告警一次,扩盘周期放宽了一个数量级
- 查询超时从 47% 降到 0.2%,接口 504 基本消失
- 写入吞吐量从 3 万条/s 提升到 4.8 万条/s,业务高峰期不再堆积
- 段合并的 I/O 抢占比从 65% 降到 20%,查询和写入的互相影响明显减小
这套优化方案的核心逻辑可以总结成一句话:不用的数据不存,不搜的字段不索引,不聚合的字段不开 doc_values,不实时的查询可以忍受 30 秒 refresh。ES 不是全能的关系型数据库,它会把你的每个字段都当宝贝供起来,但你得告诉它哪些值得供。
以后接入新的业务线,先把 mapping 设计评审过了再给权限。模板已配好,剩下的是人的问题。