日志平台的检索界面用久了,总会遇到一些"页面做不到"的需求:只想看看时间窗口内到底有哪些日志路径、想按分钟拉一条日志量趋势、想确认每个采集任务"最后一条日志是什么时候写入的"。这些场景直接写 Elasticsearch DSL 查询,比点界面快得多,也更灵活。
本文整理自一套日志平台(Kafka 采集 → Flink 处理 → Elasticsearch 存储,索引名 logs_app*)的真实检索实践,环境为 Elasticsearch 7.17 + Kibana 7.17 。全文围绕 4 个生产级查询模板展开:时间范围检索、collapse 字段去重、date_histogram 分钟级统计、terms + top_hits 取每组最新日志,最后附一份 Kibana 只读用户配置流程和踩坑清单。DSL 均可在 Kibana Dev Tools 中直接执行。
一、索引结构:先搞清楚字段再写查询
写 DSL 之前先看存储结构。日志索引 logs_app 的核心字段如下(完整 mapping 见下文):
| 字段名 | 类型 | 说明 |
|---|---|---|
| collect_time | date | 采集时间,代理端抓到这行日志的时间 |
| receive_time | date | 接收时间,日志上传到 Kafka 的时间 |
| log_time | date | 日志时间,从日志文本中解析出的业务时间 |
| save_time | date | 保存时间,写入 ES 的时刻 |
| logSource | keyword | 日志来源路径 |
| log_id | keyword | 日志采集任务 ID |
| log_record_number | long | 日志行号 |
| raw_message | text | 日志原始文本 |
| result | object | 结构化解析结果,子字段由采集任务的解析规则决定 |
| host_ip | keyword | 采集对象 IP |
| host_id | keyword | 采集对象在 CMDB 中的资源 ID |
| pattern_id | long | 日志模板(模式)ID,经模式识别算法解析后附带 |
| tag | keyword | 采集任务标签 |
| container_id / container_name / name_space / pod_id / pod_name | text | 容器与 K8s 场景专用字段 |
有两个设计细节值得注意,直接影响后面的查询写法。
四个时间字段,语义不同。 一次链路里 collect_time → receive_time → save_time 是平台侧的处理时间,log_time 才是业务时间。检索"业务上 14:20~14:30 发生了什么"用 log_time;排查"采集链路有没有延迟/堆积"时对比 log_time 与 save_time 的差值。查询统计口径选错字段,结论会完全跑偏。
多格式日期。 日志时间来源五花八门,所以日期字段统一声明了多 format 兜底:
json
"log_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss.SSS||yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||strict_date_optional_time||epoch_millis"
}
这样 2025-11-19 14:20:19.000、2025-11-19 14:20:19、毫秒时间戳都能直接写入和 range 查询。
另外 result 是动态解析结果,mapping 里用 dynamic_templates 把 result.* 中匹配 *Time / *time 的字符串自动映射为 date,其余交给动态映射:
json
"dynamic_templates": [
{
"time_as_date": {
"match": "*Time",
"path_match": "result.*",
"match_mapping_type": "string",
"mapping": {
"type": "date",
"format": "yyyyMMdd HH:mm:ss||yyyy-MM-dd HH:mm:ss.SSS||yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||strict_date_optional_time||epoch_millis||HH:mm:ss",
"null_value": "1970-01-01T00:00:00Z"
}
}
}
]
*time 小写后缀可以再配一条同样的规则,此处不再重复。
二、查询一:时间范围内枚举所有日志路径(collapse 去重)
场景:某应用接入了十几路日志,想快速确认"最近 10 分钟到底有哪些日志路径在写入"。这就是一个按 logSource 字段的去重需求。ES 没有原生 DISTINCT,但 collapse(字段折叠)就是为此设计的:
json
GET /logs_app*/_search
{
"size": 9999,
"query": {
"bool": {
"filter": [
{ "range": { "log_time": { "gte": "2025-11-19 14:20:19", "lte": "2025-11-19 14:30:19" } } }
]
}
},
"_source": false,
"collapse": {
"field": "logSource"
}
}
要点:
_source: false不取文档内容,只留fields里折叠后的logSource值,响应体非常小;collapse只能作用在keyword或数值类型字段上------这也是logSource、host_ip这类字段必须建成 keyword 的原因之一;- 去重结果没有总数值,且分页语义和普通查询不同,只适合"拉清单";
- 若去重之外还要每组计数,用
terms聚合代替:"aggs": { "paths": { "terms": { "field": "logSource", "size": 100 } } }。
顺带一提,有些客户端(例如 MyBatis-Plus 的 ES 插件、部分低版本 SDK)会把一个时间范围拆成两条 filter------一条只有 from、一条只有 to。语义上与单条 gte + lte 完全等价,看到生成出来的 DSL 长这样不必慌:
json
"filter": [
{ "range": { "log_time": { "from": "2025-11-19 14:20:19.000", "to": null } } },
{ "range": { "log_time": { "from": null, "to": "2025-11-19 14:30:19.000" } } }
]
三、查询二:时间范围内的日志内容(排序 + 高亮 + 精确总数)
标准的日志检索:取最新 10 条,按业务时间倒序,命中关键词整段高亮。
json
GET /logs_app*/_search
{
"from": 0,
"size": 10,
"query": {
"bool": {
"filter": [
{ "range": { "log_time": { "gte": "2025-11-19 14:20:19", "lte": "2025-11-19 14:30:19" } } }
],
"must": [
{ "match_phrase": { "raw_message": "timeout" } }
]
}
},
"sort": [
{ "log_time": { "order": "desc" } }
],
"track_total_hits": 2147483647,
"highlight": {
"fragment_size": 0,
"fields": { "raw_message": {} }
}
}
三个细节:
- filter 与 must 分工 :时间条件放
filter不算相关性得分(还可被缓存),关键词放must参与打分。日志检索谈不上排序艺术,但这样写开销更小。 track_total_hits: 2147483647:ES 默认命中数统计到 10000 就封顶显示gte: 10000。日志量动辄百万,前端要显示精确总数或做分页越界判断时必须打开它;只在确实需要精确计数时用,纯翻页场景维持默认反而省资源。fragment_size: 0:日志行必须整行返回才可读,默认按 100 字符切段会把堆栈切成碎片。设为 0 返回完整命中行。
四、查询三:按分钟统计日志量(date_histogram 趋势)
场景:画出 10 分钟窗口内的日志量曲线,用来肉眼定位日志尖刺。date_histogram 是标准答案:
json
GET /logs_app*/_search
{
"size": 0,
"query": {
"bool": {
"filter": [
{ "range": { "log_time": { "gte": "2025-11-19 14:20:19", "lte": "2025-11-19 14:30:19" } } }
]
}
},
"track_total_hits": 2147483647,
"aggregations": {
"count": {
"date_histogram": {
"field": "log_time",
"fixed_interval": "1m",
"min_doc_count": 0,
"extended_bounds": {
"min": "2025-11-19 14:20:19",
"max": "2025-11-19 14:30:19"
}
}
}
}
}
两个参数是画图正确性的关键:
min_doc_count: 0:没有日志的分钟也返回计数为 0 的桶。缺了它,曲线会自动"跳过"空档,图上看起来日志从没断过;extended_bounds:强制直方图铺满整个查询窗口。否则 ES 只返回第一条到最后一条数据覆盖的范围,窗口两端的空白会丢失。
另外 7.x 推荐 fixed_interval 替代已废弃的 interval;按自然日/月统计时用 calendar_interval,两者区别在于 fixed_interval: 30d 是固定 30 天,而 calendar_interval: "1M" 是自然月。
五、查询四:每个采集任务的最新日志时间(terms + top_hits)
场景:值班巡检,需要确认 6 个采集任务是否都"活着"------看每个 log_id 最近 10 分钟内最后一条 save_time 即可。分组取最新一条,是 terms 聚合嵌套 top_hits 的经典用法:
json
GET /logs_app*/_search
{
"size": 0,
"query": {
"bool": {
"filter": [
{
"terms": {
"log_id": [
"log-task-0001", "log-task-0002", "log-task-0003",
"log-task-0004", "log-task-0005", "log-task-0006"
]
}
},
{ "range": { "save_time": { "gte": "2025-11-19 14:37:34", "lte": "2025-11-19 14:47:34" } } }
]
}
},
"track_total_hits": 2147483647,
"aggregations": {
"all_log": {
"terms": {
"field": "log_id",
"size": 999
},
"aggregations": {
"my_top_hits": {
"top_hits": {
"size": 1,
"sort": [ { "save_time": { "order": "desc" } } ],
"_source": ["log_id", "logSource", "save_time", "raw_message"]
}
}
}
}
}
}
返回结构是每个 log_id 一个桶,桶内 my_top_hits.hits.hits[0] 就是该任务最新一条日志。注意三点:
- 这里时间字段刻意用了
save_time而不是log_time:判断"任务是否还在写入"看落库时间最直接,业务时间异常的日志(比如应用重启导致时钟错乱)不会误伤判断; terms.size: 999是桶数上限,任务多时按需调大,但要注意桶数越多聚合越贵;- 某个任务在桶里不出现,就是 10 分钟内一条都没写入------巡检脚本里"缺失即告警"比"逐个比对时间"更好写。
top_hits 里我还加了 _source 过滤,只回传需要的字段,6 个任务巡检的响应可以控制在 KB 级。
六、给业务方开通 Kibana 只读检索权限
排查问题时经常要把 Kibana 交给业务方自己查日志,直接给管理员账号显然不行。在开启了 security 的 7.x 集群上,用 角色 + 用户 的方式收敛权限,全程界面操作(也可以走 Security API):
- 建角色 :
Stack Management → Security → Roles → Create role。命名如logs_app_readonly;- Cluster privileges:只给
monitor(看集群健康状态必需); - Index privileges:
indices填logs_app*,privileges勾read和view_index_metadata; - 不要 勾
create_index、delete_index、write等任何写权限。
- Cluster privileges:只给
- 建用户 :
Security → Users → Create user,设置用户名密码,角色挂上logs_app_readonly。 - 验证 :用新用户隐身窗口登录 Kibana,创建索引模式时只能看到
logs_app*相关索引;在 Dev Tools 里执行POST/DELETE会返回 403,GET查询正常,即配置生效。
等价的安全 API 写法(省去点界面):
json
POST /_security/role/logs_app_readonly
{
"cluster": ["monitor"],
"indices": [
{
"names": ["logs_app*"],
"privileges": ["read", "view_index_metadata"]
}
]
}
json
POST /_security/user/log_viewer
{
"password": "********",
"roles": ["logs_app_readonly"]
}
七、踩坑小结
把这套 DSL 用在生产里沉淀下来的经验,按出现频率排个序:
- 去重别用脚本 :见到不少人用
cardinality(近似值)或者拉全量自己去重来枚举字段值,日志场景直接collapse/terms即可,精确且便宜; terms聚合的字段必须是 keyword :对 text 字段聚合会报Fielddata is disabled,用对应的字段名.keyword子字段或提前把字段设计成 keyword;- 日期 format 要预留兜底 :日志接入新增一种时间格式(比如
20251119142019)时,先PUT更新索引模板的 format 列表,否则写入直接失败; - 精确计数有成本 :
track_total_hits全开的查询在大索引上明显变慢,只给需要展示总数的接口开,分页遍历类任务保持默认; from + size深分页有上限 (默认 10000):导出类场景改用search_after,别调大index.max_result_window硬扛;- 7.x 与 8.x 的差异 :本文 DSL 在 7.17 验证通过;8.x 中
filter语义、以上所有查询写法均兼容,只是 mapping 里的_doc类型不再需要显式声明。
结语
四个查询模板覆盖了日志检索的高频需求:collapse 拉字段清单、range + highlight 查内容、date_histogram 看趋势、terms + top_hits 做任务级巡检。把它们存成 Dev Tools 的代码片段,排障时改两个时间参数就能用。
这套查询背后的索引结构、采集链路与分片规划,可以接着看这两篇:
你在日志检索中还遇到过哪些"界面做不到、DSL 一行搞定"的需求?评论区聊聊,我可以补充进模板清单。