上一篇建立了 KQL→DSL 的翻译管道——Search Source 把 KQL、Lucene query string 和过滤器 pills 组装成一个合法的 ES 请求体。这一篇进入 Discover。Discover 容易被误解成"就是个搜索框加日志表格"。更准确的说法是:Discover 是一个多请求编排的 UI 状态机——打开一次 Discover 并不是发一次 ES 请求,而是至少发三类请求:field_caps 请求(字段侧边栏)、date_histogram 聚合请求(顶部直方图)、以及 document search 请求(文档表格);每次条件变化,三类请求都会按依赖顺序触发。本文只抓一个问题:字段列表怎么来、直方图怎么算、翻页为什么用 search_after,以及这三件事背后共用的 Search Source 父子结构。

Discover 的请求拓扑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
用户打开 Discover(或改变条件)

├─── [1] field_caps 请求
│ GET /<index-pattern>/_field_caps?fields=*
│ → 返回每个字段的类型和能力集
│ → 驱动左侧字段侧边栏渲染
│ 触发条件:Data View 切换、mapping 变化

├─── [2] date_histogram 聚合请求(直方图)
│ POST /<index-pattern>/_search
│ body: { aggs: { histogram: { date_histogram: { field: @timestamp, ... } } },
│ size: 0, ← 不要文档,只要聚合结果
│ query: <当前 query + filters + 时间范围> }
│ → 返回每个时间桶的文档数
│ → 驱动顶部柱状直方图渲染

└─── [3] document search 请求(文档表格)
POST /<index-pattern>/_search
body: { query: <当前 query + filters + 时间范围>,
size: 500, ← 当前页文档数
sort: [{ @timestamp: desc }],
fields: [...选中列...],
_source: false,
track_total_hits: false ← 性能优化,不精确计数 }
→ 返回文档列表
→ 驱动文档表格渲染

请求 [2] 和 [3] 共享同一个父 Search Source(含 Data View + query + filters + 时间范围)
[2] 子 Search Source 覆盖:aggs=date_histogram, size=0
[3] 子 Search Source 覆盖:fields=选中列, sort=时间倒序, size=页大小

字段侧边栏:field_caps API

Discover 左侧的字段列表不是直接读 Data View 的字段配置,而是通过 field_caps API 实时查询索引的字段能力集,再与 Data View 的元数据合并渲染。

field_caps 返回的每个字段包含:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"status": {
"type": "keyword",
"searchable": true,
"aggregatable": true,
"indices": ["logs-2026.08.*"] ← 哪些索引有这个字段
},
"message": {
"type": "text",
"searchable": true,
"aggregatable": false ← text 字段默认不可聚合
}
}

aggregatable: true 的字段在侧边栏会显示"可视化"快捷按钮;searchable: true 的字段可以在 KQL 里按 field:value 过滤。两者都是 false 的字段在侧边栏仍然列出(通常来自 _ignored 之类的元字段)但标为不可用。

字段侧边栏里的文档频率和 top values(点击字段展开后显示)来自另一类内嵌聚合请求:对该字段发一个 terms 聚合,取 top N 个值及其计数。这个请求是懒触发的,点击字段展开时才发。

直方图:date_histogram 聚合的参数逻辑

顶部柱状图是一次 date_histogram 聚合,核心参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"aggs": {
"histogram": {
"date_histogram": {
"field": "@timestamp",
"fixed_interval": "1h", ← 或 calendar_interval: "1d"
"min_doc_count": 0, ← 空桶也显示(显示时间轴上的"0")
"extended_bounds": {
"min": <时间范围开始>,
"max": <时间范围结束>
}
}
}
},
"size": 0
}

时间粒度(fixed_interval)由 Discover 根据时间范围动态选择:时间范围越大,粒度越粗(天/小时),时间范围越小,粒度越细(分/秒)。这个计算是客户端逻辑,使目标桶数大约保持在屏幕像素数的合理范围内。

size: 0 是关键:直方图请求只要聚合结果,不需要任何文档,这个请求和文档请求 [3] 是并发发出的,互不阻塞。

自 Kibana 8.x 起,直方图的时间粒度可在 Discover 的图表设置里手动固定;若不手动设置,仍走自动档。

文档表格:search_after 翻页

文档表格的翻页采用 search_after 机制,而不是传统的 from + size

传统 from + size

1
2
GET /_search
{ "from": 10000, "size": 10 }

问题:ES 需要在内存中排序并丢弃前 10000 条,深度翻页时内存开销随 from 线性增长,默认有 index.max_result_window: 10000 限制。

search_after 机制:

1
2
3
4
5
6
7
8
9
10
第一页:
GET /_search
{ "size": 500, "sort": [{ "@timestamp": "desc" }, { "_id": "desc" }] }
→ 返回文档,记录最后一条的 sort values: [1722912000000, "docid-xyz"]

第二页(继续往前翻):
GET /_search
{ "size": 500,
"sort": [{ "@timestamp": "desc" }, { "_id": "desc" }],
"search_after": [1722912000000, "docid-xyz"] }

search_after 不需要在内存中保留跳过的文档,每次翻页是 O(1) 内存开销(相对于 from)。Discover 文档表格的"加载更多"按钮就是在这个游标上追加。

sort 数组里额外带上 _id 的原因:时间戳相同的文档必须有确定的排序,否则分页边界不稳定,会出现重复或跳过。

Discover 的列选择(文档表格里显示哪些字段)直接影响请求 [3] 的 fields 参数——只请求用户选中的字段,不传 _source: true(自 7.x 起 Discover 默认用 fields 参数而非 _source,这样 runtime fields 和 field aliases 才能正确返回)。

点击"Save"保存当前 Discover 视图,实际上写入了一个 type: search 的 Saved Object,它的 attributes 里包含:

1
2
3
4
5
6
7
8
9
10
{
"title": "My Saved Search",
"description": "",
"columns": ["status", "method", "bytes"], ← 列配置
"sort": [["@timestamp", "desc"]],
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{...}" ← Search Source 的序列化状态
含 query + filters + index(Data View id)
}
}

这个 Saved Object 的 references 数组里有一条指向所用 Data View 的引用。其他组件(Dashboard Embeddable、Visualize 基于 Saved Search 建图)通过 references 找到这个依赖,确保 Data View 存在时才渲染。

上下文文档(Surrounding Documents)

Discover 文档表格每行有一个展开按钮,还有一个"查看上下文文档"链接。点击后,Kibana 额外发两条请求:

1
2
3
4
5
6
7
向前(older):
{ "query": { "range": { "@timestamp": { "lt": <当前文档时间戳> } } },
"sort": [{ "@timestamp": "desc" }], "size": 5 }

向后(newer):
{ "query": { "range": { "@timestamp": { "gt": <当前文档时间戳> } } },
"sort": [{ "@timestamp": "asc" }], "size": 5 }

两条请求的 query 里包含当前 Discover 的全部 filters,只放开时间范围约束,用相邻时间范围替代。结果就是"在当前过滤条件下,这条日志前后各 5 条"。

实验:用 Inspect 面板捕获三类请求

以下步骤在 Kibana 7.x/8.x Discover 界面均可执行:

  1. 打开 Discover,选择一个日志类 Data View(含 @timestamp 字段),设置时间范围为最近 1 小时。

  2. 点击 Discover 右上角 Inspect 按钮,展开 Requests 标签,观察页面加载时发出了几条请求——应该能看到至少两条:直方图请求(名称通常含 “Chart”/“Histogram”)和文档请求(名称通常含 “Documents”)。

  3. 展开直方图请求的 Request 面板,验证:

    • size: 0(不返回文档)
    • aggs.histogram.date_histogram 存在,field 为 Data View 的时间字段
    • query.bool.filter 包含时间范围
  4. 展开文档请求的 Request 面板,验证:

    • size 等于页面大小(默认 500)
    • sort 含时间字段降序 + _id 作为决定性排序键
    • fields 数组只包含侧边栏选中的列
    • _source: false
  5. 缩小时间范围到最近 5 分钟,重新观察直方图请求,确认 fixed_interval 变细(秒级)。

  6. 在侧边栏点击一个 keyword 字段展开 top values,打开 Inspect 观察是否新增了一条 terms 聚合请求。

模式提炼

1
2
3
4
5
6
7
8
9
10
11
模式:单 UI 面板 = 多类并发请求,共享查询父上下文

- 把"查询条件"(Data View + filters + 时间范围)提升成父 Search Source,
子组件只覆盖自己的差异部分(aggs / size / sort)
- 结构化字段能力(field_caps)和文档数据(_search)分离,
字段能力可独立缓存,不受每次过滤变化影响
- 翻页用 search_after 而非 from,深页性能 O(1)
- 列/排序/过滤的 UI 状态序列化为 Saved Object,
实现跨会话、跨浏览器的视图共享
- 懒触发聚合(字段 top values):不在初次加载时计算,
仅在用户展开字段时触发,降低初始加载开销

工程迁移表

Discover 概念 工程类比
field_caps API → 字段侧边栏 GraphQL introspection → schema explorer
date_histogram size:0 → 直方图 SQL SELECT date_trunc, COUNT(*) GROUP BY 独立查询
父 Search Source 继承 React Context / 依赖注入:全局 filter 下沉到子查询
search_after 翻页 keyset pagination(cursor-based)vs offset pagination
columns → fields 参数 GraphQL field selection,只请求需要的列
Saved Search → Saved Object 查询书签 / 视图配置持久化
Surrounding Documents 日志系统"上下文行"功能(Loki LogQL context)
Grafana Explore 与 Discover 同构的日志探索 UI,查询模型不同(Loki/Tempo/Prom)

常见误解

误解一:“Discover 每次刷新只发一条 ES 请求”。打开 Discover 至少触发三类请求,其中 field_caps 和直方图在很多场景下并发发出,文档请求紧随其后。用 Inspect 面板或浏览器 DevTools Network 标签可以看到全部请求。

误解二:“Discover 的分页是 from+size,翻到一万条后会报错”。Discover 默认用 search_after,不受 index.max_result_window 限制。from+size 限制只影响显式写 from 参数的请求(包括某些旧版 Kibana 的聚合和自定义脚本)。

误解三:“字段侧边栏显示的 top values 是实时的全量统计”。侧边栏的 top values 是对当前时间范围内的 terms 聚合,受时间范围限制,不是对整个索引的全量统计;点击字段展开时才触发,不是预加载。

误解四:“保存 Discover 视图只保存了搜索条件”。Saved Search 对象还保存了列配置、排序、以及完整的 Search Source(含 KQL query、所有 filter pills)。Dashboard 里嵌入的 “Saved Search” panel 实际上是把这整个 Saved Object 加载并运行,包括它的列和过滤器。

练习

  1. 在 Discover 的 Inspect 面板里找到直方图请求和文档请求,确认两者共享相同的 query.bool.filter 内容(除时间范围桶参数外),验证父子 Search Source 继承关系在 network 层的体现。

  2. 把 Discover 列配置改为只显示 statusmethod 两列,再打开 Inspect 查看文档请求的 fields 参数——验证确实只请求了这两个字段,而不是 _source: true。保存为 Saved Search,用 GET api/saved_objects/_find?type=search 找到刚保存的对象,读它的 columnssearchSourceJSON

  3. 思考题:search_after 翻页要求排序键在分页边界上是稳定且唯一的(因此加 _id)。在自己的业务系统里,如果要对一张没有自增主键的宽表实现无限滚动翻页,应该用哪个字段或字段组合作为游标?如果时间戳精度不够(同毫秒多条),有什么兜底策略?

系列导航

序号 主题 状态
00 导读:Kibana 的状态存在 Elasticsearch 里 已发布
01 Kibana 架构:浏览器、Node.js server 与 New Platform
02 Saved Object:Kibana 一切状态的统一模型
03 Data View(Index Pattern):查询之前的字段抽象
04 Search Source 与查询翻译:KQL、Lucene 与 Query DSL 上一篇
05 Discover:交互式检索的执行模型 本篇
06 聚合式可视化的老路:Visualize 与 bucket/metric 下一篇
07-09 Lens / TSVB / Dashboard 后续阶段
10-12 告警、上报与自动化 后续阶段
13-15 平台、安全与扩展 后续阶段
16-17 演进、生态与对比 后续阶段

参考资料