《Spring Boot 实战》16.3 日志聚合与告警

从 Spring Boot 3.4 起支持的结构化日志讲起,核实 logging.structured.format.console/file 的 ecs、gelf、logstash 取值,讲运行时改日志级别的 /actuator/loggers、日志切分与保留、敏感信息脱敏,并论证告警应基于指标而非日志关键字,最后给出日志、指标、追踪的分工表与一条真实排障路径。

本节目标:把日志变成可查询的结构化字段,掌握运行时调级、切分保留与脱敏,并建立一个正确的心智模型——指标负责「要不要叫人」,日志负责「为什么」。
适用版本: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全称适用后端
ecsElastic Common SchemaElasticsearch / ELK
gelfGraylog Extended Log FormatGraylog
logstashLogstash JSONLogstash

这三个取值已在 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 → 追踪看链路 → 日志看细节。缺任何一环,排障都会卡住。

一条真实排障走查路径

场景:用户反馈「借书偶尔要等好几秒才成功」。按上面的分工走一遍:

  1. 指标层:确认现象与时间窗口。 看 http.server.requests 按 uri=/api/loans 切分的 P99,确认确实有尖峰;再看 hikaricp.connections.pending 与 jvm.gc.pause,判断是「等连接」还是「GC 停顿」。假设发现 hikaricp.connections.pending 与延迟尖峰同步出现——初步怀疑连接池不足或连接泄漏。

  2. 追踪层:定位到具体跳。 从尖峰时间段里取一条慢请求的 traceId,在追踪后端看它的 span 瀑布图,确认慢的是 bookloan.inventory.reserve 还是数据库查询。假设是数据库 span 特别长。

  3. 日志层:看具体发生了什么。 用 traceId 在日志平台过滤出这条请求的全部日志,看到「获取数据库连接耗时 3200ms」以及同一时刻多条类似记录——坐实是连接获取在排队。

  4. 根因与修复。 结合 hikaricp.connections.active 长期贴着上限,判断是池太小或存在未归还的连接。调大池(参考 11.1 HikariCP 调优 )或修泄漏,再观察 hikaricp.connections.pending 是否回落到 0。

  5. 防复发。 给 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 性能剖析 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计