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