ES索引优化实战:mapping设计让磁盘降60%
发布日期: 2026/08/20 阅读总量: 0

一、凌晨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 字段会同时生成 textkeyword 两份索引。加上 _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_iddoc_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 设计评审过了再给权限。模板已配好,剩下的是人的问题。