搜索翻页就超时:一次 Elasticsearch 查询优化的复盘

商品搜索翻到几十页就卡死、组合查询要三四秒,加了两台 ES 节点几乎没用。沉下心用 profile 剖析才发现问题全在自己:mapping 滥用 text、过滤条件塞 must、翻页用了深度 from/size。几天 ES 查询优化:search_after 游标分页、mapping text/keyword、query 与 filter、写入与分片优化、监控。

2024 年我们的商品搜索服务被投诉了。运营在后台搜商品,翻到第几十页就转圈卡死;前台用户用关键词搜索,热门词还行,稍微生僻一点的组合查询就要等三四秒。这套搜索是建在 Elasticsearch 上的,数据量大概八千万条商品文档。一开始我们以为是 ES 集群机器不够,加了两台节点,情况只好转了一点点。后来沉下心用 ES 自己的 profile 工具去剖析每一条慢查询,才发现真正的问题全在我们自己身上——mapping 设计得很随意、查询该用 filter 的地方用了 query、翻页用的是 ES 最忌讳的深度 from/size。投了几天做 ES 查询专项优化,本文复盘这次实战。

问题背景

业务:商品搜索,Elasticsearch 7.x,8000 万商品文档,索引 6 主分片
事故现象:
- 后台翻页,翻到第 50 页以后明显变慢,第 100 页直接超时
- 前台组合条件搜索 P99 3-4 秒
- 加了机器,改善很有限

现场排查:
# 1. 用 profile 剖析一条慢查询
GET /product/_search
{
  "profile": true,
  "query": { ... },
  "from": 1000, "size": 20
}
# profile 结果显示:
# - 大量时间耗在 "collect" 阶段 -> 深度分页,要收集前 1020 条
# - query 里用了 match,每个分片都在算相关性评分(很贵)

# 2. 看 mapping
GET /product/_mapping
# 发现 status、category_id 这些只做精确过滤的字段,
# 被映射成了 text 类型 —— text 会分词,还占额外空间

# 3. 看慢查询日志
index.search.slowlog.threshold.query.warn: 1s
# 慢日志里堆满了 from 很大的分页查询、和带 text 字段排序的查询

根因:
1. 深度分页:from=1000 要求每个分片都取前 1020 条再汇总,翻得越深越慢
2. mapping 滥用 text:精确过滤字段用了 text,既慢又占空间
3. 该用 filter 的过滤条件用了 query,白白计算相关性评分、且不缓存
4. 一条查询返回了文档全部字段,大文档传输开销大

修复 1:看懂 ES 查询慢在哪

=== ES 查询的执行模型(理解了才知道为什么慢)===
一次 search 分两个阶段:
1. Query 阶段:请求发到【每个分片】,各分片在本地查出
   匹配的文档 id + 排序值,返回给协调节点
2. Fetch 阶段:协调节点汇总、排序、确定最终要的文档,
   再去对应分片把【文档完整内容】取回来

=== 关键认知 ===
- 查询是【每个分片都要执行一遍】的,分片越多,扇出越大
- from + size 的分页,每个分片都要取 from+size 条 ——
  深度分页的代价随 from 线性增长

=== 用 profile 定位慢的环节 ===
GET /product/_search
{ "profile": true, "query": {...} }
# profile 把耗时拆到每个分片、每个查询子句:
# - "query" 部分慢:某个子查询本身昂贵(如 wildcard、正则)
# - "collect" / "rewrite" 慢:通常和深度分页、聚合有关

=== 慢查询日志:把慢查询都记下来 ===
PUT /product/_settings
{
  "index.search.slowlog.threshold.query.warn": "1s",
  "index.search.slowlog.threshold.query.info": "500ms",
  "index.search.slowlog.threshold.fetch.warn": "500ms"
}
# Query 阶段慢 -> 优化查询语句 / mapping
# Fetch 阶段慢 -> 返回字段太多 / 文档太大,见修复 5

修复 2:深度分页 —— from/size 的死穴

// === 问题:from + size 深度分页 ===
// GET /product/_search  { "from": 10000, "size": 20 }
// 要拿第 10001~10020 条,但 ES 不知道哪 20 条是全局的,
// 于是【每个分片】都要取前 10020 条,6 个分片共 6 万多条
// 汇总到协调节点排序,再丢掉前 10000 条 —— 极度浪费。
// ES 默认还限制 from+size <= 10000,超过直接报错。

// === 方案 A:search_after —— 用上一页最后一条做游标 ===
// 第一页:正常查,带一个唯一的排序字段(如 _id 或 create_time + id)
GET /product/_search
{
  "size": 20,
  "query": { ... },
  "sort": [ {"create_time": "desc"}, {"_id": "asc"} ]
}
// 响应里每条命中都带 sort 值,记下最后一条的 sort 值

// 下一页:带上 search_after = 上一页最后一条的 sort 值
GET /product/_search
{
  "size": 20,
  "query": { ... },
  "sort": [ {"create_time": "desc"}, {"_id": "asc"} ],
  "search_after": [1716192000000, "prod_88231"]
}
// 每个分片只需取"排在这个游标之后的 20 条",
// 无论翻到第几页,代价都恒定 —— 这是深翻页的正解。
// 注意:search_after 只能一页页顺翻,不能直接跳到第 N 页。

// === 方案 B:scroll —— 适合后台批量导出,不适合实时翻页 ===
// scroll 会生成一个数据快照,适合"一次性遍历全部数据"
// (如导出、reindex),但快照占资源,不适合给用户做实时翻页。

// === 产品层面:别让用户翻太深 ===
// 真实搜索场景,用户几乎不会翻到第 100 页。
// 限制最多翻 N 页 + 引导用户用更精确的筛选条件,
// 比硬扛深度分页更合理。

修复 3:mapping 设计 —— text 与 keyword

// === 核心区别:text 会分词,keyword 不分词 ===
// text   :存"商品标题"这种要全文检索的内容,
//          写入时被分词器切成词条,支持 match 模糊匹配
// keyword:存"状态""分类id""品牌"这种要精确匹配/聚合/排序的值,
//          整体作为一个词条,不分词

// === 错误的 mapping(我们踩的坑)===
{
  "product": {
    "properties": {
      "title":       { "type": "text" },     // 对,标题要全文搜
      "status":      { "type": "text" },     // 错!状态只做精确过滤
      "category_id": { "type": "text" },     // 错!分类 id 只做过滤
      "brand":       { "type": "text" }      // 错!品牌做过滤和聚合
    }
  }
}
// status 用 text 的恶果:
// - 被分词,精确过滤要用 match,语义不准
// - text 字段默认【不能】用于聚合和排序(要开 fielddata,极耗内存)

// === 正确的 mapping ===
{
  "product": {
    "properties": {
      "title": {
        "type": "text",                      // 全文检索
        "analyzer": "ik_max_word",           // 中文用 IK 分词器
        "fields": {
          "keyword": { "type": "keyword" }   // 同时存一份不分词的,
        }                                    // 用于精确匹配/排序
      },
      "status":      { "type": "keyword" },  // 精确过滤
      "category_id": { "type": "keyword" },  // 精确过滤
      "brand":       { "type": "keyword" },  // 过滤 + 聚合
      "price":       { "type": "double" },   // 数值,范围查询
      "create_time": { "type": "date" }
    }
  }
}
// === 经验 ===
// 字段要不要分词,取决于它的用途:
// 全文搜索 -> text;精确匹配 / 排序 / 聚合 -> keyword。
// 拿不准时,用 text + fields.keyword 的多字段方案两者兼得。
// mapping 一旦建好,字段类型不能改,改只能 reindex,所以一定要想清楚。

修复 4:query 与 filter —— 评分与缓存

// === ES 查询的两种上下文:query context 与 filter context ===
// query context :回答"匹配得有多好" —— 要计算相关性评分 _score,
//                 评分计算有开销,且结果【不缓存】
// filter context:回答"匹配还是不匹配" —— 只判断是否,不算分,
//                 而且 filter 结果会被【缓存】,重复查极快

// === 错误写法:所有条件都塞在 must 里(全是 query context)===
{
  "query": {
    "bool": {
      "must": [
        { "match":  { "title": "无线耳机" } },     // 这个确实要算分
        { "term":   { "status": "ON_SALE" } },     // 错!只是过滤
        { "term":   { "category_id": "3C" } },     // 错!只是过滤
        { "range":  { "price": { "gte": 100 } } }  // 错!只是过滤
      ]
    }
  }
}
// status / category / price 只是"过滤",根本不需要参与算分,
// 放 must 里白白计算评分,而且这些条件无法被缓存。

// === 正确写法:过滤条件放 filter ===
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "无线耳机" } }    // 真正要算分的留在 must
      ],
      "filter": [
        { "term":  { "status": "ON_SALE" } },   // 纯过滤,放 filter
        { "term":  { "category_id": "3C" } },
        { "range": { "price": { "gte": 100, "lte": 500 } } }
      ]
    }
  }
}
// filter 里的条件:不算分(省 CPU)+ 结果被缓存(重复查命中缓存)
// "status=ON_SALE" 这种值固定的过滤,缓存命中率极高。

// === 原则 ===
// 问自己:这个条件是"要排序优先级"还是"纯粹要不要"?
// 要排序优先级 -> query/must;纯粹的是非过滤 -> filter。
// 大部分业务条件(状态、分类、时间范围)都该进 filter。

修复 5:分片、写入与返回字段优化

// === 1. 只返回需要的字段,别返回整个 _source ===
// 错:默认返回 _source 全部字段,大文档传输很贵
// 对:用 _source 过滤,只取列表页要展示的字段
{
  "_source": ["id", "title", "price", "cover"],
  "query": { ... }
}
// 或者用 docvalue_fields / stored_fields 取个别字段。

// === 2. 分片数要合理,不是越多越好 ===
// 每个分片都是一个独立的 Lucene 索引,有固定开销。
// 分片太多:每次查询扇出大、协调成本高;分片太少:单分片过大。
// 经验值:单个分片大小控制在 20~50GB;
//         分片数 = 预估数据量 / 单分片目标大小。
// 8000 万文档、单分片目标 30GB,6 个主分片是合理的。
// ⚠ 主分片数创建后不能改,要 reindex,所以建索引前就要估算好。
// === 3. 批量写入用 _bulk,不要一条条写 ===
// 错:循环单条 index,每条一次网络往返
// 对:攒成一批用 _bulk 一次提交(一批 1000~5000 条)
POST /_bulk
{ "index": { "_index": "product", "_id": "1" } }
{ "title": "...", "price": 199 }
{ "index": { "_index": "product", "_id": "2" } }
{ "title": "...", "price": 299 }

// === 4. 大批量导入时,临时调优写入 ===
PUT /product/_settings
{
  "index.refresh_interval": "30s",      // 默认 1s,导入时调大,减少段生成
  "index.number_of_replicas": 0         // 导入时先关副本,导完再开
}
// 导入完成后再改回:
// refresh_interval 改回 "1s",replicas 改回正常值。

// === 5. 用 filter 缓存 + 合理利用查询缓存 ===
// node query cache 缓存 filter 结果,
// shard request cache 缓存整个请求结果(对聚合类查询很有效)。

修复 6:ES 监控告警

# ES 集群健康与查询性能监控
groups:
- name: elasticsearch
  rules:
  # 1. 集群健康状态非 green
  - alert: ESClusterNotGreen
    expr: elasticsearch_cluster_health_status{color="green"} != 1
    for: 5m
    annotations:
      summary: "ES 集群非 green,排查分片未分配 / 节点掉线"

  # 2. 查询延迟过高
  - alert: ESQueryLatencyHigh
    expr: |
      rate(elasticsearch_indices_search_query_time_seconds[5m])
      / rate(elasticsearch_indices_search_query_total[5m]) > 0.5
    for: 5m
    annotations:
      summary: "ES 平均查询延迟 > 500ms,排查慢查询/深度分页"

  # 3. JVM 堆内存使用率过高(易触发 Full GC,查询卡顿)
  - alert: ESHeapHigh
    expr: elasticsearch_jvm_memory_used_bytes{area="heap"}
          / elasticsearch_jvm_memory_max_bytes{area="heap"} > 0.85
    for: 10m
    annotations:
      summary: "ES 节点堆内存 > 85%,排查 fielddata / 大聚合"

  # 4. 查询线程池有拒绝(查询负载超过处理能力)
  - alert: ESSearchRejected
    expr: increase(elasticsearch_thread_pool_rejected_count{type="search"}[5m]) > 0
    annotations:
      summary: "ES 查询线程池出现拒绝,集群查询过载"

优化效果

指标                      治理前              治理后
=============================================================
深度翻页                  from/size,百页超时  search_after,恒定耗时
组合条件搜索 P99          3-4 秒               120-300ms
mapping                   精确字段滥用 text    精确字段改 keyword
过滤条件                  全塞 must 算分       过滤移入 filter,可缓存
filter 缓存命中           几乎为 0             高频过滤命中率 90%+
返回字段                  整个 _source         _source 裁剪到 5 字段
批量写入                  单条 index           _bulk 批量提交
写入吞吐                  约 3000 doc/s        约 25000 doc/s
ES 可观测                 无                   集群/查询/堆内存监控

治理过程:
- profile 剖析慢查询 + 慢日志分析:1 天
- 深度分页改 search_after:1.5 天
- mapping 修正 + reindex:2 天
- query/filter 拆分改造:1 天
- 写入优化 + 监控接入:1.5 天

避坑清单

  1. ES 查询是每个分片都执行一遍,先用 profile 和慢查询日志定位慢在哪个环节
  2. from/size 深度分页每个分片都要取 from+size 条,翻得越深越慢,默认上限 1 万
  3. 实时翻页用 search_after 游标,无论第几页代价恒定;批量导出用 scroll
  4. text 会分词用于全文检索,keyword 不分词用于精确匹配/排序/聚合
  5. 状态、分类 id、品牌这类精确过滤字段一定用 keyword,别用 text
  6. mapping 字段类型建好不能改,改只能 reindex,建索引前一定想清楚
  7. query context 算相关性评分且不缓存,filter context 不算分且结果被缓存
  8. 纯是非过滤条件(状态/分类/时间范围)都放 filter,只有要算分的留 must
  9. 用 _source 裁剪只返回需要的字段,别默认返回整个大文档
  10. 批量写入用 _bulk,大批量导入临时调大 refresh_interval、关副本

总结

这次 Elasticsearch 搜索优化,最值得记下来的一条经验,是我们一开始走的那条弯路:搜索慢,第一反应是"机器不够,加节点",于是真的加了两台,结果改善微乎其微。这个结果其实是一记响亮的提醒——当一个系统慢下来时,加机器是最省事、也最容易掩盖真问题的做法,但如果慢的根源是软件层面的设计缺陷,那么硬件投入会被这些缺陷大量地、低效地吞掉,你花了钱,却只换来一点点改善。真正解决问题的转折点,是我们停下来,用 ES 自带的 profile 工具去老老实实剖析每一条慢查询,让它告诉我们时间到底花在了哪里。结果一目了然:问题全部出在我们自己写的查询和设计上。第一个大坑是深度分页。很多人不知道,ES 的 from/size 分页有一个残酷的机制——你要第一千页那二十条数据,ES 并不能直接定位,它必须让每一个分片都把前面所有的数据(from 加 size 条)统统取出来,汇总到协调节点排好序,再把前面那一万条整个丢掉。翻页翻得越深,这个浪费就越大,这是 from/size 的死穴,正确的做法是改用 search_after,用上一页最后一条记录作为游标往后翻,这样无论翻到第几页,每个分片要做的工作量都是恒定的。第二个大坑是 mapping 设计的随意。ES 里 text 和 keyword 是两种用途完全不同的类型,text 会被分词、用于全文检索,keyword 不分词、用于精确匹配和排序聚合,而我们却把商品状态、分类 ID 这些只用来做精确过滤的字段一律映射成了 text,既慢又占空间,语义还不准。更麻烦的是,mapping 里字段的类型一旦确定就无法修改,想改只能把整个索引 reindex 一遍,所以建索引之前就必须把每个字段到底拿来干什么想得清清楚楚。第三个坑很隐蔽,是 query 和 filter 的混用。ES 的查询有两种上下文,query 上下文要计算"匹配得有多好"这个相关性评分,有 CPU 开销而且结果不缓存;filter 上下文只回答"匹配还是不匹配",不算分,而且结果会被缓存下来。商品状态、分类、价格区间这些条件,本质上都只是"要不要"的是非过滤,根本不需要参与相关性打分,我们却把它们和真正需要算分的关键词匹配一起塞进了 must 子句,白白地算了一遍又一遍分,还享受不到缓存。把这些纯过滤条件挪进 filter 之后,不仅省下了评分计算,高频的过滤条件还获得了极高的缓存命中率。回头看,这次优化里我们做的每一件事——search_after、修 mapping、拆 query 和 filter、裁剪返回字段、批量写入——没有一件是靠加机器能解决的,它们全都是关于"理解这个工具到底是怎么工作的"。Elasticsearch 是一个非常强大的搜索引擎,但它的强大有它自己的脾气和规则,你顺着它的工作模型去用,它快得惊人;你违背它的模型去用,再多的机器也填不平这个窟窿。所以面对任何一个慢的系统,我现在都会先克制住"加机器"的冲动,先去搞清楚:它到底慢在哪里,以及,我用对它了吗。

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

页面间歇性 502:一次 Nginx 配置排查的复盘

2026-5-20 13:27:09

技术教程

TIME_WAIT 堆到四万:一次 HTTP 客户端连接池踩坑的复盘

2026-5-20 13:33:43

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