本节目标:把日志变成可查询的结构化字段,掌握运行时调级、切分保留与脱敏,并建立一个正确的心智模型——指标负责「要不要叫人」,日志负责「为什么」。
适用版本:Spring Boot 4.1.x(Java 21)
16.3 日志聚合与告警
16.1 给了你指标,16.2 给了你链路,最后一块拼图是日志。日志是三者的「底片」:指标告诉你哪里不对,追踪告诉你哪一跳慢,而日志告诉你具体发生了什么——那条 SQL、那个异常栈、那个被拒绝的业务分支。
但日志也是最容易失控的一项:量最大、成本最高、最容易泄漏敏感信息、也最容易被误用成告警来源。本节先讲怎么把日志变成机器可查的结构化数据,再讲运营层面的调级、切分、脱敏,最后把「日志 / 指标 / 追踪」三者的分工讲清楚。
结构化日志:从 3.4 起的 JSON 输出
传统日志是一行给人看的字符串。它的问题是:要按「状态码是 500 且 uri 是 /api/loans」筛选时,只能靠正则去匹配文本,既脆弱又慢。结构化日志把每个字段变成独立的键值对(通常是 JSON),日志平台可以直接按字段检索与聚合。
Spring Boot 从 3.4 起内置了结构化日志支持,4.x 沿用。开箱支持三种格式,通过 logging.structured.format.console 与 logging.structured.format.file 分别控制控制台与文件输出:
| 格式 id | 全称 | 适用后端 |
|---|---|---|
ecs | Elastic Common Schema | Elasticsearch / ELK |
gelf | Graylog Extended Log Format | Graylog |
logstash | Logstash JSON | Logstash |
这三个取值已在 4.1.1 的配置元数据中核实(logging.structured.format.console 的 hint 为 ecs、gelf、logstash)。开启方式:
logging:
structured:
format:
console: ecs # 容器环境走 stdout,采集器抓 stdout
# file: logstash # 需要写文件时用这个
开启后,原本的行内日志变成一行 JSON。下面是 ECS 格式的示例输出(字段顺序与内容随版本略有差异,这里示意结构):
{"@timestamp":"2026-10-05T18:03:12.441Z","log.level":"INFO","process.pid":51230,"process.thread.name":"nio-8080-exec-3","service.name":"book-loan","message":"借阅单创建成功 loanId=90211","ecs.version":"8.11","trace.id":"7f3a1c9e2b4d5e6f","span.id":"1a2b3c4d5e6f","log.logger":"com.example.loan.LoanService"}
关键字段:log.level、service.name、message、trace.id、span.id。注意 trace.id —— 结构化日志会自动把 16.2 里进 MDC 的 traceId 作为独立字段输出,这样在日志平台按 trace.id 检索就能拿到整条请求的所有日志,形成「日志 ↔ 追踪」的闭环。
容器环境优先用 format.console:让应用把结构化日志打到 stdout,由容器运行时或采集器(Fluent Bit、Filebeat)收集,而不是应用自己写文件。应用写文件在容器里会带来「日志随容器销毁而丢失」和「多副本聚合困难」两个问题。
用 json.* 定制字段
ECS/GELF 是固定 schema,如果你的日志平台需要额外字段(比如 env、tenant),可以用 logging.structured.json.* 增删改:
logging:
structured:
json:
add:
env: prod
tenant: book-loan
rename:
log.level: level
exclude:
- process.thread.name
add 加固定字段、rename 改字段名、include / exclude 控制白名单或黑名单。这几个属性只对 JSON 系格式(ecs、logstash、自定义 JSON)生效,因为它们本质是「在 JSON 成员上做变换」。需要更复杂逻辑时可以注册一个 StructuredLoggingJsonMembersCustomizer bean(类位于 org.springframework.boot.logging.structured)。
自定义字段前先问一句:这个字段是「每行都变」还是「每行都一样」? env=prod 这种每行都一样的字段更适合放在采集器的 pipeline 里统一加,而不是应用每行重复输出——重复字段会显著增加日志体积。
运行时调整日志级别
排障时最常见的诉求是「临时把某个包开到 DEBUG 看细节」。传统做法是改配置、重启,慢且影响面大。Actuator 的 loggers 端点支持运行时读写日志级别,前提是暴露了该端点(见 16.1
)。
查询当前级别:
curl -s localhost:8081/actuator/loggers/com.example.loan | python3 -m json.tool
示例输出:
{
"configuredLevel": "INFO",
"effectiveLevel": "INFO"
}
临时改成 DEBUG:
curl -X POST localhost:8081/actuator/loggers/com.example.loan \
-H 'Content-Type: application/json' \
-d '{"configuredLevel":"DEBUG"}'
改回 INFO 只需把 DEBUG 换回 INFO。两点提醒:
loggers是可写端点,必须鉴权。把它暴露给公网,任何人都能开 TRACE 打爆你的磁盘与 CPU。- 改完要记得改回。运行时级别不落盘,重启会恢复,但「忘了改回」这段时间的日志量可能已经压垮磁盘或淹没采集器。生产上建议把「临时开 DEBUG」纳入变更流程,而不是随手 curl。
与配置中心的区别:配置中心(3.3)改的是应用配置,通常需要配合刷新才生效;loggers 直接改的是日志框架的运行时状态,立即生效、不需要刷新。两者定位不同,别混用。
logging.pattern.* 与 traceId 占位
如果不做结构化、仍用行内日志,traceId 要靠 logging.pattern.* 打进字符串。16.2 已给出关键配置:
logging:
pattern:
correlation: "[${spring.application.name:-},%X{traceId:-},%X{spanId:-}]"
logging.pattern.correlation 专门控制相关信息的占位,Boot 默认把它拼进 console 与 file 的 pattern。三个属性各管一段:console 管控制台整行、file 管文件整行、correlation 管中间的「应用名 + traceId + spanId」片段。
行内 pattern 与结构化日志的选择:
| 维度 | 行内 pattern | 结构化日志 |
|---|---|---|
| 人眼可读 | 好 | 差(要工具解析) |
| 机器可查 | 差(靠正则) | 好(按字段) |
| traceId 可筛 | 要正则 | 作为独立字段 |
| 体积 | 小 | 略大 |
| 适用 | 本地开发 | 生产聚合 |
结论很直接:本地开发用行内、生产聚合用结构化。两者可以通过 profile 切换,不必二选一。
日志切分与保留策略
日志写文件时必须限制它的增长,否则磁盘被打满是运维事故的常客。Logback 的滚动策略由 logging.logback.rollingpolicy.* 控制(默认值已核实):
logging:
logback:
rollingpolicy:
max-file-size: 100MB # 单个文件上限,默认 10MB
max-history: 30 # 保留天数,默认 7
total-size-cap: 5GB # 归档总量上限,默认 0(不限制)
clean-history-on-start: false # 启动时是否清理历史,默认 false
默认值意味着:单文件 10MB、保留 7 天、总量不限制。total-size-cap 默认是 0(不限制)——这是最容易忽略的一项:只设 max-history 而不设 total-size-cap,在日志量大的服务上仍可能把磁盘写满,因为「30 天」的量没有上限。
推荐把三项都显式设上,让磁盘占用有确定上界:max-file-size 控制单文件、max-history 控制时间跨度、total-size-cap 控制总量。三者的关系是「取最先触发的那个」。
容器环境则通常不需要应用管切分:stdout 交给运行时,由采集器的 rotation 策略负责。应用写文件 + 采集器读文件在容器里是反模式,除非有明确理由(比如需要保证本地留存)。
敏感信息脱敏
日志泄漏是真实的安全事故。常见的高危内容:
| 内容 | 风险 | 处理 |
|---|---|---|
| 密码 / token / 密钥 | 直接可利用 | 永不记录;必要时只记前几位哈希 |
| 身份证 / 手机号 / 银行卡 | 个人信息合规 | 掩码(保留前 3 后 4) |
| 完整请求体 / 响应体 | 含任意敏感字段 | 只记必要字段,不整包打 |
| SQL 参数 | 可能含敏感值 | 记 SQL 模板,参数按需脱敏 |
Authorization 头 | 可直接重放 | 永不记录 |
结构化日志提供了字段级控制:用 logging.structured.json.exclude 排除已知敏感字段,或注册 customizer 对特定字段做掩码。但更根本的原则是「不产生」而不是「事后过滤」:过滤规则永远追不上新加的字段,最稳的做法是从源头不把敏感值传给日志。
一个具体建议:把「脱敏」做成一个工具方法(如 Mask.mobile(String)),所有需要记录敏感字段的地方统一调用它,而不是各写各的正则。这样新增字段时也有一致的规范可循。
告警要基于指标而不是日志关键字
这是本节最重要的观点,也是团队里最常争论的一点:「出现 ERROR 就告警」是错误做法。
| 维度 | 基于日志关键字告警 | 基于指标告警 |
|---|---|---|
| 稳定性 | 脆弱,文案一改就失效 | 稳定,指标名是契约 |
| 信噪比 | 差,一次异常刷屏触发几百条 | 好,聚合后一个阈值 |
| 采样 | 日志可能被采样或丢弃 | 指标全量 |
| 基线 | 难建立 | 天然可算速率与分位数 |
| 成本 | 高(要实时解析海量日志) | 低(抓取已聚合的序列) |
| 典型误报 | 无害的 WARN 被当 ERROR | 少 |
为什么日志关键字告警会失效:
- 文案是易变的。开发改一句日志措辞,告警就静默失效,且没人会记得同步改告警规则。
- 量级不可控。一次数据库抖动可能在一分钟内产生几万条 ERROR,告警系统被打爆,真正的问题被淹没。
- 无法表达「率」。「错误率超过 1%」这种有意义的告警,用日志关键字很难算准(分母是总请求数,日志里未必有)。
- 正常业务也可能打 ERROR。把「用户输入非法」记成 ERROR 的团队,用关键字告警会天天误报。
正确的分工是:
- 指标负责「要不要叫人」:错误率、P99 延迟、连接池等待、GC 停顿——这些是聚合后的、有基线的、能表达速率的信号。见 16.1 。
- 日志负责「为什么」:被指标告警叫醒后,用它去查具体发生了什么。
- 例外:安全审计类事件(登录失败、越权访问尝试)可以用日志告警,因为它们的语义是「事件发生」而非「指标越界」,且通常有专门的日志管道与规则。这类是少数。
日志、指标、追踪的分工
三者不是替代关系,而是三个不同粒度、不同成本的视角:
| 维度 | 日志 | 指标 | 追踪 |
|---|---|---|---|
| 回答 | 发生了什么 | 要不要叫人 | 慢在哪一跳 |
| 数据形态 | 离散事件 | 聚合数值 | 请求调用链 |
| 粒度 | 单条最细 | 全局聚合 | 单请求跨服务 |
| 成本 | 最高(量与存储) | 最低 | 中(受采样控制) |
| 是否采样 | 通常不采(但可能丢) | 不采(全量) | 采(默认 10%) |
| 保留期 | 短(天级) | 长(月级) | 中(天到周) |
| 典型用途 | 根因、异常栈 | 告警、趋势 | 性能瓶颈定位 |
三者的连接键是 traceId:指标告警 → 找到时间窗口 → 从该窗口的慢请求拿到 traceId → 追踪看链路 → 日志看细节。缺任何一环,排障都会卡住。
一条真实排障走查路径
场景:用户反馈「借书偶尔要等好几秒才成功」。按上面的分工走一遍:
指标层:确认现象与时间窗口。 看
http.server.requests按uri=/api/loans切分的 P99,确认确实有尖峰;再看hikaricp.connections.pending与jvm.gc.pause,判断是「等连接」还是「GC 停顿」。假设发现hikaricp.connections.pending与延迟尖峰同步出现——初步怀疑连接池不足或连接泄漏。追踪层:定位到具体跳。 从尖峰时间段里取一条慢请求的 traceId,在追踪后端看它的 span 瀑布图,确认慢的是
bookloan.inventory.reserve还是数据库查询。假设是数据库 span 特别长。日志层:看具体发生了什么。 用 traceId 在日志平台过滤出这条请求的全部日志,看到「获取数据库连接耗时 3200ms」以及同一时刻多条类似记录——坐实是连接获取在排队。
根因与修复。 结合
hikaricp.connections.active长期贴着上限,判断是池太小或存在未归还的连接。调大池(参考 11.1 HikariCP 调优 )或修泄漏,再观察hikaricp.connections.pending是否回落到 0。防复发。 给
hikaricp.connections.pending加一条「持续 5 分钟大于 0」的指标告警,让下次不用等用户投诉才发现。
这条路径体现了三者的顺序:指标发现问题 → 追踪缩小范围 → 日志确认根因 → 指标防复发。反过来「先翻日志」往往要在海量文本里盲找,效率低得多。
常见坑
- 只设
max-history不设total-size-cap:日志量大时磁盘仍会满,「30 天」没有量级上界。 - 容器里应用写文件:日志随容器销毁丢失,多副本还难以聚合,应打 stdout。
- 用日志关键字做告警:文案一改就失效,量级不可控,还表达不了错误率。
- 临时开 DEBUG 忘了改回:日志量飙升压垮磁盘或采集器,务必纳入变更流程。
loggers端点无鉴权:任何人可开 TRACE,等同拒绝服务。- 把敏感值写进日志再靠过滤兜底:过滤追不上新增字段,应从源头不产生。
- 结构化与行内混用却不区分环境:生产用结构化、本地用行内,靠 profile 切换,别一刀切。
小结
日志要做成可查询的结构化数据:Spring Boot 3.4 起内置 ecs / gelf / logstash 三种格式,容器环境优先 logging.structured.format.console 打 stdout,用 logging.structured.json.* 定制字段。运营层面:/actuator/loggers 支持运行时调级(可写端点必须鉴权、用完改回),logging.logback.rollingpolicy.* 三件套(单文件、天数、总量)限制磁盘占用,敏感信息从源头不产生而非事后过滤。最重要的心智模型是告警基于指标、日志用于根因——指标回答「要不要叫人」,日志回答「为什么」,追踪回答「慢在哪一跳」,三者靠 traceId 串起来。
阅读导航:上一节:16.2 链路追踪 · 下一节:17.1 性能剖析 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。