Elasticsearch 查询从 3 秒到 50 毫秒:分片、mapping 与深分页治理实录

商品搜索接口 P99 从 80ms 涨到 3.2s,大促前 ES 集群变 red、查询频繁熔断。一周专项治理:ILM rollover/shrink 重新规划 1840 个分片、mapping 按用途瘦身、精确过滤改 filter、深分页改 search_after、JVM heap 让出 page cache。搜索 P99 稳定 50ms,集群常年 green。

2024 年我们的商品搜索接口越来越慢,P99 从最初的 80ms 一路涨到 3 秒多,大促前一周 Elasticsearch 集群甚至变成了 red 状态,搜索接口大面积超时。排查发现是索引分片设计、mapping 和查询写法多年积累的问题集中爆发。投了一周做 ES 专项治理,重建索引、优化分片与 mapping、改写慢查询,之后搜索 P99 稳定在 50ms,集群常年 green。本文复盘 Elasticsearch 查询优化的完整实战。

问题背景

业务:商品搜索,Elasticsearch 7.17,6 节点,商品索引约 8000 万文档
事故现象:
- 搜索接口 P99 从 80ms → 3.2s
- 大促前集群变 red,部分分片 unassigned
- 查询频繁触发 circuit_breaking_exception(熔断)
- 节点 Old GC 频繁,heap 长期 90%+

现场排查:
# 1. 集群健康
$ curl localhost:9200/_cluster/health?pretty
  "status" : "red",
  "number_of_shards" : 1840,        ← 1840 个分片,严重过多
  "unassigned_shards" : 23

# 2. 看索引分片分布
$ curl localhost:9200/_cat/shards/products?v
  product_v3  0  p  STARTED  410231  62gb     ← 单分片 62GB,过大
  product_v3  1  p  STARTED  408122  61gb

# 3. 慢查询日志里一堆深分页
  "took": 3100, "query": {"from": 100000, "size": 20}

根因:
1. 分片规划失控:按月建索引 + 默认 5 分片,小索引堆出 1840 个分片
2. 单个大索引又只有 2 分片,每个 62GB,远超推荐的 20-50GB
3. mapping 里所有字符串都是 text + keyword 双类型,索引体积翻倍
4. 查询用 query 上下文做精确过滤,本可走 filter 缓存
5. 深分页 from+size,from=10w 时每个分片要取 10w+20 条再汇总
6. JVM heap 设到 31G,但机器只有 32G,没给系统留 buffer

修复 1:分片数规划

# === 分片不是越多越好,每个分片都是一个 Lucene 实例,有固定开销 ===
# 经验值:单分片 20-50GB;集群总分片数 ≤ 节点数 * 20(每 GB heap 约 20 分片)

# === 问题 1:小索引太多分片 → 合并 ===
# 按月建索引 + 每个 5 分片,一年就 60 分片,大多很小
# 解法:用 ILM(索引生命周期管理)+ rollover,按大小滚动而非按时间

PUT _ilm/policy/products_policy
{
  "policy": {
    "phases": {
      "hot": {
        "actions": {
          "rollover": { "max_primary_shard_size": "40gb", "max_age": "30d" }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": { "shrink": { "number_of_shards": 1 } }
      },
      "delete": { "min_age": "180d", "actions": { "delete": {} } }
    }
  }
}
# rollover:分片到 40GB 或 30 天就滚动新索引,大小可控
# shrink:老索引收缩成 1 分片,减少总分片数

# === 问题 2:大索引分片太少太大 → 重建时拆分 ===
# 8000 万文档、120GB,合理是 3 分片(每片 40GB)
PUT product_v4
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "refresh_interval": "30s"
  }
}
# 然后用 reindex 把数据迁过来
POST _reindex
{
  "source": { "index": "product_v3" },
  "dest":   { "index": "product_v4" }
}

# === 用别名做无缝切换,业务零感知 ===
POST _aliases
{
  "actions": [
    { "remove": { "index": "product_v3", "alias": "products" } },
    { "add":    { "index": "product_v4", "alias": "products" } }
  ]
}
# 业务始终查 products 别名,底层索引切换业务无感

修复 2:mapping 优化

// === 错误:所有字符串字段都 text + keyword 双类型,索引体积翻倍 ===
// {
//   "product_name": {
//     "type": "text",
//     "fields": { "keyword": { "type": "keyword" } }
//   }
// }

// === 正确:按字段实际用途精确定义 ===
PUT product_v4/_mapping
{
  "properties": {
    "product_name": {
      "type": "text",                    // 要分词搜索 → text
      "analyzer": "ik_max_word"           // 中文用 IK 分词
    },
    "brand": {
      "type": "keyword"                   // 只做精确过滤/聚合 → 纯 keyword
    },
    "category_id": {
      "type": "integer"                   // 数值就用数值类型,别用 keyword
    },
    "price": {
      "type": "scaled_float",             // 金额用 scaled_float,省空间
      "scaling_factor": 100
    },
    "description": {
      "type": "text",
      "index": false                      // 长文本只存不搜 → index:false
    },
    "internal_remark": {
      "type": "keyword",
      "doc_values": false                 // 不排序不聚合 → 关掉 doc_values
    },
    "create_time": { "type": "date" }
  }
}
// 优化要点:
// 1. 要全文搜 → text;只精确匹配/聚合 → keyword;别无脑双类型
// 2. 数值/日期用对应类型,不要塞进 keyword(范围查询会慢)
// 3. 不参与搜索的字段 index:false,不排序不聚合的关掉 doc_values
// 4. 用 dynamic:strict 防止脏字段自动映射,mapping 失控的常见根源

// === 关掉动态映射,防字段爆炸 ===
PUT product_v4/_settings
{ "index.mapping.total_fields.limit": 200 }

修复 3:查询优化 — filter 与 query

// === 核心认知:query 上下文算相关性评分(_score),filter 不算 ===
// filter 结果可被缓存,且不算分,精确过滤一律用 filter

// 错误:把精确过滤条件全塞进 must(query 上下文,每次都算分)
// {
//   "query": {
//     "bool": {
//       "must": [
//         { "match": { "product_name": "手机" } },
//         { "term": { "brand": "小米" } },          ← 精确过滤,不该算分
//         { "range": { "price": { "gte": 1000 } } } ← 范围过滤,不该算分
//       ]
//     }
//   }
// }

// 正确:全文搜放 must(要算分),精确条件放 filter(不算分 + 可缓存)
POST products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "product_name": "手机" } }
      ],
      "filter": [
        { "term":  { "brand": "小米" } },
        { "term":  { "category_id": 5 } },
        { "range": { "price": { "gte": 1000, "lte": 5000 } } }
      ]
    }
  },
  "_source": ["product_id", "product_name", "price", "brand"],
  "size": 20
}
// 优化点:
// 1. filter 子句结果会被 ES 缓存,重复查询直接命中,极快
// 2. _source 只取需要的字段,别拉回整个大文档
// 3. 不需要评分的纯过滤查询,整个用 constant_score 包起来更快
// === 纯过滤场景:用 constant_score,完全跳过评分 ===
POST products/_search
{
  "query": {
    "constant_score": {
      "filter": {
        "bool": {
          "filter": [
            { "term": { "brand": "小米" } },
            { "term": { "status": 1 } }
          ]
        }
      }
    }
  }
}
// 没有全文搜需求时,这样比 bool+must 快很多

// === 避免的几个慢查询写法 ===
// 1. wildcard 前导通配:{"wildcard":{"name":"*手机"}} → 极慢,改用 ngram
// 2. script 脚本排序/过滤 → 每文档执行脚本,慎用
// 3. 超大 terms:{"terms":{"id":[...5000个...]}} → 拆批或换 join 思路
// 4. 聚合不加 size 限制,默认想返回所有 bucket → 内存爆

修复 4:深分页 — search_after

// === from + size 深分页的代价 ===
// from=100000, size=20:每个分片要取出 from+size=100020 条
// 协调节点再从各分片汇总排序,丢弃前 10 万 → 极慢 + 吃内存
// ES 默认 index.max_result_window=10000,超过直接报错

// === 方案 1:search_after(推荐,无状态游标)===
// 第一页:正常查,排序字段必须能唯一确定顺序(加 _id 兜底)
POST products/_search
{
  "size": 20,
  "query": { "match": { "product_name": "手机" } },
  "sort": [
    { "create_time": "desc" },
    { "_id": "asc" }
  ]
}
// 响应里每条命中带 sort 值,取最后一条的 sort 数组

// 下一页:带上一页最后一条的 sort 值
POST products/_search
{
  "size": 20,
  "query": { "match": { "product_name": "手机" } },
  "sort": [
    { "create_time": "desc" },
    { "_id": "asc" }
  ],
  "search_after": [1717200000000, "Pdjsk2"]
}
// search_after 无论翻到第几页都是恒定开销,不存在"取出再丢弃"

// === 方案 2:scroll(仅用于导出全量,不适合实时翻页)===
// POST products/_search?scroll=2m  → 拿 scroll_id 持续翻
// scroll 会保持快照、占资源,只适合离线导出,别用在用户翻页

// === 方案 3:PIT(Point In Time)+ search_after,翻页期间数据快照一致 ===
// POST products/_pit?keep_alive=1m → 拿 pit id,配合 search_after 用

修复 5:写入、刷新与 JVM

// === 写入优化:批量 + 调刷新间隔 ===
// 1. 用 bulk 批量写,别一条条 index(单条写网络往返开销大)
POST _bulk
{ "index": { "_index": "products", "_id": "1" } }
{ "product_name": "手机A", "price": 2999 }
{ "index": { "_index": "products", "_id": "2" } }
{ "product_name": "手机B", "price": 3999 }
// 每批 5-15MB 为宜,太大反而 GC 压力大

// 2. refresh_interval:默认 1s 刷一次生成新段,写多时调大
PUT products/_settings
{ "index.refresh_interval": "30s" }
// 搜索对实时性要求不高时,30s 刷一次,写入吞吐大幅提升

// 3. 大批量导入时的极限优化:临时关副本
PUT products/_settings
{ "index.number_of_replicas": 0 }      // 导入时设 0
// 导完再设回:{ "index.number_of_replicas": 1 }
# === jvm.options:heap 设置的两条铁律 ===
# 1. heap 不超过物理内存的 50%(另一半留给 Lucene 的 page cache,ES 性能命脉)
# 2. heap 不超过 ~31GB(超过 32GB 失去压缩指针,反而浪费)
-Xms31g
-Xms31g
# 32G 机器其实该设 16g,把另外的内存留给系统文件缓存
# 我们的错误:31g heap + 32g 机器 → 几乎没 page cache,查询全打磁盘

# === 熔断器:防单个大查询打爆 heap ===
# elasticsearch.yml
indices.breaker.total.limit: 70%           # 总熔断阈值
indices.breaker.request.limit: 40%         # 单请求(聚合等)
indices.breaker.fielddata.limit: 30%       # fielddata(text 字段排序的坑)
# fielddata 很危险:对 text 字段排序/聚合会把整列加载进 heap
# 杜绝对 text 排序,要排序的字段用 keyword/数值类型

# === 慢日志:定位慢查询 ===
# PUT products/_settings
# index.search.slowlog.threshold.query.warn: 1s
# index.search.slowlog.threshold.fetch.warn: 500ms

修复 6:监控告警

# elasticsearch_exporter + Prometheus
groups:
- name: elasticsearch
  rules:
  # 1. 集群健康(red 必须立即处理)
  - alert: ESClusterRed
    expr: elasticsearch_cluster_health_status{color="red"} == 1
    for: 1m
    annotations:
      summary: "{{ $labels.cluster }} 集群 RED,有主分片不可用"

  # 2. 未分配分片
  - alert: ESUnassignedShards
    expr: elasticsearch_cluster_health_unassigned_shards > 0
    for: 5m
    annotations:
      summary: "{{ $labels.cluster }} 有 {{ $value }} 个未分配分片"

  # 3. JVM heap 使用率
  - alert: ESHeapHigh
    expr: |
      elasticsearch_jvm_memory_used_bytes{area="heap"}
      / elasticsearch_jvm_memory_max_bytes{area="heap"} > 0.85
    for: 5m
    annotations:
      summary: "{{ $labels.name }} heap > 85%,排查大查询/fielddata"

  # 4. 查询熔断触发
  - alert: ESCircuitBreaker
    expr: increase(elasticsearch_breakers_tripped[5m]) > 0
    annotations:
      summary: "{{ $labels.name }} 熔断器触发,有查询超内存预算"

  # 5. 查询延迟
  - alert: ESQuerySlow
    expr: |
      rate(elasticsearch_indices_search_query_time_seconds[5m])
      / rate(elasticsearch_indices_search_query_total[5m]) > 0.5
    for: 5m
    annotations:
      summary: "{{ $labels.name }} 平均查询耗时 > 500ms"

  # 6. 单节点分片数过多
  - alert: ESTooManyShards
    expr: elasticsearch_node_shards_total > 600
    for: 10m
    annotations:
      summary: "{{ $labels.name }} 单节点分片 > 600,考虑合并/收缩"

优化效果

指标                      治理前          治理后
=============================================================
搜索接口 P99              3.2s            50ms
集群状态                  red             green(稳定)
集群总分片数              1840            约 280
单分片大小                最大 62GB       均匀 30-40GB
索引体积                  480GB           260GB(mapping 瘦身)
深分页查询(第 5000 页)   3.1s            45ms(search_after)
JVM heap 使用率           90%+ 长期       55% 平稳
查询熔断                  频繁            消失
filter 缓存命中率         未用            91%

压测(搜索 1.2 万 QPS):
- 治理前:大量超时,集群濒临崩溃
- 治理后:P99 50ms,heap 平稳,无熔断

排查与改造:
- 集群健康 + 慢日志定位:1 天
- 重新规划分片 + reindex 重建索引:2 天
- mapping 优化 + 查询改写:2 天
- search_after 改造分页接口:1 天
- 压测验证:1 天

避坑清单

  1. 分片不是越多越好,每个分片有固定开销,单片控制在 20-50GB
  2. 小索引用 ILM rollover 按大小滚动,老索引 shrink 收缩减少分片
  3. 重建索引用别名切换,业务始终查别名,底层切换零感知
  4. 字符串字段按用途定类型,要搜用 text、精确匹配用 keyword,别双类型
  5. 不搜的字段 index:false,不排序不聚合的关 doc_values,省体积
  6. 精确/范围过滤一律放 filter 子句,可缓存且不算分,纯过滤用 constant_score
  7. 深分页禁用 from+size,实时翻页用 search_after,导出用 scroll
  8. JVM heap 不超物理内存 50%、不超 31GB,另一半留给 page cache
  9. 绝不对 text 字段排序聚合,会触发 fielddata 把整列加载进 heap
  10. 集群健康、heap、熔断、查询延迟、单节点分片数全要上监控

总结

这次 Elasticsearch 治理让我明白,ES 用着简单,用好却很考验对它底层机制的理解。最大的认知改变是关于分片:很多人下意识觉得"分片多 = 并行度高 = 快",但每个分片本质是一个独立的 Lucene 索引,有自己的内存占用、文件句柄和元数据开销,分片过多会让集群元数据管理不堪重负——我们 1840 个分片里大部分都是几百 MB 的小分片,纯属浪费;而另一个极端,单分片 62GB 又太大,查询和恢复都慢。分片规划的核心就是把单片控制在 20-50GB 这个区间,小了合并、大了拆分。第二个深刻的体会是 query 和 filter 的区别——这是 ES 查询优化里收益最高的一招:query 上下文要计算相关性评分,filter 上下文不算分而且结果能被缓存,商品搜索里"品牌等于小米""价格在某区间"这种精确条件根本不需要评分,把它们从 must 挪到 filter,既省下了算分的 CPU,又能吃到缓存,效果立竿见影。第三是深分页,from+size 的本质是"取出 from+size 条再丢弃前 from 条",翻得越深浪费越大,search_after 用上一页的排序值作为游标,把"翻页"变成"定位",代价恒定。最后一个最容易被忽视、却最致命的是 JVM heap 的设置——ES 的查询性能极度依赖操作系统的文件缓存来缓存 Lucene 段文件,如果把 heap 设到物理内存的 90%,就等于把 ES 性能的命脉给掐了,heap 只该占一半,剩下一半必须留给 page cache。ES 的优化没有银弹,但把分片、mapping、查询写法、JVM 这四件事做对,性能差距就是几十倍。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

秒杀被黑产刷崩了下单:Sentinel 限流、熔断、热点防护实战

2026-5-20 12:26:03

技术教程

Kafka 消费者陷入 rebalance 风暴:消息积压与重平衡治理实录

2026-5-20 12:31:04

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索