把查询 DSL 硬编码在应用代码里,短期最省事,长期最痛苦:改一个排序要重新发版,多个服务各写一份相似的 bool 查询,字段名散落在各处,一旦映射调整就要全量搜索替换。搜索模板把查询抽成带占位符的模板存在集群里,应用只传参数,查询逻辑集中在服务端演进。本文从模板的价值讲起,覆盖 Mustache 语法、模板化查询与聚合、与应用层组装的取舍、版本管理,最后讲渲染性能与安全边界。
1. 搜索模板的价值与形态
一句话总结: 搜索模板把查询逻辑从应用代码搬到集群,实现集中管理、参数化调用与免发版变更。
1.1 硬编码查询的四个痛点
- 变更成本高:调整一个
boost值或排序字段,要走完整的构建发布流程。 - 逻辑重复:订单服务与报表服务各写一份相似的过滤条件,行为逐渐漂移。
- 字段名散落:字段重命名时要在多个仓库里搜索替换,容易漏。
- 难以审计:无法统一回答「线上到底在跑哪些查询」。
1.2 模板的两种存在形式
# 内联模板:每次请求都带完整模板
curl -X GET "localhost:9200/orders/_search/template?pretty" -H 'Content-Type: application/json' -d '
{
"source": { "query": { "term": { "{{field}}": "{{value}}" } } },
"params": { "field": "status", "value": "paid" }
}'
内联适合临时调试,预注册适合生产。预注册模板有独立的 API 与版本号,可以像代码一样管理。
1.3 预注册模板
curl -X PUT "localhost:9200/_scripts/orders_by_status" -H 'Content-Type: application/json' -d '
{
"script": {
"lang": "mustache",
"source": {
"query": { "bool": { "filter": [
{ "term": { "status": "{{status}}" } },
{ "range": { "created_at": { "gte": "{{from}}", "lte": "{{to}}" } } }
] } }
}
}
}'
调用时只需传参数:
curl -X GET "localhost:9200/orders/_search/template?pretty" -H 'Content-Type: application/json' -d '
{
"id": "orders_by_status",
"params": { "status": "paid", "from": "2026-09-01", "to": "2026-10-01" }
}'
2. Mustache 模板语法
一句话总结: Mustache 用双花括号做变量替换与条件分支,语法极简但足以覆盖绝大多数查询场景。
2.1 变量替换
{
"query": {
"match": {
"title": "{{{query_string}}}"
}
}
}
这点极易踩坑:Elasticsearch 的 Mustache 实现默认对 {{var}} 做转义,若参数里含引号或特殊字符会被转义成实体,导致 JSON 非法。凡是嵌入 JSON 值的占位符都应使用三花括号。
2.2 条件分支
{
"query": {
"bool": {
"filter": [
{ "term": { "tenant_id": "{{tenant_id}}" } }
{{#status}}
, { "term": { "status": "{{status}}" } }
{{/status}}
]
}
}
}
当 status 未传时,整个逗号与条件块都不渲染,得到的 JSON 依然合法。这是模板化查询处理可选条件的标准写法。
2.3 循环与列表
{
"query": {
"terms": {
"tag": [
{{#tags}}
"{{.}}"{{^last}},{{/last}}
{{/tags}}
]
}
}
}
逗号处理是循环渲染的难点,常见做法是在参数里带上一个标记,或者改用 terms 的 lookup 形式避开手写逗号。
2.4 内置函数
{
"query": {
"bool": {
"filter": [
{ "terms": { "category": {{#toJson}}categories{{/toJson}} } }
]
}
}
}
{{#toJson}}categories{{/toJson}} 会把数组参数序列化成 JSON 数组,直接嵌入查询,省去了手工拼逗号。
3. 模板化查询
一句话总结: 模板化查询的核心是把「结构固定、取值可变」的部分参数化,把结构差异用条件块表达。
3.1 可选过滤条件
curl -X PUT "localhost:9200/_scripts/orders_search" -H 'Content-Type: application/json' -d '
{
"script": {
"lang": "mustache",
"source": {
"query": { "bool": { "filter": [
{ "term": { "tenant_id": "{{tenant_id}}" } }
{{#user_id}}
, { "term": { "user_id": "{{user_id}}" } }
{{/user_id}}
{{#min_amount}}
, { "range": { "amount": { "gte": {{min_amount}} } } }
{{/min_amount}}
] } }
}
}
}'
注意数值型参数不要加引号,{{min_amount}} 直接渲染成数字,否则会被当成字符串导致类型不匹配。
3.2 排序与分页参数化
{
"sort": [
{ "{{sort_field}}": { "order": "{{sort_order}}" } }
],
"from": {{from}},
"size": {{size}}
}
sort_field 直接来自用户输入是危险的:虽然 Mustache 不做注入,但任意字段名会导致查询失败甚至异常开销。排序字段必须走应用侧白名单。
3.3 模板组合与部分复用
Elasticsearch 的 Mustache 实现支持 {{>other_template}} 形式的局部模板引用,需要把被引用的模板一起注册。实践中更常见的是在应用侧做组合,把多个小模板拼成一个大查询。
4. 模板化聚合与排序
一句话总结: 聚合同样可以模板化,尤其是分桶字段、区间边界与子聚合的开关。
4.1 分桶字段参数化
curl -X PUT "localhost:9200/_scripts/sales_agg" -H 'Content-Type: application/json' -d '
{
"script": {
"lang": "mustache",
"source": {
"size": 0,
"aggs": { "by_dimension": {
"terms": { "field": "{{dimension}}", "size": {{bucket_size}} },
"aggs": { "total_amount": { "sum": { "field": "amount" } } }
} }
}
}
}'
调用时传 dimension 为 category 或 region,就能切换分析维度,而指标聚合复用同一份定义。
4.2 区间边界参数化
{
"aggs": {
"amount_ranges": {
"range": {
"field": "amount",
"ranges": [
{ "to": {{tier1}} },
{ "from": {{tier1}}, "to": {{tier2}} },
{ "from": {{tier2}} }
]
}
}
}
}
4.3 条件聚合
{
"aggs": {
"by_category": {
"terms": { "field": "category" }
{{#with_stats}}
, "aggs": {
"avg_price": { "avg": { "field": "price" } }
}
{{/with_stats}}
}
}
}
4.4 多值参数与 toJson
curl -X GET "localhost:9200/sales/_search/template?pretty" -H 'Content-Type: application/json' -d '
{
"id": "sales_agg",
"params": {
"dimension": "category",
"bucket_size": 20
}
}'
5. 与应用层 DSL 组装的对比
一句话总结: 模板适合「结构稳定、参数可变」的查询,应用层组装适合「结构随场景剧烈变化」的查询。
5.1 模板的优势
- 查询逻辑收敛到集群,多个服务共用一份定义。
- 修改模板不需要发版,可以快速灰度与回滚。
- 任何语言的客户端都能调用,消除了各语言 DSL 库的差异。
- 模板本身是集群对象,可以纳入版本管理与审计。
5.2 模板的劣势
Mustache 只有变量、条件、循环三件套,没有函数、没有变量赋值、没有算术。遇到「根据 A 与 B 的取值组合出不同嵌套结构」这类需求,模板会迅速退化成难以维护的字符串拼接。
5.3 混合策略
# 应用层只做「选模板 + 传参」两件事
curl -X GET "localhost:9200/orders/_search/template?pretty" -H 'Content-Type: application/json' -d '
{
"id": "orders_by_status",
"params": { "status": "paid", "from": "2026-09-01", "to": "2026-10-01" }
}'
推荐的分工是:查询骨架、常用过滤组合、聚合定义放模板;路由决策、参数校验、字段白名单放应用层。这样既拿到了模板的集中管理优势,又保留了应用层的表达力。
6. 模板版本管理与治理
一句话总结: 模板要像代码一样管理:命名规范、版本可追溯、变更可灰、废弃可下线。
6.1 命名与元数据
curl -X PUT "localhost:9200/_scripts/orders_search_v2" -H 'Content-Type: application/json' -d '
{
"script": {
"lang": "mustache",
"source": {
"query": { "bool": { "filter": [
{ "term": { "status": "{{status}}" } }
] } }
}
}
}'
Elasticsearch 的模板对象本身不支持自定义元数据字段,因此 owner 与说明通常写在配套的代码仓库里,用 CI 同步到集群。
6.2 版本并存与灰度
# 列出集群里所有已注册的模板
curl -s "localhost:9200/_cluster/state/metadata?pretty" | grep -A2 "orders_search"
# 删除旧版本
curl -X DELETE "localhost:9200/_scripts/orders_search_v1"
版本并存的代价是集群对象增多,需要定期清理。
6.3 用 CI 管理模板
#!/usr/bin/env bash
set -euo pipefail
ES_HOST="http://localhost:9200"
TPL_DIR="./search-templates"
for f in "${TPL_DIR}"/*.json; do
name="$(basename "${f}" .json)"
curl -sS -X PUT "${ES_HOST}/_scripts/${name}" \
-H 'Content-Type: application/json' --data-binary @"${f}" > /dev/null
done
echo "all templates registered"
6.4 变更前的兼容检查
把「新增参数给默认值、删除参数先保留再废弃」作为纪律,可以避免模板变更引发的线上故障。
7. 模板性能与安全边界
一句话总结: 模板渲染在协调节点完成,代价小但非零;参数必须校验,模板本身要限制写入权限。
7.1 渲染开销
# 用 profile 观察模板查询的耗时分布
curl -X GET "localhost:9200/orders/_search/template?profile=true&pretty" -H 'Content-Type: application/json' -d '
{
"id": "orders_by_status",
"params": { "status": "paid", "from": "2026-09-01", "to": "2026-10-01" }
}'
渲染开销通常远小于查询本身,只有当模板包含大量循环时才需要关注。若模板渲染成为瓶颈,说明模板设计过度复杂,应拆分。
7.2 参数注入的边界
必须校验的三类参数:字段名(白名单)、排序方向(枚举)、数值边界(范围与类型)。这三类之外的普通取值参数,Mustache 的转义机制已足够安全。
7.3 模板的权限
在开启了安全功能的集群上,通过角色配置区分:
curl -X PUT "localhost:9200/_security/role/template_reader" -H 'Content-Type: application/json' -d '
{
"cluster": ["manage_index_templates"],
"indices": [
{
"names": ["orders*"],
"privileges": ["read"]
}
]
}'
7.4 与其他参数化手段的边界
常见误解是把模板当成通用的「服务端计算」入口。模板只能渲染 JSON,不能做计算;需要计算请用 painless 脚本或 ingest pipeline,各司其职。
8. 总结
| 环节 | 要点 |
|---|---|
| 模板价值 | 查询逻辑集中到集群,免发版变更,多语言共用,可审计 |
| 两种形态 | 内联适合调试,预注册适合生产,用 _scripts API 管理 |
| Mustache 语法 | 三花括号嵌入 JSON 值,条件块表达可选,循环渲染数组 |
| 查询模板 | 可选过滤、排序分页参数化,排序字段必须走白名单 |
| 聚合模板 | 分桶字段与区间边界参数化,用条件块开关子聚合 |
| 与 DSL 对比 | 结构稳定用模板,结构多变用应用层组装,混合策略最稳 |
| 版本管理 | 命名规范、版本并存灰度、CI 幂等注册、变更前兼容检查 |
| 性能与安全 | 渲染代价小,字段名与排序方向必须校验,注册权限只给 CI |
搜索模板不是银弹,但对「查询结构稳定、参数频繁变化」的场景,它能把散落在各服务里的查询逻辑收拢成可管理、可灰度的集群资产。配合 CI 与版本并存策略,查询层的变更终于可以像代码一样被工程化对待。下一篇我们转向机器学习的异常检测,看如何让集群自己发现数据里的异常模式。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。