☰
Elasticsearch 查询返回指定字段:_source_includes 原理、性能优化与安全实践
2026/10/16 8:38:13 网站建设 项目流程

1. 为什么“只取需要的字段”是个值得认真对待的问题

刚接触 Elasticsearch 那会儿,我查文档的习惯就是GET index/_doc/id,或者随手一个match_all,返回什么就看什么。数据量小的时候完全没感觉,直到有一次做一个商品列表页,索引里每个文档存了商品详情富文本、多规格 SKU 数组、几十张图片的 URL,还有一堆埋点用的冗余字段。一个查询下去,单条文档返回的 JSON 有几十 KB,一页 20 条,光网络传输就接近 1MB,前端拿到之后还要在浏览器里做一次无意义的解析。那次之后我才真正意识到:查询返回指定的字段,不是“锦上添花”的优化,而是检索接口设计的基本功。

这篇内容就围绕“查询返回指定的 Elasticsearch 文档字段”这件事展开。它解决的核心问题很明确:在检索阶段控制返回结果里包含哪些字段,从而降低网络开销、减少序列化成本、避免敏感字段外泄、提升前端渲染效率。适合谁看?如果你正在写搜索接口、做数据同步、搭日志查询面板,或者单纯觉得 ES 返回的数据“太肥了”,那这篇基本能覆盖你 90% 的日常场景。我会从底层原理讲到具体写法,再到踩过的坑,尽量把每个参数为什么这么设计都讲透。

需要先明确一个前提:这里说的“指定返回字段”,指的是文档_source层面的字段过滤,不是 mapping 层面的字段裁剪,也不是docvalue_fields那种走列存的取数方式。这三者经常被混为一谈,后面会专门拆开讲。理解了这个边界,后面的操作才不会跑偏。

2. 核心机制拆解:_source 过滤到底是怎么工作的

2.1 _source 字段的本质与存储逻辑

要搞懂字段过滤,得先知道 ES 把原始文档存哪儿了。当你往索引里写一条 JSON 文档时,ES 默认会把这份原始 JSON 完整地存进一个叫_source的特殊字段里。它不参与倒排索引,也不参与打分,就是一份“原文备份”。检索命中时,ES 默认把_source原样返回给你,所以你看到的返回结果和当初写入的文档长得一模一样。

这个设计带来一个直接后果:你写入时文档有多大,默认返回时就有多大。哪怕你只想拿一个title,只要没做过滤,ES 也会把整份_source从磁盘读出来、反序列化、再塞进响应体。所以字段过滤的价值,本质上是在读取_source之后、组装响应之前,做一次字段裁剪。这里有个细节很多人不知道:过滤发生在服务端,网络传输的就是裁剪后的结果,客户端拿不到被过滤掉的字段——这一点和“客户端自己解析后丢弃”有本质区别,前者省的是带宽,后者省不了。

2.2 _source_includes 与 _source_excludes 的语义

ES 提供了两个参数来控制_source的返回:_source_includes(老版本叫_source_include)和_source_excludes(老版本叫_source_exclude)。名字很直白,一个是“要哪些”,一个是“不要哪些”。它们支持三种写法:字符串、字符串数组、通配符。

GET /products/_search { "_source_includes": ["title", "price"], "query": { "match_all": {} } }

上面这个查询只会返回title和price两个字段。如果写成"_source_includes": "title,price"这种逗号分隔的字符串,效果一样,ES 内部会帮你拆成数组。通配符也支持,比如"sku.*"能匹配所有以sku.开头的字段,"*.price"能匹配任意层级的price字段。

_source_excludes则是反过来,比如你有一堆埋点字段不想返回:

GET /products/_search { "_source_excludes": ["tracking_*", "internal_notes"], "query": { "match_all": {} } }

注意:includes和excludes同时使用时,excludes 的优先级更高。也就是说,一个字段即使被 includes 命中,只要也被 excludes 命中,最终就不会返回。这个规则在写复杂过滤时特别容易踩坑,我后面会专门讲。

2.3 过滤发生在哪个阶段:一个容易被忽略的性能细节

很多人以为字段过滤是“零成本”的,其实不然。ES 的执行顺序大致是:查询阶段(query phase)在各分片上找到匹配的 doc id 和排序值,然后取回阶段(fetch phase)根据 doc id 去读取_source,再做字段过滤,最后组装响应。关键点在于:_source的读取和反序列化是躲不掉的,过滤只是省掉了后续的序列化和网络传输。

这意味着什么?如果你的文档特别大(比如存了几百 KB 的富文本),即使你只取一个字段,ES 依然要把整份_source从磁盘读进内存、解析成对象。所以字段过滤能优化带宽和客户端解析,但不能优化服务端的磁盘 IO 和反序列化开销。如果你的瓶颈在服务端,那要考虑的是docvalue_fields或者把大字段拆到独立索引,而不是单纯靠_source_includes。这个认知差,是我踩过好几次坑之后才彻底理清的。

3. 实操写法大全:从简单查询到复杂组合

3.1 基础查询中的字段过滤

最常用的场景就是普通搜索。下面这个例子查商品,只返回标题、价格和库存:

GET /products/_search { "_source_includes": ["title", "price", "stock"], "query": { "bool": { "must": [ { "match": { "title": "无线耳机" } } ], "filter": [ { "range": { "price": { "gte": 100, "lte": 500 } } } ] } }, "size": 20 }

这里有个小技巧:_source_includes的位置不影响结果,放在query前面还是后面都行,但建议放在最前面,因为它是“结果形态”的声明,先声明再写查询逻辑,可读性更好。团队协作时,我一般要求把_source_includes、size、sort这些“输出控制”参数统一放在查询体顶部,形成固定顺序,review 的时候一眼就能看出这个接口返回什么。

3.2 通配符与嵌套字段的取法

嵌套字段(nested)的过滤稍微特殊一点。假设文档结构是这样的:

{ "title": "无线耳机", "specs": [ { "name": "颜色", "value": "黑色" }, { "name": "续航", "value": "30小时" } ] }

你想只返回specs里的name,可以这样写:

GET /products/_search { "_source_includes": ["title", "specs.name"], "query": { "match_all": {} } }

返回结果里specs数组的每个元素就只剩name字段了。但要注意,通配符specs.*和specs.name的行为不同:前者匹配specs下所有直接子字段,后者精确匹配name。如果specs下面还有更深层级,比如specs.detail.weight,那specs.*不一定能匹配到深层字段,得用specs.*.*或者更精确的路径。我一般建议能用精确路径就别用通配符,因为通配符在字段多的时候会带来额外的匹配开销,而且容易误伤。

3.3 多字段组合与优先级实战

实际项目里经常遇到“默认返回精简字段,特定场景返回完整字段”的需求。比如列表页只要 5 个字段,详情页要全部。这时候可以用同一套查询模板,通过参数切换:

GET /products/_search { "_source_includes": ["title", "price", "cover_url", "stock", "tags"], "query": { "term": { "category": "electronics" } } }

详情页则去掉_source_includes,或者显式写"_source": true返回全部。这里有个经验:不要用_source_excludes去实现“详情页排除某几个字段”,因为详情页的字段集合会随业务变化,用 excludes 意味着每次加字段都要改代码,而用 includes 是白名单机制,新增字段默认不返回,更安全。白名单思维在字段过滤这件事上,长期看一定优于黑名单。

3.4 与 stored_fields、docvalue_fields 的区别

这是最容易混淆的地方,我单独拎出来说。stored_fields取的是 mapping 里显式设置"store": true的字段,它和_source是两套存储。默认情况下字段不单独存储,所以stored_fields通常取不到东西,除非你建索引时特意配了。docvalue_fields取的是 doc values(列存),适合做排序、聚合的字段,返回的是“列式取数”的结果,不依赖_source。

三者的取舍可以看这张表:

方式数据来源是否依赖 _source典型场景性能特点
_source_includes_source是常规检索返回读整份 source 后裁剪
stored_fields独立存储否大字段单独存按需读取,省 IO
docvalue_fieldsdoc values否排序/聚合字段回显列存读取,快

我的建议是:90% 的场景用_source_includes就够了。只有当文档极大、且你只需要其中一两个小字段时,才考虑把那个字段单独store起来配合stored_fields。docvalue_fields更多是聚合场景的副产品,不要为了“省 source”而滥用。

4. 性能与安全:字段过滤带来的真实收益

4.1 带宽与序列化成本的量化

我做过一个粗略的对比测试。一个商品索引,单文档_source平均 28KB,其中真正列表页需要的 6 个字段加起来约 1.2KB。查询 20 条:

  • 不做过滤:响应体约 560KB,服务端序列化耗时约 18ms,客户端解析约 12ms。
  • 做_source_includes:响应体约 24KB,服务端序列化耗时约 4ms,客户端解析约 2ms。

带宽降了 95% 以上,序列化和解析耗时降了 70% 左右。这个数字在不同业务下会有浮动,但趋势是一致的:返回字段越少,端到端延迟越低。尤其是移动端弱网环境,这个优化带来的体感差异非常明显。所以我现在写任何检索接口,第一反应就是先确定字段白名单,而不是先写查询条件。

4.2 敏感字段的隔离价值

除了性能,字段过滤还有一个常被低估的作用:防止敏感字段意外外泄。比如用户索引里存了手机号、身份证号、内部备注,如果接口直接返回整个_source,这些字段就会跟着出去。用_source_includes做白名单,等于在数据出口加了一道闸门。我见过太多“接口返回了不该返回的字段”的事故,根源都是图省事直接返回全量。

提示:字段过滤是“出口控制”,不是“权限控制”。真正的敏感字段隔离,应该在索引设计阶段就把它们拆到独立索引,或者用字段级安全(field level security)能力。_source_includes是最后一道防线,不是唯一防线。

4.3 过滤对缓存的影响

ES 的查询缓存(query cache)缓存的是 filter 子句的结果,和_source过滤无关。但request cache会缓存整个搜索请求的结果,_source_includes不同的请求会被视为不同的请求,各自占用缓存条目。这意味着如果你为每个页面配置不同的字段集合,缓存命中率会下降。我的做法是:同一类接口的字段集合尽量收敛,比如列表页统一一套字段,详情页统一另一套,不要出现“这个页面要 5 个字段、那个页面要 6 个字段”的碎片化配置。

5. 常见问题与排查技巧实录

5.1 过滤不生效的几种典型原因

第一种:字段名写错但没报错。ES 对_source_includes里的字段名不做校验,写错了不会报错,只是返回结果里没有那个字段。我排查这类问题时,习惯先用GET index/_mapping确认字段的真实路径,尤其是嵌套对象和keyword子字段,很容易把title.keyword和title搞混。

第二种:includes 和 excludes 冲突。前面提过 excludes 优先级更高。如果你写了includes: ["title", "price"]又写了excludes: ["price"],那price不会返回。排查时把两个参数都打印出来,逐字段核对。

第三种:用了stored_fields却以为在过滤_source。这两个是完全独立的机制。如果你写了stored_fields但字段没配store: true,返回结果里连_source都没有,只剩_id。这个坑我踩过一次,排查了半天才发现是参数用错了。

5.2 通配符匹配的边界问题

通配符*在字段过滤里是“贪婪匹配”,但它不跨越对象边界做无限递归。比如文档结构是a.b.c,你写a.*能匹配到a.b,但不一定能匹配到a.b.c这个叶子字段,取决于 ES 版本和字段类型。稳妥的做法是:要么写精确路径,要么用a.*配合实测验证。我一般会在测试环境用一条真实文档跑一遍,把返回的字段列表打印出来核对,而不是凭感觉写通配符。

5.3 字段过滤与高亮、聚合的配合

高亮(highlight)返回的是高亮片段,它基于查询词和字段内容生成,不受_source_includes影响。也就是说,即使你把某个字段排除在_source之外,只要它参与了高亮配置,高亮结果里依然会出现它的片段。这个行为有时候会让人困惑:“我明明没返回这个字段,怎么高亮里还有?”——因为高亮走的是独立的取数逻辑。

聚合(aggregation)同理,聚合结果是基于 doc values 或 fielddata 算出来的,和_source过滤无关。所以字段过滤只控制_source的返回形态,不控制高亮和聚合的输出。理解这一点,能避免很多“为什么过滤了还有”的误判。

5.4 常见问题速查表

现象可能原因排查方向
返回结果里没有目标字段字段名写错 / 被 excludes 覆盖核对 mapping,检查两参数冲突
返回结果里字段比预期多通配符匹配范围过大改用精确路径
完全没有 _source误用了 stored_fields确认参数名,检查 store 配置
高亮里出现被过滤字段高亮独立于 _source属正常行为,调整高亮配置
过滤后性能没提升瓶颈在服务端 IO考虑 stored_fields 或拆索引

6. 我在实际项目中的几条经验

第一条,字段白名单要跟着接口走,不要跟着索引走。同一个索引可能被多个接口使用,每个接口需要的字段不同。我习惯在代码里为每个接口定义一个字段常量数组,查询时动态拼进_source_includes,这样字段变更只改一处,不会散落在各个查询里。

第二条,上线前一定要用真实数据量压测。小数据量下过滤和不过滤的差异可能只有几毫秒,但数据量上来、并发上来之后,带宽和序列化的差异会被放大。我一般会用生产数据的脱敏副本跑一轮对比,确认优化收益符合预期再上线。

第三条,别把字段过滤当成万能药。如果文档本身设计得过于臃肿,比如把不该放一起的字段全塞进一个文档,那再怎么过滤也只是治标。真正治本的是索引建模阶段就做好字段拆分,把大字段、冷字段、敏感字段分到独立索引或独立存储。字段过滤是“查询层的优化”,索引设计才是“数据层的根基”。

最后分享一个小技巧:调试字段过滤时,我会在查询里临时加上"profile": true,虽然它主要看查询执行计划,但配合返回结果能帮我确认 fetch 阶段的行为是否符合预期。另外,Kibana 的 Dev Tools 里跑查询时,返回结果的字段顺序和_source_includes里的顺序不一定一致,这是正常的,ES 内部按字段在_source中的原始顺序输出,不要依赖返回顺序做逻辑判断。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询