说到 Spring Boot 应用日志,很多团队的状态其实都是这样的:日志打出来、文件一存,出了问题就登服务器 grep 半天,几台节点来回比对,好不容易找到一条异常,上下文还被截断了。这套 ELK + Filebeat 日志平台,解决的就是这个局面。
Filebeat 负责把各节点日志文件稳定收走,Logstash 负责清洗和解析,Elasticsearch 负责存储和检索,Kibana 再把检索界面和可视化一起端出来。对于正在做日志改造、或者被要求搭一套日志系统的读者,这篇文章我会从日志格式设计讲起,一路走到索引清理策略,把能直接落地的配置和踩过的坑都放在里面。
1. 为什么 Spring Boot 日志需要一套正经的收集链路
1.1 从“出事去看日志”到“随时查得到”
很多项目早期都是把 Spring Boot 自带的日志文件直接留在服务器上,能用,但痛点非常明显。
第一是分散。你部署了四五台实例,日志散落在各自磁盘上,某一个接口报错了,你得一台一台上去看,要是负载均衡把请求随机打到不同节点,一条请求的完整链路甚至得拼好几台服务器的日志片段。
第二是低效。日志文件一旦上了几百 MB,grep 'NullPointerException' 基本等于碰运气,没有索引、没有时间线,你想按某个 traceId 把一次请求的所有日志串起来,基本要靠眼力。
第三是缺上下文。单机日志只能看到“这个时刻这台机器发生了什么”,但你不知道这个报错出现的频率、分布在哪几个接口、高峰时段有没有明显放大。这些问题靠人肉看日志看不出来,需要先有集中检索,才有统计。
把日志集中起来之后,价值就从“事后翻找”变成了“随时检索”:在 Kibana 里输入关键词,几百 GB 日志秒级返回,再按 level、服务名、节点、时间段做聚合,效率完全不是一个量级。
1.2 一套组合中各司其职的组件
这套平台里,每个组件都是因为负担不了其他组件的活才存在的。
Filebeat 是采集器,部署在应用节点上,专门盯日志文件。它的核心工作是读文件、把新增内容发出去,自己不做复杂解析,所以内存占用极低,对业务服务影响很小。
Logstash 是管道加工站。它接收 Filebeat 传来的日志,在这里做字段提取、类型转换、时区修正、过滤无用数据,然后投递给 Elasticsearch。如果你前面的日志格式设计得好,Logstash 的活儿会非常轻,管道里基本是加分项;如果日志格式混乱,这里就得用各种 grok 硬啃文本。
Elasticsearch 是存储与检索引擎。日志进到这里之后会被建立倒排索引,字段被自动映射成可检索的类型,Kibana 的查询都打在它身上。日常运维里,ES 集群的健康状态和索引规划反而是整个链路中最需要上心的部分。
Kibana 是可视化层。检索界面、图表、大盘、日志上下文浏览全在这里完成,业务方和研发看日志基本只接触这一层。
这四个环节扣在一起,链路就是:日志文件 → Filebeat → Logstash → Elasticsearch → Kibana。部署上 Filebeat 在业务节点,Logstash 和 ES 可以独立集群,Kibana 部署在管理端,前后端只要网络互通就行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 日志规范先行:写不好格式,后面全白搭
2.1 在动手部署之前,先定三个设计决策
我见过太多团队把 Filebeat、Logstash、ES 全搭好之后才发现日志没法用,不是因为组件出问题,而是日志本身没有规范。所以第一件事不是写配置,是定规矩。
第一个决策:用纯文本还是 JSON。强烈建议 JSON。Spring Boot 应用集成了 logstash-logback-encoder 之后,每行日志直接输出成一个 JSON 对象,字段天然结构化,Logstash 这边几乎零解析成本。你要是坚持用纯文本,就得花大量时间写 grok 正则去猜每一行日志哪个位置是时间、哪个位置是级别,字段一乱就全跑偏。
第二个决策:一个应用至少输出哪些字段。timestamp、level、logger、message、traceId 是底线。前四个不用解释,traceId 特别值得强调:一次请求贯穿所有远程调用,如果日志里没有 traceId,你在日志平台里几乎不可能把一次完整请求的日志串起来。
第三个决策:文件怎么滚动。按天滚动是基础,但生产上通常还要加大小限制。我曾经见过一个没有做任何滚动策略的应用,日志文件三个月涨到 30 GB,Filebeat 每次启动都要扫描半天,业务方想清理也不敢动。正确的做法是大小触发达 200 MB 就切一个新文件,同时按天保留最近七天,两者叠加。
2.2 一份可以直接抄的 Logback 配置
下面这份 logback-spring.xml 是我在多个项目里反复用过的配置,直接放在 resources 目录下,Spring Boot 会自动加载。
xml复制<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<springProperty scope="context" name="APP_NAME" source="spring.application.name"/>
<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH:-/var/logs}/${APP_NAME}.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH:-/var/logs}/${APP_NAME}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>7</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdc>true</includeMdc>
</encoder>
</appender>
<appender name="ASYNC_JSON" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>1024</queueSize>
<discardingThreshold>0</discardingThreshold>
<includeCallerData>false</includeCallerData>
<appender-ref ref="JSON_FILE"/>
</appender>
<root level="${LOG_LEVEL:-INFO}">
<appender-ref ref="ASYNC_JSON"/>
</root>
</configuration>
几个细节解释一下。
LogstashEncoder 会把日志级、线程名、logger 名、message 自动变成 JSON 字段,includeMdc 会把 MDC 里的 traceId 带出来。如果你的网关在请求头生成了 traceId,切到 MDC 之后,日志平台里就能按 traceId 拉出整条请求链。
SizeAndTimeBasedRollingPolicy 是滚动策略的关键。maxFileSize 是单文件超过 200 MB 就切,maxHistory 是保留 7 天,totalSizeCap 是整个目录日志总大小上限 5 GB。这三条组合起来,基本不会出现磁盘被日志打爆的问题,属于保命配置。
之所以再加一层 ASYNC_JSON,是因为日志写入是 IO 操作,如果直接在业务线程里写文件,接口 RT 会受影响。异步 appender 把日志事件交给后台线程批量处理,队列默认 1024,discardingThreshold=0 意味着队列满了不会丢弃业务日志,宁可阻塞也保证日志不丢。这条对高并发服务很重要。
2.3 日志生成量的估算法则
有一个简单的经验公式:单实例单天日志 ≈ 高峰期 QPS × 单请求平均日志条数 × 单条日志大小 × 峰值时长占比。举个例子,某服务高峰 1000 QPS,每个请求大概打 6 条日志,每条平均 300 字节,那高峰一小时就是 1000 × 6 × 300 × 3600 ≈ 6.5 GB。这种量级如果保留 7 天,单个节点要预留至少 50 GB。
这个估算的意义不在于精确,而在于让你对“日志平台会不会成为磁盘瓶颈”有数。数据量一上来,Filebeat 的吞吐、Logstash 的队列、ES 的索引写入都会成为影响点,提前按业务体量规划比单纯加磁盘更靠谱。
3. Filebeat 轻量采集:从安装到稳定服役
3.1 采集端为什么必须轻
有人一开始会问:为什么不直接在节点上装 Logstash?
原因非常现实:Logstash 是 JVM 应用,默认堆内存分配就按 GB 来算,业务节点内存本来就不宽裕,每个日志节点再塞一个 JVM 进程,成本太高了。Filebeat 是 Go 写的,单进程内存占用通常只有几十 MB,CPU 消耗也非常小,放在业务节点上几乎无感。
另一个原因是从运维角度,业务节点上跑的东西越简单越好。Filebeat 的逻辑就一句话“读文件、发出去”,出了问题最多是日志延迟,不会拖垮业务进程。Logstash 那种管道处理更适合放在独立的日志中间层,而不是跟业务抢资源。
3.2 filebeat.yml 配置逐段拆解
安装部分直接去下载对应系统的二进制包解压即可,重点看配置。
yaml复制filebeat.inputs:
- type: log
enabled: true
paths:
- /var/logs/*.log
fields:
app_name: order-service
node_name: order-node-01
fields_under_root: false
multiline.type: pattern
multiline.pattern: '^\d{4}-\d{2}-\d{2}'
multiline.negate: true
multiline.match: after
output.logstash:
hosts: ["logstash-host:5044"]
paths 用通配符指向日志目录,注意目录要存在,否则 Filebeat 会空转不报错。fields 是给每条日志打上固定标签,强烈建议填上应用名和节点名,因为在 Kibana 里你总得知道“这条日志来自哪个服务、哪台机器”。
multiline 这一段是专门处理异常堆栈的。Java 的异常堆栈通常跨多行,比如:
text复制2025-01-10 10:15:22.123 ERROR 12345 --- [http-nio-8080-exec-1] c.e.d.SomeService : NullPointerException occurred
at com.example.demo.SomeService.handle(SomeService.java:88)
at com.example.demo.OrderController.create(OrderController.java:32)
如果不做合并,这几行会被拆成三条日志,进了 ES 之后 message 字段是碎的,检索和查看都非常痛苦。上面的配置用正则匹配“以日期开头”的行,negate: true 表示不以日期开头的行不属于新日志,match: after 表示把它追加到前一条日志后面。这样一次完整的异常堆栈就成了一个 document。
output.logstash 指向上游 Logstash 的地址和端口,端口要和 Logstash beats 输入保持一致,常见的是 5044。
3.3 registry 文件:断点续传背后的关键
Filebeat 有一个很容易被忽略但极其重要的文件:data/registry/filebeat.yml。它就像一个书签,记录了每一个日志文件已经读到了哪个字节偏移量。
有了它,Filebeat 重启后不会重复发送旧日志,而是从上次的位置继续读。这是日志采集不重不丢的基础。
但有几点要小心:
- 不要手动删除
data/registry目录。一旦删除,Filebeat 会重新扫描所有匹配的日志文件,把你历史上所有日志全部重发一遍,ES 里瞬间涌入大量重复数据。 - 老日志已经被 Logstash 侧删过了,但 Filebeat 还在读,这时候你就会发现 ES 里有一条新日志但没有内容,大概率是文件轮转时 Filebeat 还没来得及切到新文件。
- 如果确实要“从头开始采集某个文件”,可以精确删除 registry 里对应文件的记录,而不是整个删。
实际当中最稳妥的检查方式是:filebeat test config 验证配置,filebeat -e 看前台日志,确认连接上了 Logstash 再放后台,就不会出现配置错误导致的静默失败。
4. Logstash 管道:日志进入 ES 前最后一道加工
4.1 从 Logstash 视角看入口配置
Logstash 的核心是 pipeline,一份配置文件就是一个管道。入口这一段:
ruby复制input {
beats {
port => 5044
client_inactivity_timeout => 60
}
}
beats 输入插件专门接收 Filebeat 发来的日志。因为 Filebeat 默认走 Lumberjack 协议,传输过程中日志已经是被序列化好的数据,所以 Logstash 这边不需要额外 codec 配置。
Filebeat 发来的日志本身是一个 JSON 字符串,Logstash 会自动把它解成独立字段,所以在 filter 里你直接就能引用 message、level、logger 这些字段。这也是为什么前文强调要用 JSON 格式输出:Filebeat 之后几乎零解析,管道性能就上来了。
4.2 grok 解析:为什么建议尽量少用
如果你的日志还是纯文本,那就只能靠 grok 解析。grok 的本质是一堆正则表达式的命名组合,它把一段文本按模式拆成字段。
比如这种纯文本日志:
text复制2025-01-10 10:15:22.123 ERROR 12345 --- [http-nio-8080-exec-1] c.e.d.SomeService : Failed to call remote
对应的 grok 可能是这样的:
code复制%{TIMESTAMP_ISO8601:timestamp} %{LOGLEVEL:level} %{NUMBER:pid} --- \[%{DATA:thread}\] %{JAVACLASS:logger} : %{GREEDYDATA:message}
看着不复杂,但实际维护起来很痛苦:不同团队日志格式稍有不同,同一份 grok 可能对 A 服务好用,对 B 服务匹配不上。匹配不上的数据不会报错,只是变成一行只有 message 字段的裸日志,在 Kibana 里很难被条件检索到。
所以我的建议很简单:能用 JSON 输出的新项目,绝不要走 grok 老路。存量项目切不过来,也要尽量统一纯文本模板,把 grok 规则收敛到一两套泛化模板,别让每一个服务自己搞一套格式。
4.3 date、mutate、output 的协同处理
日志里有两个时间,一个是应用打印日志的时间 timestamp,一个是 ES 接收日志的时间 @timestamp。如果你不做处理,Kibana 默认按 @timestamp 排,而它反映的是“日志到达 ES 的时间”。一旦 Logstash 积压或者网络抖动,日志排序就会错乱。
正确的做法是用 date 过滤器把应用日志里的时间解析成 @timestamp:
ruby复制filter {
date {
match => ["timestamp", "ISO8601"]
timezone => "Asia/Shanghai"
}
}
这里 match 表示拿 timestamp 字段去匹配 ISO8601 格式,匹配成功后覆盖 @timestamp。timezone 一定要设置成应用所在时区,否则 ES 默认按 UTC 存,Kibana 里会整整差 8 小时。
mutate 过滤器负责字段收尾:
ruby复制filter {
mutate {
rename => { "logger" => "logger_name" }
remove_field => ["host", "agent", "ecs", "input"]
}
}
把字段名整理成下游好用的命名,同时删掉大量无用的元数据字段,让索引体积小一点。很多人不注意这一步,结果每个日志 document 里塞了五六层 Filebeat 自带元数据,索引增长速度非常快。
output 这一段就是进 ES:
ruby复制output {
elasticsearch {
hosts => ["es-node-01:9200", "es-node-02:9200"]
index => "springboot-logs-%{+YYYY.MM.dd}"
ilm_enabled => true
}
}
index 按照日期动态生成索引名,比如 springboot-logs-2025.01.10。ilm_enabled 开启索引生命周期管理,配合后续的滚动和清理策略。
5. Elasticsearch 与 Kibana:索引模型和检索体验
5.1 索引设计与生命周期管理
ES 里日志类数据是典型的“写多查少、有时效”数据。如果所有日志都写进一个索引,数据量大了以后查询会越来越慢,清老数据也只能暴力删。
按天切分索引是最好的策略。上文的 Logstash output 已经生成了 springboot-logs-2025.01.10 这种索引,接下来建立 ILM 策略,让 ES 自动处理索引滚动、迁移和删除。
先用 API 创建策略:
bash复制PUT /_ilm/policy/logs-ilm-policy
{
"policy": {
"phases": {
"hot": {
"min_age": "0ms",
"actions": {
"rollover": {
"max_age": "1d",
"max_size": "50gb"
}
}
},
"delete": {
"min_age": "7d",
"actions": {
"delete": {}
}
}
}
}
}
然后用索引模板把策略绑定到匹配的索引上:
bash复制PUT /_index_template/springboot-logs-template
{
"index_patterns": ["springboot-logs-*"],
"template": {
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"index.lifecycle.name": "logs-ilm-policy"
}
}
}
这样索引存活超过 7 天就会被自动删除,从源头上解决“磁盘被日志填满”的问题。分片数 3 是参考值,如果单日日志只有几个 GB,1 个分片也够用,分片太多反而浪费资源。
5.2 Kibana 使用时的几个高效姿势
进 Kibana 之后第一件事是创建 Data View,索引模式填 springboot-logs-*。创建好后,Discover 页面就可以查日志了。
比较常用的查询条件有几种场景:
- 查报错:
level : ERROR - 查某服务问题:
app_name : "order-service" and level : ERROR - 查某个 traceId 的完整链路:
trace_id : "abc123456" - 查堆栈异常具体内容:
message : "NullPointerException"
KQL 语法比 Lucene 语法更直观,时间范围可以直接通过右上角选择。有一个技巧:查看报错日志时,把 message 里冗长的 stack_trace 单独拆成一个字段,然后在 Discover 的表格列里只勾选 timestamp、level、logger_name、message 前几行,避免整个堆栈铺满屏幕。
我还见过一种很实用的做法:用 level:ERROR 聚合出错误率趋势图,按小时分桶,高峰期如果错误率突然抬高,基本能在第一时间发现问题,价值不比业务监控低。
5.3 ES 侧两个迟早要踩的坑
第一个坑是字段类型冲突。ES 的映射一旦创建就不可变,同一个字段名在两个索引里出现不同类型,或者同一天内数据格式变了,就会导致写入报错。典型场景是:某服务日志里 status 有时是整数 200,有时是字符串 "SUCCESS",ES 第一次建索引时按整数映射,后面写入字符串就直接报 mapping 异常。
应对办法是做好索引模板的字段映射,明确声明哪些字段是 keyword、哪些是 long、哪些是 text。这样不管数据格式怎么变,模板已经锁死类型,不会因为“第一次写入见到谁就是谁”而翻车。
第二个坑是索引膨胀。日志平台跑久了,如果一开始没上 ILM,老索引堆积几百个,查询性能下降,磁盘也说满就满。处理办法就是上文那套模板 + 生命周期策略,一次配置,长期生效。如果已经堆积了旧索引,手写脚本按天删除即可。
6. 排错手册:我踩过的那些实际问题
6.1 日志一直没进 ES,先别急着怀疑代码
日志平台接好之后,最常见的反馈就是“日志不见了”。这种时候大部分人第一反应是业务代码有问题,但大概率问题出在链路中间。我建议按照一条单向链路去排查。
先从 Filebeat 看,systemctl status filebeat 确认它在运行,再看 /var/log/filebeat/filebeat.log,如果连接不上 Logstash,它会持续重试并打印错误。接着在 Logstash 侧执行 netstat -an | grep 5044,确认 Filed和 Logstash 连接已经建立。再看 Logstash 日志里有没有 pipeline 报错。最后直接在 ES 侧查索引:
bash复制curl -XGET "http://es-node-01:9200/_cat/indices/springboot-logs-*?v"
哪一环没通,问题就在哪一环,基本不会跳级。还有一个容易被忽略的点:Filebeat 的 paths 配置如果写错目录,它不会报错,只是什么都不发,所以要重点确认日志文件路径和实际路径一致。
6.2 时区、编码、堆栈合并三座大山
Kibana 里时间比实际晚 8 小时,这是最经典的问题。原因是 ES 内部统一存 UTC 时间,Kibana 查询时如果配置的时区不对,就永远差 8 小时。解决方式是在 Logstash 的 date 过滤器里加上 timezone => "Asia/Shanghai",同时在 Kibana 自己的高级设置里把 timezone 也改成东八区。
中文乱码也是长期存在的坑。日志文件如果写的是 GBK 编码,但 Filebeat 默认按 UTF-8 读,到了 ES 里中文全是乱码。解决方式是在 Filebeat 的 input 里加一行 encoding: gbk,告诉它文件原始编码。通用性原则是:应用输出日志尽量统一 UTF-8,存量服务单独配置。
堆栈合并失败也常发生。multiline 正则如果写得不对,两个异常堆栈可能会被合并成一条,或者每条异常被拆成多条。验证方法很简单:在 Filebeat 采集时只处理一份样例文件,进 ES 后看每个 document 的长尾,确认一次异常堆栈是否作为一个整体存在。改正则的时候记得在正则调试工具里先用样例数据验证,别直接上生产。
6.3 一段时间后常见的稳定问题速查
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 日志延迟越来越严重 | Logstash 队列持久化太小或 ES 写入跟不上 | 调大 queue.type: persisted 的队列容量,检查 ES 的 bulk 队列 |
| Filebeat 内存飙升 | 日志文件太多且都匹配上了,或者频繁重发 | 核对 paths 通配符,减少多余匹配;确认 registry 无异常 |
| ES 节点磁盘报警 | ILM 没配好或没生效 | 补配 index.lifecycle.name,检查 rollover 和 delete 阶段 |
| Kibana 查询超时 | 时间范围太大或者索引太多 | 缩小时间范围,合理切分索引避免单索引跨年 |
| 日志重复写入 | 删过 Filebeat registry 或 ES 里没有配置主键去重 | 按 traceId 生成 ES _id 实现去重 |
| 丢日志 | 异步 appender 队列溢出或 Filebeat 读取偏移异常 | 确认 discardingThreshold 配置,检查队列日志 |
6.4 再补一条实战心得
我实际使用中体会最深的一点是:日志平台做得再好,最终瓶颈都在“日志源头质量”。解析端、采集端、存储端的问题都能通过配置修复,但业务代码里如果日志本身没有等级划分、没有 traceId、信息残缺,平台再强也是从垃圾里淘金。
所以新项目启动时,第一步不是搭平台,而是全员统一日志规范。SLF4J 的占位符语法、异常日志必须打印完整堆栈、错误信息不能只写一句“failed”不带入参,这些看似基础的规矩,对日志平台的实际体验影响超过任何调优。
另外一个小技巧:给 traceId 设置独立的 MDC key,比如 MDC.put("trace_id", TraceUtils.generate()),然后在 Logback 的 encoder 里配置输出。如果你的网关层已经生成了 traceId,只让它进入 MDC,就能做到一次请求的所有链路日志全部串起来,这个能力在排障时是降维打击。
这套环境一旦稳定运行,日常维护量其实很小。你只需要留一两个常用脚本:一个查 Filebeat 是否掉线,一个看 ES 磁盘水位,剩下的大部分时间它都在安静干活。希望这篇从规范到实战的内容,能帮你少走点弯路。
