搜索接口越来越慢:一次 ElasticSearch 慢查询优化的复盘

商品搜索接口 P99 从几十毫秒涨到两三秒,高峰偶发超时。我先入为主想加机器,profile 之后发现问题全在查询写法和索引设计上。几天彻底治理:slowlog+profile 定位、from+size 深翻页换 search_after、过滤条件移入 filter context、聚合用 keyword 治理 fielddata、分片数收敛、mapping 优化。

2024 年我们的商品搜索接口被用户投诉"越来越慢"。这个接口背后是一个 ElasticSearch 集群,索引里有几千万条商品文档。刚上线那会儿搜索是很快的,几十毫秒就返回,可随着数据量涨上来、查询花样越加越多,它的 P99 耗时一路爬到了两三秒,高峰期还会偶发超时。运营那边催得紧,我先入为主地以为是集群机器不够、得加节点,真要加机器之前,我决定先把慢查询好好分析一遍——结果发现,问题几乎全出在查询写法和索引设计上,跟机器多少没多大关系。投了几天把 ES 慢查询彻底治理了一遍,本文复盘这次实战。

问题背景

业务:商品搜索,ES 集群,索引约 4000 万文档
事故现象:
- 搜索接口 P99 从几十毫秒涨到 2~3 秒
- 高峰期偶发超时
- 翻页翻到后面明显更慢
- ES 节点 CPU、old GC 在查询高峰时飙升

现场排查:
# 1. 开慢查询日志后,捞到的典型慢查询
GET /product/_search
{
  "from": 9980, "size": 20,            // 翻到很后面的页
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "无线蓝牙耳机" } },
        { "match": { "brand": "小米" } },        // 精确值也用了 match
        { "range": { "price": { "gte": 100 } } },// 范围条件放进了 must
        { "term": { "status": 1 } }
      ]
    }
  },
  "aggs": {
    "by_cat": { "terms": { "field": "category", "size": 1000 } }
  }
}

# 2. 用 profile 看耗时分布
"profile": true
# 结果:大量时间花在 scoring(算分)和 deep pagination 上

# 3. 看索引设置
"number_of_shards": 50          # 4000 万文档,开了 50 个分片
# 单分片平均才 80 万文档,分片过多

根因:
1. from+size 深翻页,from 9980 要求每个分片都取前 1 万条
2. 精确匹配的字段(brand/status)用了会算分的 match
3. 范围、状态这些"过滤"条件全塞进 must,每条都参与算分
4. terms 聚合 size=1000,且字段是 text 类型,开销巨大
5. 分片数严重偏多,4000 万文档开了 50 个分片

修复 1:用 profile 和 slowlog 把慢查询找出来

=== 第一步:打开 ES 的慢查询日志 ===
给索引设置 search 慢日志阈值(超过就记录):
PUT /product/_settings
{
  "index.search.slowlog.threshold.query.warn": "1s",
  "index.search.slowlog.threshold.query.info": "500ms",
  "index.search.slowlog.threshold.fetch.warn": "500ms"
}
注意 ES 把一次搜索分成两个阶段,慢日志也分开记:
- query 阶段:在各分片上找出匹配的文档 id + 算分
- fetch 阶段:根据 id 取回文档的完整 _source
两个阶段哪个慢,优化方向完全不同。

=== 第二步:对可疑查询开 profile ===
在查询体里加 "profile": true,ES 会返回这次查询
在每个分片、每个步骤(build_scorer、score、
next_doc...)上花了多少纳秒。
它能精确告诉你:时间到底耗在算分、还是耗在
取文档、还是耗在聚合上。

=== 第三步:看清"慢"的类型 ===
ES 慢查询大体分几类,对症才能下药:
1. 深翻页慢   -> from+size 太大
2. 算分慢     -> 该用 filter 的地方用了 query
3. 聚合慢     -> 高基数字段聚合 / size 过大 / text 字段聚合
4. 分片问题   -> 分片过多(协调开销)或过少(单片过大)
5. mapping 问题 -> 字段类型选错,如该 keyword 用了 text

别一上来就加机器。先 profile,先定位,
绝大多数 ES 慢查询是"写法和设计"问题,不是"资源"问题。

修复 2:深翻页——from+size 换 search_after

// === from+size 为什么慢 ===
// 一个查询 from=9980, size=20,你以为只要 20 条。
// 但 ES 是分布式的:它要从【每一个分片】上,
// 各取出前 from+size = 10000 条,
// 汇总到协调节点,再统一排序,丢弃前 9980 条,留 20 条。
// 50 个分片 x 每片 1 万条 = 协调节点要处理 50 万条!
// from 越大,这个数字越夸张。这和 MySQL 的
// LIMIT offset 深分页,是同一个病。

// === ES 甚至直接给 from+size 设了硬上限 ===
// index.max_result_window 默认 10000,
// from+size 超过 1 万,ES 直接报错拒绝 ——
// 这是它在提醒你:别用 from+size 做深翻页。

// === 正解:search_after,游标式翻页 ===
// 第一页:正常查,排序里必须带一个唯一字段(如 id)做兜底
GET /product/_search
{
  "size": 20,
  "query": { "match": { "title": "蓝牙耳机" } },
  "sort": [
    { "_score": "desc" },
    { "id": "asc" }              // 唯一字段,保证排序确定
  ]
}
// 记下最后一条文档返回的 sort 值,如 [12.7, 88012]

// 下一页:带上 search_after,从上一页末尾接着取
GET /product/_search
{
  "size": 20,
  "query": { "match": { "title": "蓝牙耳机" } },
  "sort": [ { "_score": "desc" }, { "id": "asc" } ],
  "search_after": [12.7, 88012]      // 上一页最后一条的 sort 值
}
// 它让每个分片只需"从上次位置往后取 20 条",
// 无论翻到第几页,耗时恒定 —— 和 MySQL 游标分页同理。

// === scroll 是另一回事 ===
// scroll 适合"导出全量数据"的离线遍历,
// 它会维持一个快照、有上下文开销,不适合在线翻页。
// 在线翻页用 search_after,数据导出用 scroll(或 PIT)。

修复 3:能 filter 的别 query——算分是有代价的

// === query context vs filter context ===
// ES 的查询条件分两种"上下文":
// - query context:回答"这个文档有多匹配?"——要【算分】,
//   算出一个 _score,用于相关性排序。
// - filter context:回答"这个文档符不符合?"——只要
//   yes/no,【不算分】,而且结果可以被【缓存】。

// === 关键区别 ===
// 算分(scoring)是实打实的 CPU 开销。
// 而像"状态=1""价格>=100""品牌=小米"这种条件,
// 它们是【精确的过滤】,根本不存在"有多匹配"的问题,
// 让它们参与算分,就是纯粹的浪费。

// === 错误写法:所有条件都堆在 must(query context)===
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "蓝牙耳机" } },     // 这个该算分
        { "match": { "brand": "小米" } },          // 精确值,不该算分
        { "range": { "price": { "gte": 100 } } },  // 过滤,不该算分
        { "term": { "status": 1 } }                // 过滤,不该算分
      ]
    }
  }
}

// === 正确写法:真正要相关性的留 must,其余进 filter ===
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "蓝牙耳机" } }       // 只有它需要算分
      ],
      "filter": [
        { "term":  { "brand": "小米" } },           // 精确匹配用 term
        { "range": { "price": { "gte": 100 } } },   // 范围过滤
        { "term":  { "status": 1 } }                // 状态过滤
      ]
    }
  }
}
// 改完的收益:
// 1. filter 里的条件不算分,CPU 开销直接降下来
// 2. filter 结果会被 ES 缓存(filter cache),
//    像 status=1 这种高频过滤,第二次几乎零成本
// 3. 精确字段从 match 换成 term,避免不必要的分词匹配

// === 一条经验 ===
// 写 ES 查询时先问自己:这个条件是要"相关性",
// 还是只要"过滤"?只要过滤,一律放 filter。
// 大多数业务查询里,真正需要算分的条件只有一两个。

修复 4:聚合慢——管住基数和 size

// === 聚合为什么会很慢 ===
// terms 聚合要把字段的每个不同取值都统计一遍。
// 它的开销,和这个字段的【基数】(不同值的数量)
// 以及你要的 size 强相关。

// === 坑 1:对 text 类型字段做聚合 ===
// text 字段是被分词的,聚合 text 字段不仅语义错误
// (你会按"分出来的词"聚合,不是按原值),
// 还需要打开 fielddata —— 它把数据加载到堆内存,
// 极易引发 old GC 甚至 OOM。
// 正解:聚合、排序、精确匹配,都用 keyword 类型字段。
// 一般给字段同时建 text 和 keyword 两个版本:
{
  "category": {
    "type": "text",
    "fields": { "keyword": { "type": "keyword" } }
  }
}
// 搜索用 category,聚合用 category.keyword。

// === 坑 2:size 给太大 ===
// "terms": { "field": "category.keyword", "size": 1000 }
// size=1000 意味着要算出并返回前 1000 个分桶。
// 实际页面可能只展示前 20 个分类。
// 把 size 收敛到真正需要的数量。

// === 坑 3:高基数字段做 terms 聚合 ===
// 对 user_id、order_no 这种几千万不同值的字段
// 做 terms 聚合,内存和 CPU 都会爆。
// 高基数场景改用 composite 聚合(分页式聚合),
// 或 cardinality 聚合(只要个数,不要明细)。

// === 坑 4:聚合和查询绑死 ===
// 很多页面"商品列表"和"分类筛选项"是一起返回的。
// 但分类筛选项往往变化很慢,没必要每次搜索都实时算。
// 可以把聚合结果单独缓存,或异步预计算。

// === 控制聚合的影响范围 ===
// 若只要聚合、不要文档列表,设 "size": 0,
// 让 ES 跳过 fetch 阶段,只跑聚合。

修复 5:分片设计——不是越多越好

=== 我们的错:4000 万文档开了 50 个分片 ===
当初想着"分片多 = 并行度高 = 快",于是分片开得很多。
实际单分片平均才 80 万文档,每个分片都很小。

=== 分片过多的代价 ===
1. 每个分片本质是一个独立的 Lucene 索引,
   它有自己的内存、文件句柄、段(segment)开销。
   分片越多,这些固定开销叠加起来越可观。
2. 一次查询要在【所有分片】上执行,再由协调节点
   合并结果。分片越多,协调、合并的开销越大。
3. 集群元数据(cluster state)膨胀,主节点压力大。

=== 分片过少的代价 ===
1. 单分片过大(几十 GB 以上),查询慢、恢复慢
2. 并行度不足,大查询无法充分利用多节点

=== 经验值 ===
- 单个分片大小,建议控制在 10~50GB 区间
- 分片总数和节点数匹配,别让分片数远超节点数
- 我们 4000 万文档、约 60GB,合理分片数是
  3~5 个,而不是 50 个

=== 分片数不能直接改,怎么办 ===
主分片数在索引创建后【不能修改】。调整办法:
1. 新建一个分片数合理的索引
2. 用 reindex API 把老索引数据迁过去
3. 用别名(alias)做无缝切换:
   POST /_aliases
   { "actions": [
       { "remove": { "index": "product_old", "alias": "product" }},
       { "add":    { "index": "product_new", "alias": "product" }}
   ]}
   应用始终查别名 product,底层索引切换对应用透明。

=== 经验 ===
分片设计要在【建索引之前】就按数据规模规划好。
"分片越多越快"是个常见的错觉。

修复 6:mapping 和查询的其它优化

// === 优化 1:不需要检索的字段,别浪费在索引上 ===
// 有些字段只是"存着展示用",从不参与搜索/过滤/排序。
// 给它设 "index": false,省去建倒排索引的开销和空间。
{
  "detail_html": { "type": "text", "index": false }
}

// === 优化 2:只取需要的字段,别每次拉完整 _source ===
// fetch 阶段取回完整 _source 是有成本的,文档大时更明显。
// 用 _source 过滤,只取页面真正要的字段:
{
  "_source": ["id", "title", "price", "cover"],
  "query": { "match": { "title": "耳机" } }
}

// === 优化 3:precision_threshold 控制 cardinality 精度 ===
// cardinality 聚合是近似的,精度越高越耗内存,
// 按业务能接受的精度设置,别盲目求精确。

// === 优化 4:善用 filter 缓存的"稳定 key" ===
// filter 缓存是按"条件"缓存的。如果 filter 里写了
// "now-1h" 这种每次都变的值,缓存永远命不中。
// 把时间范围对齐到整点/整天,让 filter 条件稳定下来,
// 缓存才能真正发挥作用。

// === 优化 5:routing —— 让查询只打到一个分片 ===
// 如果数据有天然的隔离维度(如按 seller_id),
// 写入和查询都带上 routing=seller_id,
// 同一个卖家的数据落在同一分片,
// 查这个卖家时只需查一个分片,而不是全部分片。
GET /product/_search?routing=seller_888
{ "query": { "term": { "seller_id": 888 } } }

// === 优化 6:给搜索单独设 timeout,别让慢查询拖垮集群 ===
{
  "timeout": "800ms",
  "query": { "match": { "title": "耳机" } }
}
// 到点返回已有的部分结果,避免一个慢查询长期占用资源。

优化效果

指标                      治理前              治理后
=============================================================
搜索接口 P99              2~3 秒               180ms
深翻页                    from+size,翻后超时   search_after,恒定
过滤条件                  全在 must,都算分    移入 filter,不算分
filter 缓存命中            几乎为 0(条件不稳)  高频过滤命中率高
聚合字段                  text + fielddata     keyword,无 fielddata
聚合 size                 1000                 收敛到 20~50
主分片数                  50                   5
单分片文档数              ~80 万               ~800 万,大小合理
查询高峰 old GC           频繁                 明显减少
返回字段                  完整 _source         _source 过滤按需取

治理过程:
- 开慢日志 + profile 定位慢查询:1 天
- 深翻页改 search_after:0.5 天
- query/filter 上下文梳理改造:1 天
- 聚合优化 + mapping 调整:1 天
- reindex 重建索引调分片 + alias 切换:1.5 天

避坑清单

  1. ES 慢查询先用 slowlog 和 profile 定位,query 阶段和 fetch 阶段慢的优化方向不同
  2. 绝大多数 ES 慢查询是写法和索引设计问题,别一上来就加机器
  3. from+size 深翻页要每个分片各取前 from+size 条,from 越大越慢,有 1 万硬上限
  4. 在线翻页用 search_after 游标式翻页,排序要带唯一字段兜底;导数据才用 scroll
  5. 精确过滤条件放 filter context,不算分还能被缓存;只有要相关性的才放 query
  6. 精确匹配用 term 不用 match,聚合排序精确匹配都用 keyword 类型而非 text
  7. 对 text 字段聚合会触发 fielddata 加载到堆内存,极易引发 old GC 甚至 OOM
  8. terms 聚合的 size 收敛到实际需要,高基数字段改用 composite 或 cardinality 聚合
  9. 分片不是越多越好,过多有协调开销,单分片建议 10~50GB,建索引前就规划好
  10. 主分片数建后不可改,要靠新建索引+reindex+alias 切换,应用始终查别名

总结

这次 ElasticSearch 慢查询的治理,最值得记下来的不是某个具体的优化技巧,而是治理之前我差点犯的那个错误——看到搜索变慢,我的第一反应是"集群扛不住了,加机器吧"。这个反应太自然了,自然到几乎不需要思考,但它很可能是错的。我强迫自己先停下来,把慢查询日志打开,把可疑的查询用 profile 跑一遍,看清楚时间到底花在了哪里。结果非常打脸:几乎所有的慢,都不是因为机器不够,而是因为我们查询写得不对、索引设计得不对。如果当时直接加了机器,我们多半会得到一个"加了机器还是慢、或者只快了一点点"的结果,然后陷入更深的困惑。所以这次复盘的第一条经验就是:面对 ES 慢查询,profile 永远是第一步,加机器永远是最后一步。定位清楚之后,问题归纳起来其实就那么几类,而它们背后又有一些共通的道理。第一类是深翻页。我们用 from+size 翻页,翻到第几百页就慢得不行,这个病和 MySQL 的 LIMIT offset 深分页其实是同一个——你以为只要那一页的二十条,但 ES 作为一个分布式系统,必须让每一个分片都先把前 from+size 条全部取出来,汇总到协调节点排序之后,再把前面绝大部分丢掉。from 越大,这个被取出来又被丢掉的量就越夸张。ES 甚至干脆给 from+size 设了一个一万的硬上限,这其实是它在用报错的方式提醒你:别用这种方式深翻页,请改用 search_after 这种游标式的翻页,让每个分片只从上次停下的地方往后取一页,翻到哪里都一样快。第二类、也是我觉得最有启发的一类,是算分的浪费。ES 把查询条件分成两种上下文,query 和 filter,它们的区别是 query 要回答"这个文档有多匹配"、要算出一个相关性分数,而 filter 只回答"符不符合"这个是非题。算分是实打实的 CPU 开销,而我们的查询把"状态等于一""价格大于一百""品牌是小米"这些纯粹的过滤条件,全都堆进了要算分的 must 里——这些条件根本不存在"有多匹配"的问题,让它们参与算分是纯粹的浪费。把它们挪进 filter 之后,不仅省下了算分的 CPU,还白白获得了一个大礼:filter 的结果是可以被缓存的,像"状态等于一"这种几乎每个查询都带的高频过滤,缓存一次之后后续几乎零成本。这件事让我养成了一个新习惯:写每一个查询条件之前,先问自己一句"这个条件,我是要它的相关性,还是只要它做个过滤?"——答案是过滤的,一律进 filter,而真正需要算相关性分数的,一个查询里通常也就一两个。第三类是聚合,聚合慢的坑大多和"基数"与"类型"有关,尤其是对一个 text 类型的字段做聚合,会触发 fielddata 把大量数据加载进堆内存,这是 old GC 乃至 OOM 的常见诱因,正确的做法是聚合、排序、精确匹配这些场景一律使用 keyword 类型的字段。第四类是分片设计,我们曾经笃信"分片越多越快",给四千万文档开了五十个分片,后来才明白每一个分片都是一个有固定开销的独立 Lucene 索引,分片太多,这些固定开销和查询时的协调合并开销叠加起来,反而是一种拖累。把这几类问题逐一改完,我对 ElasticSearch 形成了一个新的整体认知:它表面上是一个"你丢一个查询进去、它吐结果出来"的黑盒,用起来很简单,但这种简单是有欺骗性的——它内部的相关性算分、分布式的分片协调、倒排索引和 doc values 的取舍、查询缓存的命中条件,每一项都在你看不见的地方实实在在地消耗着资源。你写的每一个查询、设计的每一个 mapping、定的每一个分片数,都是在替这个黑盒做决策。它快或者慢,从来不是由集群有多少台机器决定的,而是由你有多理解它内部在做什么决定的。这次治理真正的收获,就是把这个黑盒,变成了一个我大致能看懂、也能为它的性能负责的白盒。

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

线上内存慢慢涨到 OOM:一次 ThreadLocal 内存泄漏的复盘

2026-5-20 16:53:27

技术教程

高峰期接口大面积超时:一次数据库连接池配置的复盘

2026-5-20 17:01:13

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