Query、分页、PIT 与聚合:查询语义和资源成本怎样权衡
查询“租户 a 的蓝色商品”,需要同时表达文字匹配和访问范围:
{
"query": {
"bool": {
"must": [{"match": {"title": "blue"}}],
"filter": [{"term": {"tenant": "a"}}]
}
}
}match 负责按分词结果找标题,filter 将候选限制在租户 a。随后还有三个独立决定:结果按相关度还是价格排序,翻页期间是否允许数据变化,分类统计覆盖哪些文档。这些选择共同决定用户看到的内容,也决定搜索集群要完成多少工作。
查询条件与返回结果
按字段含义选择 DSL
全文字段与精确字段的结构见Mapping 与索引建模。查询应沿用那份字段契约:
| 需求 | 常用查询 | 需要确认的字段或参数 |
|---|---|---|
| 标题包含自然语言词语 | match | text 的 analyzer;多词默认组合方式 |
| 标题或描述包含词语 | multi_match | 字段列表、各字段 boost、匹配策略 |
| 词语顺序相邻 | match_phrase | 分词位置与允许的间隔 slop |
| 租户、状态、商品编号相等 | term / terms | keyword 或对应精确类型 |
| 价格、时间落在区间 | range | 数字单位、时间格式、上下界是否包含 |
| 字段存在可检索值 | exists | null、空数组、未索引字段的处理 |
| 同一规格对象同时满足颜色和尺寸 | nested | path 与内部条件必须指向同一嵌套层 |
| 无文本条件的列表 | match_all 加过滤、排序 | 仍需限制租户和可见状态 |
term 不对输入执行全文分析。对标题直接查完整字符串,经常与索引中已经拆开的词项对不上。match_phrase 匹配分析后的词序,也不表示原始字符串逐字相同。各全文查询的行为可查 Full-text queries。
query_string 允许括号、字段名和布尔操作符,适合受控查询工具。面向普通用户的搜索框,通常由服务端构造限定字段的 DSL;若开放查询语法,需要限制可访问字段、通配符和表达式复杂度。
bool 中的可选条件可能真的“可选”
must 要求匹配并参与评分,filter 要求匹配但不贡献相关度,must_not 排除匹配项。should 的最低命中数受其他子句影响:只有 should 时,默认至少满足一项;已有 must 或 filter 时,默认最低数变成 0。规则见 Boolean query。
因此,下面的条件会选中租户 a 的所有商品,并给蓝色商品增加匹配优势:
{
"query": {
"bool": {
"filter": {"term": {"tenant": "a"}},
"should": [{"match": {"title": "blue"}}]
}
}
}若蓝色是硬性要求,将它放入 must,或显式设置 minimum_should_match: 1。实验数据中,缺少该参数时命中 3 条,补齐后命中 2 条。
权限过滤应由可信的服务端身份生成。客户端传来的 tenant 字段只能作为待校验输入;对命中列表、聚合、补全建议和导出接口都要使用同一访问规则。
相关度、排序与分布式执行
默认全文相关度常采用 BM25。一个词在当前文档中出现多少次、在文档集合中是否稀有、字段长短,都会影响分值;boost 可以调整字段或子查询的贡献。_score 用于当前查询的排序,不适合作为跨查询稳定的业务分数。评分与参数入口见 Similarity。
典型查询先在涉及的分片上寻找局部候选,协调节点合并候选,再取回所需文档字段。请求只返回 20 条,并不表示整个集群只考察了 20 条。
应用请求
└─ 协调节点:确定目标索引和分片
├─ 分片 0:过滤、匹配、评分或排序,返回局部候选
├─ 分片 1:过滤、匹配、评分或排序,返回局部候选
└─ 合并全局结果 → 取回字段 → 返回 hits 与 aggregations默认 query_then_fetch 使用分片侧统计执行查询。需要更统一的词频统计时,dfs_query_then_fetch 会增加分布式统计阶段,带来额外往返;是否值得,应以代表性查询的排序质量判断。Search API列出了搜索类型、排序、超时和返回字段。
按价格排序时,可使用:
{
"size": 20,
"sort": [{"price": "asc"}, {"id": "asc"}],
"_source": ["id", "title", "price"],
"track_total_hits": true
}第二个字段消除同价商品之间的顺序歧义。这里的 id 是具有 doc values 的 keyword 字段;不要直接把元数据 _id 当成可排序字段。缺失值的位置、升降序、日期精度,都属于分页协议的一部分。
过滤上下文为缓存和不计分执行提供了条件,但实际是否缓存受查询类型、访问频率和分片状态影响。不要根据使用了 filter 就假定每次请求都命中缓存。
读响应时同时检查完整性
| 返回字段 | 含义与处理 |
|---|---|
took | 服务端报告的搜索耗时;应用仍要记录完整调用延迟 |
timed_out | 搜索是否触及服务端超时条件 |
_shards.failed / failures | 是否有分片执行失败,失败来自哪个索引、什么原因 |
hits.total.value / relation | relation 为 eq 是精确值,gte 表示下界 |
hits.hits | 本页命中;每项包含索引、ID、source、score 或 sort |
aggregations | 聚合结果,不受 hits 的 size 直接限制 |
需要完整查询时,显式使用 allow_partial_search_results=false,并检查响应结构。允许部分结果的展示应给用户可见标记,报表、结算或全量导出不能静默采用缺失分片的统计。
track_total_hits: true 会要求准确统计总命中数,可能增加工作量。只需要“还有下一页”的滚动列表,可以采用较低统计上限或不返回总数,但接口需要说明这时数字的含义。
高亮片段可能包含来自原始文档的 HTML,页面输出必须转义并仅放行自己指定的标记。自动补全可以使用前缀查询、专门的字段或 suggester;字段和过滤规则见 Autocomplete。向量与混合检索有不同的候选生成和重排过程,可从 Hybrid search继续查阅。
排序和分页如何保持连续
四种分页方式解决不同问题
| 方式 | 保存什么 | 适合什么操作 | 主要限制 |
|---|---|---|---|
| from / size | 页码对应的偏移量 | 浅分页、少量跳页 | 越深越需要处理前面的候选 |
| search_after | 上一页最后一项的 sort 数组 | 持续向后读取 | 单独使用时仍读变化中的索引 |
| Scroll | 固定查询及服务端搜索上下文 | 批量扫描、离线处理 | 长时间保留上下文,不适合普通交互页码 |
| PIT + search_after | 固定数据视图,加排序位置 | 一致地连续翻页、导出 | 需要维护有效期和释放资源 |
对于经典 from / size 分布式 top-k 执行,每片通常要为前 from + size 的候选付出工作,再由协调节点归并。具体开销还取决于索引排序、查询优化、命中分布和是否精确计数;这个关系用于理解深分页增长,不是所有查询的精确内存公式。详细协议见 Paginating search results。
提高 index.max_result_window 只能放宽限制。若需求是一次导出数百万记录,分页方式应改为顺序读取,而不是让前端不断增加偏移量。
search_after 保存顺序,PIT 固定可见数据
上一页价格排序结果为:
p1 → sort [100, "p1"]
p2 → sort [200, "p2"]下一页原样发送 search_after: [200, "p2"]。若此时 p1 改价到 500,它可能再次出现在后面;一个原本在后面的商品降价到 50,又可能被跳过。唯一排序键消除了同值歧义,却无法冻结商品价格。
PIT 在创建时保留索引的一组可搜索 segment 视图。后续索引继续变化,同一个 PIT 仍可访问原来的内容。只针对已经可搜索的数据创建快照:需要纳入刚写入的数据,应先等其 refresh 可见。PIT 的资源和失效语义见 Point in Time。
一次遍历需要保持:
- 同一个有效 PIT;若所用接口返回了新的 PIT ID,续页保存最新返回值。
- 相同租户与权限范围、查询条件、排序方向和字段类型。
- 完整的 sort 数组,包括用于打破平局的字段。
- 已确认交付的页面位置,避免“请求已发出”就提前推进游标。
PIT ID 和 sort 数组不适合任由客户端拼装。服务端可保存游标状态,或对包含查询摘要、访问范围、过期时间的游标签名。权限发生变化时,应重新校验当前身份,而非借旧游标继续访问已撤销的数据。
在真实索引中观察快照
下载并解压OpenSearch 实验工程,进入 opensearch-lab。使用 Linux Bash、Docker Engine/Compose、curl、jq;账号需要有 Docker 执行权限。镜像是 OpenSearch 3.8.0,单节点限制 2 GiB、512 MiB Java 堆,端口只绑定宿主回环。该配置关闭 Security 插件,仅用于隔离的本机实验。部署选项见 Docker 安装说明。
set -euo pipefail
command -v docker curl jq
docker compose -p search22 up -d --wait --wait-timeout 180
export SEARCH_URL=http://127.0.0.1:19222
INDEX="lab22-query-$$"
curl -q --noproxy '*' --fail-with-body --silent --show-error \
"$SEARCH_URL/" | jq -e '.version.number == "3.8.0"'
curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X PUT "$SEARCH_URL/$INDEX" -H 'Content-Type: application/json' \
--data-binary @src/test/resources/query/index.json | jq -e '.acknowledged'
curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X POST "$SEARCH_URL/$INDEX/_bulk" -H 'Content-Type: application/x-ndjson' \
--data-binary @src/test/resources/query/products.ndjson \
| jq -e '.errors == false and (.items | length) == 4'
curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X POST "$SEARCH_URL/$INDEX/_refresh" | jq -e '._shards.failed == 0'创建索引失败时停止,不要改为删除同名索引后重试。若服务没有就绪,检查 docker compose -p search22 logs opensearch、端口占用、内存和宿主 vm.max_map_count;不要先跳过版本和健康检查。
实验记录为 p1 到 p4,价格依次是 100、200、300、400;前三条属于租户 a,p4 属于租户 b。下面为了观察完整索引快照,查询四条记录;实际业务接口仍应带租户过滤。
PIT=$(curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X POST "$SEARCH_URL/$INDEX/_search/point_in_time?keep_alive=2m" \
| jq -er '.pit_id')
PAGE=$(curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X POST "$SEARCH_URL/_search" -H 'Content-Type: application/json' \
--data "$(jq -nc --arg id "$PIT" \
'{size:1,pit:{id:$id,keep_alive:"2m"},sort:[{price:"asc"},{id:"asc"}]}')")
printf '%s\n' "$PAGE" | jq -e \
'.timed_out == false and ._shards.failed == 0 and .hits.hits[0]._id == "p1"'
CURSOR=$(printf '%s\n' "$PAGE" | jq -c '.hits.hits[-1].sort')续页请求把 CURSOR 作为 JSON 数组传入:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X POST "$SEARCH_URL/_search" -H 'Content-Type: application/json' \
--data "$(jq -nc --arg id "$PIT" --argjson after "$CURSOR" \
'{size:1,pit:{id:$id,keep_alive:"2m"},
sort:[{price:"asc"},{id:"asc"}],search_after:$after}')" \
| jq '.hits.hits[] | {id:._id,sort}'这一步返回 p2。完整并发修改过程由工程内 QueryTest 自动执行:先读 p1,再插入价格 50 的 p0,将 p2 改为 500,删除 p3 并 refresh,最后遍历 PIT。观察结果为:
PIT IDs=[p1, p2, p3, p4]; live first=p0; live p3 absent
closed PIT: HTTP 404 search_context_missing_exceptionPIT 保留了 p2 的旧排序位置和已删除的 p3。新建 PIT 会看到修改后的数据,无法恢复刚刚关闭的旧快照。导出中断后,如果旧 PIT 已失效,需要重新导出并按稳定业务键去重,或改用可持久恢复的上游快照机制。
关闭、过期和资源回收
curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X DELETE "$SEARCH_URL/_search/point_in_time" \
-H 'Content-Type: application/json' \
--data "$(jq -nc --arg id "$PIT" '{pit_id:[$id]}')" | jq .关闭后再使用该 PIT,实验版本返回 404,错误中包含 search_context_missing_exception。这类错误需要重新建立遍历,不应无限重发相同游标。
keep_alive 应覆盖相邻请求的合理间隔,按需续期。长时间保留大量 PIT 会使旧 segment 无法及时回收,增加磁盘和文件资源占用;导出结束、用户取消或发生不可恢复错误时,都应释放自己创建的 PIT。
聚合怎样统计同一批文档
指标、分桶和管道
聚合从匹配文档中计算统计量。size: 0 只省去命中明细,仍然执行查询与聚合。
| 类型 | 常用操作 | 返回含义 |
|---|---|---|
| 指标 | sum、avg、min、max、stats | 对匹配字段值求统计量 |
| 近似指标 | cardinality、percentiles | 去重计数或分位数估计,需要理解精度和内存权衡 |
| 分桶 | terms、range、date_histogram、filters | 按分类、区间、时间或条件分组 |
| 嵌套对象分桶 | nested / reverse_nested | 在嵌套文档与父文档统计范围间转换 |
| 管道 | derivative、bucket_script、bucket_sort | 基于前面已经形成的桶计算或处理结果 |
完整类型和数值限制见 Aggregations。金额以整数最小单位保存可避免输入阶段的小数误差;聚合实现仍可能使用浮点表示,超大整数和财务精确计算需要另行验证数值范围。
日期分桶还要确定时区与间隔。自然日、自然月适合 calendar_interval;固定 24 小时用 fixed_interval。夏令时地区的自然日未必恰好 24 小时。需要连续空桶时,配置边界和最小文档数,避免把“这天没有记录”误读为“这天没有统计”。参数见 Date histogram。
分类 Top N 与完整分类列表
terms 默认返回最常见的若干桶。下面只取租户 a 的一个分类桶:
{
"size": 0,
"query": {"term": {"tenant": "a"}},
"aggs": {
"categories": {"terms": {"field": "category", "size": 1}},
"amount": {"sum": {"field": "price"}}
}
}实验返回 clothes 两条,sum_other_doc_count: 1 表示还有一条匹配文档属于未返回的 home 桶;总金额仍为 600,因为 sum 的统计范围是所有匹配文档。
多分片时,每片先提交候选桶,协调节点合并。shard_size 太小可能使某些全局重要桶遗漏局部计数。doc_count_error_upper_bound 的可用性与桶排序方式有关;按某些子聚合排序时不能套用默认计数排序的误差解释。增大 shard_size 会增加分片传输与归并内存。规则见 Terms aggregation。
这里的单分片结果展示了 Top N 截断。分布式计数误差还需在多分片上分散同一分类的数据,比较候选桶和归并结果。
需要遍历全部分组键时,使用 composite:
{
"size": 0,
"query": {"term": {"tenant": "a"}},
"aggs": {
"categories": {
"composite": {
"size": 1,
"sources": [{"category": {"terms": {"field": "category"}}}]
}
}
}
}下一页将响应中的 after_key 整体放到 composite 的 after。不要从最后一个桶自行拼接游标;返回的 after_key 才是 API 给出的继续位置。实验遍历结果是 {clothes=2, home=1}。Composite aggregation说明了多来源排序、缺失值和续页规则。
composite 默认按来源键顺序遍历,不是“按总销售额排名”的完整榜单。跨页统计如果要求固定数据范围,也需要稳定的数据视图或明确停止变化的时间窗口。
搜索命中与筛选面板的范围
商品列表选中 home 后,筛选面板可能仍需显示当前租户的所有分类。可以将租户放在 query,将类别选择放在 post_filter:
{
"query": {"term": {"tenant": "a"}},
"post_filter": {"term": {"category": "home"}},
"aggs": {"categories": {"terms": {"field": "category"}}}
}返回 1 条 home 商品,而分类聚合仍统计租户 a 的 3 条商品。post_filter 只过滤命中列表;如果把租户约束也移到那里,聚合便可能包含其他租户的数据。Filter search results对三种过滤位置作了区分。
控制查询开销与失败结果
成本要沿实际执行环节查
深分页之外,常见放大来源包括高基数字段、多层分桶、很宽的时间范围、通配符或正则扩展、脚本执行、大量高亮片段,以及涉及过多小分片。应先确认慢在匹配、排序、聚合、字段取回还是请求排队。
profile: true 可以展开查询内部执行器的耗时,适合针对代表性请求诊断;它本身带来额外开销,也不覆盖完整网络和所有服务端等待,不能直接作为普通请求的基准延迟。Profile API列出了覆盖范围。
应用可以按接口定义最大页大小、最长时间窗口、允许的排序字段与聚合深度。需要 expensive query 的管理接口应隔离并发额度,不能与高频商品查询共用无限队列。
通过真实负例确认限制,再选择恢复方式
QueryTest 将自己创建的索引 max_result_window 临时设为 3,再请求 from=3、size=1。服务真实返回 400 和 Result window is too large;改用每页 1 条的 PIT 后读完 4 条,无须增大窗口。
运行完整的五项实验:
mkdir -p .m2
docker run --rm --memory 2g --entrypoint mvn \
--user "$(id -u):$(id -g)" --network search22_default \
-e MAVEN_CONFIG=/tmp/maven -e SEARCH_URL=http://opensearch:9200 \
-v "$PWD":/work -v "$PWD/.m2":/m2 -w /work \
maven:3.9.12-eclipse-temurin-17 \
-B -Duser.home=/tmp -Dmaven.repo.local=/m2 -Dtest=QueryTest clean verify预期 Tests run: 5, Failures: 0, Errors: 0。测试创建随机名称的专用索引,并检查 bool 命中集合、PIT 并发变化、深分页拒绝、聚合完整遍历和 post_filter 范围,结束时删除自己成功创建的索引。需要 Java 25 对照时,只将 Maven 镜像后缀改为 eclipse-temurin-25,项目仍以 Java 17 为编译目标。
测试失败时查看 target/surefire-reports 中具体 HTTP 响应。400 类型错误先修查询或 Mapping;404 PIT 失效重建遍历;超时或分片失败则结合慢查询与搜索降级检查负载和可用性。
手动实验完成后,只删除上面成功创建的索引,并停止这套 Compose。命名卷保留,可再次启动:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X DELETE "$SEARCH_URL/$INDEX" | jq -e '.acknowledged'
docker compose -p search22 down权威资料与规范地址
查询组合与相关度
- 全文查询类型:https://docs.opensearch.org/latest/query-dsl/full-text/index/
- bool 组合规则:https://docs.opensearch.org/latest/query-dsl/compound/bool/
- 相关度与相似度:https://docs.opensearch.org/latest/im-plugin/similarity/
- Search API:https://docs.opensearch.org/latest/api-reference/search-apis/search/
- 自动补全:https://docs.opensearch.org/latest/search-plugins/searching-data/autocomplete/
- 混合检索:https://docs.opensearch.org/latest/vector-search/ai-search/hybrid-search/index/
分页与快照
- 分页方式:https://docs.opensearch.org/latest/search-plugins/searching-data/paginate/
- PIT:https://docs.opensearch.org/latest/search-plugins/searching-data/point-in-time/
聚合计算与过滤位置
- 聚合类型与限制:https://docs.opensearch.org/latest/aggregations/
- 日期直方图:https://docs.opensearch.org/latest/aggregations/bucket/date-histogram/
- Terms 聚合:https://docs.opensearch.org/latest/aggregations/bucket/terms/
- Composite 聚合:https://docs.opensearch.org/latest/aggregations/bucket/composite/
- 过滤位置:https://docs.opensearch.org/latest/search-plugins/filter-search/
