排序、分页与高亮
搜索结果展示三要素:排序规则、分页策略和关键词高亮。
排序(sort)
普通字段排序
GET /bank/_search
{
"query": { "match_all": {} },
"sort": [
{ "account_number": { "order": "asc" } },
{ "age": { "order": "desc" } }
]
}多字段排序:按数组顺序优先,第一个相同则比较第二个。
💡 最佳实践:一旦指定了
sort,ES 自动停止相关性打分(_score: null),显著提升查询性能。
排序模式(sort mode)
当排序字段有多个值(数组)时,指定取哪个值排序:
GET /_search
{
"sort": [
{
"ratings": {
"order": "desc",
"mode": "avg"
}
}
]
}| mode | 说明 |
|---|---|
min |
取最小值 |
max |
取最大值 |
avg |
取平均值 |
sum |
取总和 |
median |
取中位数 |
地理距离排序
GET /restaurants/_search
{
"query": { "match_all": {} },
"sort": [
{
"_geo_distance": {
"location": { "lat": 40, "lon": -70 },
"order": "asc",
"unit": "km",
"distance_type": "plane"
}
}
]
}| 参数 | 说明 |
|---|---|
unit |
距离单位:km / m / mi / yd |
distance_type |
arc(默认,球面距离,精确)/ plane(平面距离,较快) |
Script 排序
GET /products/_search
{
"query": { "match_all": {} },
"sort": [
{
"_script": {
"type": "number",
"script": {
"lang": "painless",
"source": "doc['price'].value * (1 - doc['discount'].value / 100)"
},
"order": "asc"
}
}
]
}保持评分的同时排序
GET /_search
{
"query": {
"function_score": {
"query": { "match": { "title": "手机" } },
"functions": [{ "field_value_factor": { "field": "sales" } }],
"boost_mode": "sum"
}
},
"sort": [
{ "_score": "desc" },
{ "price": "asc" }
]
}分页
基础分页(from + size)
GET /bank/_search
{
"query": { "match_all": {} },
"from": 10,
"size": 10
}| 参数 | 说明 | 默认值 |
|---|---|---|
from |
跳过的文档数(偏移量) | 0 |
size |
返回的文档数 | 10 |
🚨 陷阱:ES 对
from + size上限为max_result_window,默认 10000。from: 9990, size: 20会直接报错。此限制可通过index.max_result_window调高,但强烈不推荐——应当使用search_after或scroll。
深度分页的性能问题
ES 是分布式系统,分页执行过程:
1. Coordinating Node 向每个分片请求 from + size 条文档
2. 每个分片排序后返回 top(from+size) 给协调节点
3. 协调节点在内存中对 (分片数 × (from+size)) 条数据重新排序
4. 截取 [from, from+size) 返回🔬 深入原理:假设 5 个主分片,查
from: 10000, size: 20。每个分片返回 10020 条文档,协调节点需对 50100 条数据进行排序,然后丢弃前 10000 条,只保留 20 条返回。这正是深度分页低效的根源。
search_after — 实时深度分页(推荐)
利用上一页最后一条文档的排序值作为游标,避免 skip 开销。
# 第一页
GET /logs/_search
{
"size": 10,
"query": { "match": { "level": "error" } },
"sort": [
{ "@timestamp": "asc" },
{ "_id": "asc" } // ⚠️ 必须有 tiebreaker(_id 是唯一的)
]
}
# 响应中每条 hit 都有 "sort": [1718123456789, "doc-xxx"]# 下一页:用上一页最后一条的 sort 值
GET /logs/_search
{
"size": 10,
"query": { "match": { "level": "error" } },
"sort": [
{ "@timestamp": "asc" },
{ "_id": "asc" }
],
"search_after": [1718123456789, "doc-xxx"]
}| 特性 | search_after | from+size | scroll |
|---|---|---|---|
| 实时性 | ✅ 实时 | ✅ 实时 | ❌ 快照(滞后) |
| 性能 | ✅ 高效 | ❌ 深度页慢 | ✅ 快(快照) |
| 随机跳页 | ❌ 不支持 | ✅ 支持 | ❌ 不支持 |
| 依赖 | 排序值必须唯一 | — | 大量堆内存 |
| 推荐度 | ⭐⭐⭐ 首选 | ⭐ 浅分页 | ⭐ 批量导出 |
🚨 陷阱:
search_after要求排序值全局唯一。如果只用时间戳,同一毫秒可能有多条文档导致数据丢失。始终在 sort 数组末尾加_id或_doc作为 tiebreaker。
scroll — 快照分页(批量导出专用)
创建数据快照,适合一次性批量导出,不适合实时搜索。
# 创建 scroll,设定快照存活时间
POST /bank/_search?scroll=5m
{
"size": 100,
"query": { "match_all": {} }
}
# 翻页(每次翻页都会重置存活时间)
POST /_search/scroll
{
"scroll": "5m",
"scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAA..."
}
# 用完必须清理
DELETE /_search/scroll
{
"scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAA..."
}
# 清理所有 scroll
DELETE /_search/scroll/_all🚨 陷阱:scroll 占用的堆内存不会在超时前释放。未清理的 scroll 是内存泄漏的常见原因。监控
GET /_nodes/stats/indices/search中open_contexts指标。
Point In Time (PIT) — ES 7.10+ 新方式
PIT 是 scroll 的轻量级替代,不锁资源:
# 创建 PIT
POST /bank/_pit?keep_alive=5m
# → { "id": "46+xBAAAA..." }
# 使用 PIT 搜索(省略索引名)
GET /_search
{
"size": 100,
"query": { "match_all": {} },
"pit": {
"id": "46+xBAAAA...",
"keep_alive": "5m"
},
"sort": [{ "@timestamp": "asc" }, { "_id": "asc" }],
"search_after": [1718123456789, "doc-xxx"]
}
# 释放 PIT
DELETE /_pit
{ "id": "46+xBAAAA..." }💡 最佳实践:实时分页用 PIT + search_after(ES 7.10+),批量导出用 scroll(尽快清理),浅分页用 from + size。
折叠(Collapse)— 按字段去重
类似 SQL 的 DISTINCT,对某个字段去重,每组只返回最相关的一条。
GET /orders/_search
{
"query": { "match": { "product": "手机" } },
"collapse": {
"field": "user_id",
"inner_hits": {
"name": "other_orders",
"size": 5
}
}
}| 参数 | 说明 |
|---|---|
field |
折叠字段,必须为 keyword 类型 |
inner_hits |
获取每组内更多文档(折叠组展开) |
max_concurrent_group_searches |
最大并发组内搜索数 |
高亮(highlight)
GET /bank/_search
{
"query": {
"match": { "firstname": "Yvette" }
},
"highlight": {
"fields": {
"firstname": {
"pre_tags": ["<em>"],
"post_tags": ["</em>"],
"number_of_fragments": 1,
"fragment_size": 100
},
"city": {}
}
}
}
高亮三种类型
| 类型 | 适用场景 | 性能 | 配置 |
|---|---|---|---|
unified |
默认,通用场景 | 中等 | fragment_size、number_of_fragments |
plain |
简单场景 | 快 | 不支持 fragment_size |
fvh (Fast Vector Highlighter) |
term_vector: with_positions_offsets 的字段 |
最快 | 支持多片段、短语高亮 |
高亮核心参数
| 参数 | 说明 | 默认值 |
|---|---|---|
pre_tags / post_tags |
高亮标签 | <em> / </em> |
number_of_fragments |
返回片段数,0 = 返回整个字段 |
5 |
fragment_size |
每个片段的字符数 | 100 |
require_field_match |
高亮字段必须与搜索字段一致 | true |
no_match_size |
未匹配时返回的字段前缀长度 | — |
🚨 陷阱:默认
require_field_match: true,即搜索firstname高亮city不会生效。跨字段高亮需设为false。
自定义高亮标签
GET /bank/_search
{
"query": { "match": { "address": "mill lane" } },
"highlight": {
"pre_tags": ["<mark class='hl'>"],
"post_tags": ["</mark>"],
"fields": {
"address": {
"require_field_match": "false",
"fragment_size": 150,
"number_of_fragments": 3
}
}
}
}高亮性能优化
⚡ 性能提示:
- 高亮文本字段(text)需要读取
_source或term_vector来定位匹配位置- 对需要频繁高亮的关键字段,建议在 Mapping 中启用
term_vector: with_positions_offsets- 长文本高亮建议限制
fragment_size,避免返回整个字段
// 为高亮优化 Mapping
{
"mappings": {
"properties": {
"content": {
"type": "text",
"term_vector": "with_positions_offsets"
}
}
}
}常见陷阱
| 陷阱 | 说明 |
|---|---|
🚨 from + size > 10000 |
超过 max_result_window 直接报错 |
🚨 search_after 排序值不唯一 |
可能导致数据丢失,末尾加 _id 保底 |
| 🚨 scroll 不清理 | 内存泄漏的常见原因,用完务必 DELETE |
| 🚨 scroll 不感知后续写入 | scroll 是快照,新建或被删除的文档不可见 |
| 🚨 高亮字段与搜索字段不一致 | require_field_match: false 解决 |
🚨 collapse 字段非 keyword |
text 字段不可用于折叠去重 |
🚨 长文本高亮不设 fragment_size |
可能返回整个文档内容 |