凌晨两点,工作群里甩过来一条消息,附了一个 traceid:6aa2526590ad07346b76e2b8d8d80384,后面跟一句"这个单子状态没更新,帮忙看下"。没有任何上下文,没有用户ID,没有时间点。这时候你能做的第一件事,就是打开 Kibana,把这个 traceid 丢进去。ELK 这套东西存在的意义,很大程度上就是为了应对这种"什么都不知道、只知道一个线索"的时刻——Elasticsearch 负责把海量日志存下来并建好索引,Kibana 负责让你用几秒钟从几亿条日志里把那条相关的捞出来。
这篇内容聊的就是 ELK 查日志这件事,从怎么把日志弄进 Elasticsearch,到 Kibana 里怎么写出真正管用的查询,再到用 traceid 把跨服务的调用串成一条链。它既适合刚接手日志平台、还在对着 Kibana 界面发懵的运维和开发,也适合已经在用、但每次查问题都靠"全文搜关键词+肉眼翻"的老手。我会尽量把每个选择的理由讲清楚,而不是只丢一段配置文件给你抄——因为同样的配置,换个业务场景可能就是坑。
1. 从一个 traceid 出发:ELK 查日志的真实工作场景
线上排障最有意思的一点是,你手里往往只有一个碎片。可能是一个 traceid,可能是用户截图里的一个订单号,也可能是"大概下午三点左右"这么一个模糊的时间范围。ELK 的价值就在于,它把这三类碎片都变成了可索引、可检索的维度。但前提是,你的日志体系得先把这些维度采集下来并且存对地方。
1.1 日志查不动,问题通常不在 Kibana
很多人第一次用 Kibana 觉得难用,以为是查询语法不熟。实际上大部分"查不动"的根因都发生在日志写进 Elasticsearch 之前。比如日志是纯文本一行行塞进去的,traceid 和业务字段全糊在 message 里,那你只能靠全文检索去撞;再比如时间戳字段解析错了,字段类型变成了text而不是date,Kibana 的时间筛选就完全失效,你在界面上选的时间范围形同虚设。
我见过最典型的一个案例:某个服务的日志时间戳用的是容器本地时间,而容器时区是 UTC,宿主机是东八区,结果 Kibana 里看到的时间永远差 8 小时。排查的人以为是 Kibana 抽风,折腾了一下午才发现是采集端没统一时区。所以第一件事得想明白:日志从产生到能被查询,中间要经过产生、采集、解析、写入、索引五个环节,任何一个环节的信息丢失,都会让最终查询少一个维度。
1.2 Elasticsearch 和 Kibana 各自负责什么
把 ELK 拆开看其实很朴素。Elasticsearch 是一个分布式的文档数据库,它管存储、管索引、管检索。你写进去的每一条日志在它眼里就是一个 JSON 文档,只不过这个文档的字段会被倒排索引处理过,所以检索速度极快。Kibana 则是架在 Elasticsearch 前面的一个可视化壳子,你点鼠标、敲查询语句,它翻译成 Elasticsearch 的查询 DSL 发过去,再把结果渲染出来。
两者之间的边界很重要:Kibana 本身不存任何日志数据。你换了 Kibana 依然查不到数据,那问题一定在 Elasticsearch 侧或者更上游的采集链路;反过来,如果你用curl直接查 Elasticsearch 能查到、Kibana 却查不到,那大概率是索引模式(Index Pattern)配错了,或者时间字段没选对。搞清楚这条分界线,能省掉一大半"到底怪谁"的纠结。
1.3 一套"查得动"的日志体系长什么样
我心目中一套合格的日志体系,至少要满足几个条件。第一,每条日志都带统一的 traceid,这样跨服务的问题能串起来。第二,关键业务字段(用户ID、订单号、接口名、耗时、状态码)都从 message 里拆出来成为独立字段,别让它们埋在文本里。第三,时间字段是真正的 date 类型,且全局统一时区。第四,有一个合理的数据保留策略,别让磁盘写爆了才想起来清理。
这四条听起来简单,但真正落地的时候,每一条都会牵扯到采集配置、日志框架、索引模板的设计。后面几节我会逐条拆开讲,包括为什么这么设计、配置该怎么写、以及我踩过哪些坑。
2. 日志进 ES 之前:采集、解析与字段设计决定查询上限
日志能不能查得爽,八成取决于它进 Elasticsearch 之前的加工质量。采集这一层容易被当成"配一下就跑"的体力活,但它其实是整个日志平台的地基。地基没打平,后面 Kibana 里怎么写查询都别扭。
2.1 Filebeat 还是 Logstash:先分清职责再选型
常见组合是 Filebeat 采集 + Logstash 清洗。Filebeat 轻量,部署在业务机器上,负责读文件、做最基础的字段添加、把数据推给下游。Logstash 重,但它有强大的 filter 插件,能做 grok 正则解析、字段改名、时间戳覆盖、条件分支。两者不是非此即彼,而是分工。
如果你的日志本身就是结构化 JSON,那其实 Filebeat 直接把message里的 JSON 展开就够了,不一定非要 Logstash。Filebeat 的配置大概是这个感觉:
filebeat.inputs: - type: filestream paths: - /var/log/app/*.log json.keys_under_root: true json.overwrite_keys: true fields: service: order-service env: prodjson.keys_under_root: true是把日志行里的 JSON 提升到文档顶层,而不是塞在 message 字段里。json.overwrite_keys: true是允许日志自己的字段覆盖掉 Filebeat 默认加的字段,比如时间。
如果日志是非结构化的,比如那种2024-01-01 12:00:00 INFO [order] user=123 action=pay cost=45ms的纯文本,那就需要 Logstash 的 grok:
filter { grok { match => { "message" => "%{TIMESTAMP_ISO8601:log_time} %{LOGLEVEL:level} \[%{DATA:service}\] user=%{NUMBER:user_id} action=%{WORD:action} cost=%{NUMBER:cost_ms}ms" } } date { match => ["log_time", "yyyy-MM-dd HH:mm:ss"] timezone => "Asia/Shanghai" target => "@timestamp" } }选型的判断标准很简单:日志是不是已经是 JSON。是,就尽量让采集端轻量,别引入 Logstash 增加运维成本;不是,那就老老实实上 Logstash 或者把应用日志改造成 JSON 输出。我个人的偏好是推动应用侧直接输出 JSON,因为解析正则是很容易出错的,业务日志格式一改,grok 就崩了,而且 grok 正则匹配是 CPU 消耗大户。
2.2 为什么我坚持让应用输出结构化 JSON
纯文本日志最大的问题是字段不可控。你写正则去拆,就得假设日志格式永远不变。但现实里,今天加个字段明天改个措辞,正则就失效了,日志采集就静默丢字段。而结构化 JSON 是把"字段定义"这件事前移到应用代码里,谁写日志谁负责字段,反而稳定。
一个典型的 JSON 日志长这样:
{ "@timestamp": "2024-06-18T14:32:11.234Z", "level": "INFO", "service": "order-service", "trace_id": "6aa2526590ad07346b76e2b8d8d80384", "span_id": "a1b2c3d4", "user_id": 10086, "order_id": "SO202406180001", "action": "update_status", "cost_ms": 45, "message": "订单状态更新完成" }这种日志进 Elasticsearch 之后,每个字段都是独立可查的。你想查trace_id等于某值、cost_ms大于 500 的记录,就是一个精确匹配加一个范围查询,速度极快。相比之下,如果这些都埋在 message 里,就只能全文检索,还得担心分词把 traceid 切碎。
2.3 Mapping 与字段类型:定错一次,后悔很久
Elasticsearch 默认会对新字段做动态映射(dynamic mapping)。规则大概是:字符串默认映射成text加一个keyword子字段,数字映射成long或float,能被解析成日期的字符串映射成date。这个默认行为在测试环境很方便,但在生产环境是灾难的开始。
因为动态映射一旦给某个字段定了类型,后续这个字段再写入不兼容的值就会报错,而且字段类型在同一个索引里无法修改,只能重建索引。所以生产环境一定要用索引模板(index template)显式定义关键字段类型:
{ "index_patterns": ["app-logs-*"], "template": { "mappings": { "properties": { "@timestamp": { "type": "date" }, "trace_id": { "type": "keyword" }, "user_id": { "type": "long" }, "order_id": { "type": "keyword" }, "cost_ms": { "type": "long" }, "message": { "type": "text" }, "level": { "type": "keyword" }, "service": { "type": "keyword" } } } } }这里有个关键区别:text会分词,keyword不分词。traceid、订单号、用户标识这类需要精确匹配的字段,必须是keyword。如果你把它映射成text,写入时会被分词器切成一堆片段,你按完整 traceid 查询就可能查不到——这是新手最常踩的坑之一,后面第 6 节会专门讲。
2.4 时间戳字段:多来源日志的第一杀手
多来源日志汇聚到一个 Elasticsearch 里,最容易乱的就是时间。每个服务、每台机器、每个容器的时区都可能不一样。如果采集时不做统一处理,@timestamp就会七零八落,Kibana 的时间范围筛选就会漏掉一堆数据。
我的处理原则是:所有日志在采集端统一转换成 UTC 写入@timestamp,展示时由 Kibana 按浏览器时区渲染。Logstash 的 date filter 里显式指定timezone,Filebeat 则尽量让它读取日志自带的 ISO8601 时间。如果应用日志里的时间是东八区,就必须在解析时告诉解析器这是东八区,否则 Elasticsearch 会按 UTC 解释,结果整体偏移 8 小时。
这个偏移很隐蔽,因为数据看着是"有"的,只是跑到别的时间段去了。你在界面选"今天 14:00-15:00",恰恰什么也查不到。所以只要发现查不到日志,第一反应就该去核对时间字段,别急着怀疑查询语句。
3. Kibana 查询语法实战:KQL、Lucene 与 DSL 的选择
Kibana 的 Discover 页面提供两种查询语言:KQL(Kibana Query Language)和 Lucene。很多人不知道自己在用哪种,也没搞清楚两者区别,导致查询时灵时不灵。搞清楚这一层,查询效率会有明显提升。
3.1 KQL 和 Lucene 到底用哪个
KQL 是 Kibana 后来主推的,语法更贴近自然语言,对新手友好。比如查订单服务里耗时超过 500 毫秒的日志:
service: "order-service" and cost_ms > 500Lucene 语法更老派,但表达能力强一些,尤其是涉及正则、模糊匹配和复杂布尔组合的时候:
service:order-service AND cost_ms:{500 TO *}我的一般建议是:日常排查用 KQL,够用且不易写错;需要复杂正则或者字段级的高级匹配时切到 Lucene。Kibana 界面上有个开关可以在两者间切换,切换的时候注意,语法不通用,换了语言原来那条查询要重写。
这里给个选择对照,方便你判断:
| 场景 | 推荐语言 | 示例 |
|---|---|---|
| 精确匹配字段值 | KQL | level: "ERROR" |
| 数值/时间范围 | KQL | cost_ms > 500 |
| 字段存在性判断 | KQL | trace_id: * |
| 复杂正则 | Lucene | order_id: /SO2024061.*/ |
| 多字段模糊匹配 | Lucene | message:fail~ |
补充一句,KQL 里的and、or、not也可以写成AND、OR、NOT,大小写都认,但建议统一用小写,看起来更清爽。
3.2 通配符、短语和模糊查询的正确姿势
几种容易混的匹配方式得说清楚。第一种是短语匹配,message: "订单状态更新",加了引号表示整段短语匹配,不会把词拆开。第二种是通配符,order_id: SO2024*,星号表示前缀匹配。第三种是模糊查询,message: error~,波浪号后面还能跟数字表示允许的编辑距离。
要注意的是,通配符查询,尤其是前置通配(*abc)非常吃性能,因为它没法利用倒排索引的前缀优化,会扫大量词项。生产环境建议避免前置通配,能用精确匹配就别用通配。如果你经常需要按订单号前缀查,不如把订单号做成keyword字段直接精确查,或者额外建一个专门的前缀字段。
模糊查询同理,编辑距离查询在数据量大时开销很高,一般只用于兜底场景,比如你不确定用户输入的业务关键字拼写,才用~去碰运气。
3.3 traceid 查询:从全文检索到精确命中
回到最开始那条 traceid。如果trace_id是keyword字段,查询就一句话:
trace_id: "6aa2526590ad07346b76e2b8d8d80384"命中速度是毫秒级,因为它走的是精确索引。但如果trace_id错误地映射成了text,写入时会被分词器切开,你按完整值查就可能一条都查不到;这时候你只能用match或者全文检索去撞,效率低不说,还可能因为分词把无关日志也带出来。
我建议的做法是:trace_id用keyword,并且如果你还需要在全文检索里按它搜索,可以额外加一个text子字段:
"trace_id": { "type": "keyword", "fields": { "text": { "type": "text" } } }这样一个字段两用:trace_id精确查,trace_id.text供全文匹配。这是个很实用的技巧,代价是索引略微变大,但排查体验提升明显。
3.4 把常用查询固化成保存搜索
排障时你可能会反复用同一类查询,比如"某服务的所有 ERROR"、"耗时超过 1 秒的接口调用"、"某个业务动作的完整链路"。Kibana 允许把这些查询保存成 Saved Search,下次一键加载,还能直接生成分享链接丢到群里。
我团队的实践是维护一份"排障查询手册",把每个核心服务最常用的几条查询存好,新同事上手时直接照着用,不用每次从零开始想语法。这看似是个小动作,但它把"谁能查日志"从少数熟悉语法的人扩展到整个团队,排障响应速度能提升不少。
4. 用 traceid 把一次请求串起来:跨服务日志的关联
单个服务的日志好查,难的是把一个用户请求在微服务之间流转的全过程拼起来。这正是 traceid 存在的意义。它是一条贯穿整条调用链的标识,只要每个服务在打日志时都带上它,你就能在 Kibana 里拼出完整链路。
4.1 traceid 是怎么一路透传下来的
traceid 的传递通常靠两个途径:HTTP 请求头和服务间调用的上下文。用户请求进入网关时,网关生成一个 traceid(或者从上游取现成的),塞进请求头,比如X-Trace-Id。后面的服务在收到请求时,从请求头里读出来,放进当前线程的上下文(比如 MDC),之后所有日志自动带上它。向下游发起调用时,再把这个 traceid 放进新的请求头传给下一个服务。
关键点是上下文透传。如果服务 A 调服务 B 的时候忘了传 traceid,那 B 里产生的日志就跟这条链断了,你在 Kibana 里只能看到半截链路。所以我一般会在统一的 HTTP 客户端拦截器里做这件事,而不是靠每个业务方法手动传,避免遗漏。
4.2 日志框架和 MDC 的配合
Java 生态里最常见的做法是 MDC(Mapped Diagnostic Context)。请求进来时把 traceid 放进 MDC,日志框架输出时会自动带上。配合 logback 的配置:
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{trace_id}] %logger{36} - %msg%n</pattern>%X{trace_id}就是取 MDC 里的 traceid。这样同一条链上的所有日志都会带上同一个值,串联就有了基础。
如果你用的是结构化 JSON 日志,那就更直接,把 traceid 作为一个字段塞进日志 JSON 里。需要注意的是,MDC 是线程绑定的,如果你用了线程池或者异步任务,子线程里拿不到父线程的 MDC,日志就会丢 traceid。这种情况需要手动把上下文传给子线程,或者用支持上下文透传的线程池包装。
4.3 在 Kibana 里拼出完整链路
有了统一字段之后,在 Kibana 里查链路就很直接。用 traceid 精确过滤,然后按@timestamp排序,就能看到这条请求流经的每个服务的日志。更规范的做法是引入 OpenTelemetry 那套 trace 体系,字段命名标准化(trace.id、span.id),Kibana 里还能用 APM 视图直接展示调用链。
如果只是用自建的 traceid 字段,也没问题,就是自己拼。我的做法是在 Discover 里按时间排序看,同时把关键字段(service、level、message、cost_ms)选进列显示,一条条往下看哪一步慢了、哪一步报错了。如果服务数量多、日志量大,还可以先按service做聚合,看哪个服务贡献的日志最多,快速缩小怀疑范围。
4.4 没有 traceid 时的补救思路
理想情况当然是全链路都有 traceid,但现实里总有些老服务没接。这时候只能退而求其次,靠时间窗加业务主键去关联。比如你知道订单号,就用订单号在所有服务里查,把时间范围收窄到问题的那个时间点前后几分钟,再看哪些日志时间上对得上。
这种补救方式效率明显低,而且容易误判,因为同一时间段可能有多个请求在跑。所以只要有条件,我还是建议把 traceid 铺全,这是一次性投入、长期受益的基础设施。
5. 索引治理:分片、ILM 与查询性能
日志数据是持续增长的,如果不管不顾地往一个索引里堆,早晚会因为分片过大或者磁盘写满而出事。索引治理这件事,平时不起眼,出事的时候很要命。
5.1 按月建索引还是按天
最直观的选择是按时段切索引,比如app-logs-2024.06.18,一天一个。好处是管理简单,过期直接删整个索引,比在索引里按时间删文档快得多。坏处是如果日志量小,会产生大量小索引,每个索引都有固定的分片开销,反而不划算。
我的经验是:日志量大(每天几十 GB 以上)就按天切,量小可以按周或按月。判断依据是单索引的大小,一般建议单个分片控制在几十 GB 以内,太小浪费资源,太大不利于均衡和恢复。按天切最省心的一点是配合 ILM 做生命周期管理非常自然。
5.2 分片数量和副本的取舍
分片数不是越多越好。分片越多,集群元数据开销越大,查询时协调节点要合并的分片也越多。对于日志场景,我一般建议单分片大小控制在 30-50GB,既不会太大导致恢复慢,也不会太小导致分片爆炸。
副本(replica)主要作用是高可用和分担查询压力。日志数据通常对一致性要求不高,如果集群资源紧张,副本可以设为 0 或 1;如果有查询压力并且不差磁盘,设 1 就够了。要注意副本数不能超过节点数减一,否则副本分配不出去,集群会一直处于 yellow 状态。
这里给个参考:
| 日志日增量 | 索引周期 | 主分片数 | 副本数 |
|---|---|---|---|
| < 5GB | 按月 | 1 | 1 |
| 5-30GB | 按周 | 3 | 1 |
| 30-100GB | 按天 | 3-5 | 1 |
| > 100GB | 按天 | 按节点数定 | 1 |
5.3 ILM 冷热分层与保留策略
ILM(Index Lifecycle Management)是管理日志生命周期的标准工具。它把索引的生命周期分成 hot、warm、cold、delete 几个阶段,你可以定义:写入阶段放在热节点,数据变旧后迁移到便宜的大盘节点,再老就直接删。
一个典型的策略是:热阶段保留 3 天,温阶段保留到 15 天,之后删除。这样既保证了最近几天的查询性能,又控制了磁盘成本。配置大概是:
{ "policy": { "phases": { "hot": { "actions": { "rollover": { "max_size": "30gb", "max_age": "1d" } } }, "warm": { "min_age": "3d", "actions": { "forcemerge": { "max_num_segments": 1 } } }, "delete": { "min_age": "15d", "actions": { "delete": {} } } } } }forcemerge把多个小段合并成一个,能显著提升查询性能,但它是 IO 密集操作,建议放在低峰期。
5.4 查询慢的常见原因
查询慢通常有几个来源。第一是时间范围太大,你让它扫半年数据,肯定慢;正确做法是尽量收窄时间范围,让 Elasticsearch 只查相关的少量索引。第二是通配符和模糊查询,尤其是前置通配。第三是聚合操作太重,比如对超大字段做 terms 聚合。第四是字段类型设计不合理,本可以精确匹配的字段做成了 text,导致走全文检索路径。
优化思路是先看 Kibana 里的查询语句有没有明显问题,再考虑从索引设计层面调整,比如给高频查询字段多做keyword,避免不必要的高基数字段聚合。很多时候,只要把时间范围收窄,查询速度就能从几十秒降到几百毫秒。
6. 查不到日志的排查清单:一次完整的定位链路
最让人抓狂的不是查询慢,而是明明应该有日志,却什么都查不到。这时候别乱试,按链路逐层往下排查,效率最高。
6.1 先确认数据到底有没有进 ES
第一步永远是用命令行直接查 Elasticsearch,绕过 Kibana:
curl -s "http://es-host:9200/app-logs-*/_count" -H 'Content-Type: application/json' -d ' { "query": { "match_all": {} } }'如果这个 count 是 0,说明数据根本没进来,问题在采集链路或写入端,Kibana 怎么配都没用。如果 count 大于 0 但 Kibana 查不到,那就是 Kibana 侧的索引模式、时间字段或者查询语句问题。这一步能帮你迅速定位问题出在哪一半。
6.2 时间与时区:最冤的一种查不到
确认数据存在之后,最常见的坑是时间。Kibana 的时间选择器默认按你浏览器时区展示,但如果日志写入时@timestamp用的是错误的时区,数据就会跑到别的时间段。你在"今天"这个范围里查不到,不代表数据不存在,只是它被记到了别的时间。
验证方法很直接:把 Kibana 的时间范围放大到"最近 30 天",看数据是否出现。如果放大就出来了,那就是时间字段的问题。这时候要回头检查采集端的时间解析配置,确认@timestamp写入的是 UTC。
6.3 字段类型与分词:全文能搜到、精确查不到
另一个高频坑是字段类型。如果你用trace_id: "完整值"查不到,但用message: "traceid的某一段"能搜到,基本可以确定trace_id被映射成了text且被分词了。解决方案是把它改成keyword,但字段类型不能直接改,需要重建索引,或者用 reindex 迁移数据。
临时救急的话,可以用trace_id.text或者直接对message做全文匹配,把那个 traceid 当普通文本搜,但这样没法精确,也可能带出无关记录。长期的方案还是修正索引模板,让新数据用正确的类型。
6.4 采集端和写入端的常见故障
如果数据压根没进来,往上游查。Filebeat 有没有在正常读文件(看它的 registry 状态和日志)、Logstash 有没有解析报错、Elasticsearch 有没有因为磁盘水位满了而拒绝写入(cluster.routing.allocation.disk.watermark)。磁盘水位触发后,Elasticsearch 会把索引切成只读,这时候数据会写不进去,表现就是日志"突然断了"。
我还遇到过一类很隐蔽的问题:采集端配置里的paths写错了,日志文件确实在产生,但 Filebeat 通配符没覆盖到,结果就是静默无数据。所以排查采集端时,第一件事是确认路径通配符能匹配到目标文件,第二件事是看采集组件自己的日志有没有报错,别只盯着 Elasticsearch。
我在实际运维里最大的体会是:日志平台的问题,八成能在"数据到底进没进 ES"这一步定性。养成先用 curl 确认数据存在性的习惯,比在 Kibana 界面里反复调查询语句要省太多时间。另外,别指望一套配置一劳永逸,业务日志格式、服务数量、数据量都在变,索引模板和 ILM 策略也需要定期回顾。我通常每个季度会把保留策略和高频查询重新过一遍,看看有没有字段类型定错、有没有索引膨胀、有没有查询别名过时。这些小事平时不做,等到磁盘告警或者排障卡壳的时候,代价就大了。