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