2452 字
约 8 分钟
16
S16-X06 · 日志规范与结构化日志:从 print 到可检索
2024-12-31
2026-11-08

绝大多数"查不到问题"的时刻,问题不在日志太少,而在日志没法用。文本日志里的 订单处理失败 有几百条,你不知道是哪一单、哪个用户、哪次请求;System.out.println 打出来的字符串,grep 能搜,进了聚合平台就是一堆噪声。

结构化的意思很朴素:把"人读的一句话"换成"机器可聚合的一组字段",人读的部分仍然保留。


1. 日志分级的使用边界

级别 含义 生产默认 保留期
TRACE 方法级细节、循环内中间值 关 仅临时排查
DEBUG 分支判断、外部调用参数 关(可动态开) 短期
INFO 关键业务动作与结果 开 中
WARN 可自愈异常、降级命中 开 中
ERROR 需人介入、业务已失败 开 长

ERROR 是否该进告警,判据是三条同时满足:有明确影响面、有对应处置动作、重复出现说明系统真的坏了。

该告警的 ERROR 不该告警的 ERROR
支付回调验签失败且连续出现 参数校验失败这类用户输入问题
核心依赖不可用(DB/缓存连接失败) 客户端主动断开引起的 IO 异常
定时任务连续失败 业务上允许的"查无此单"
数据一致性告警 已被上层重试成功的单次失败

最容易失控的是把业务异常也打成 ERROR:一旦 ERROR 率进了告警,这类"正常失败"会天天触发。


2. 结构化日志 vs 纯文本

// 纯文本:能读,不能聚合
log.info("订单创建成功,订单号 " + orderId + ",金额 " + amount + ",耗时 " + cost + "ms");

// 结构化:人读得懂,机器能按字段聚合
log.atInfo()
   .addKeyValue("event", "order.create")
   .addKeyValue("orderId", orderId)
   .addKeyValue("amount", amount)
   .addKeyValue("costMs", cost)
   .log("订单创建成功");

log.atInfo() 这套 fluent API 是 SLF4J 2.0 引入的,配 Logback 1.3+ 使用。

能力 纯文本 结构化
按字段筛选 正则碰运气 event:"order.create" AND costMs:>500
按字段聚合 做不到 avg(costMs) by event
数值比较 做不到 status:>=500
建看板 只能数字符串 按 event 维度画图
统一脱敏 只能改代码 在 encoder/provider 层处理

本地开发用可读 pattern、生产用 JSON,由 profile 决定 encoder 即可。


3. Logback JSON encoder 配置

<dependency>
  <groupId>net.logstash.logback</groupId>
  <artifactId>logstash-logback-encoder</artifactId>
  <version>8.0</version>   <!-- 按 JDK 17 / Logback 1.5.x 核对 -->
</dependency>
<!-- src/main/resources/logback-spring.xml -->
<springProfile name="prod">
  <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
      <providers>
        <timestamp/><version/><logLevel/><loggerName/><threadName/>
        <message/><stackTrace/><mdc/>
        <pattern><pattern>{"app":"clarity-api","env":"${ENV:-prod}"}</pattern></pattern>
      </providers>
    </encoder>
  </appender>
  <root level="INFO"><appender-ref ref="JSON"/></root>
</springProfile>
provider 产出字段 注意
timestamp @timestamp 默认 UTC,看板按 UTC 展示
logLevel level 别和索引保留字冲突
mdc MDC 中所有键 键名避免与内置字段重名
stackTrace stack_trace 仅异常时出现,字段类型不稳定
pattern 自定义固定字段 用于补 app、env

stack_trace 字段"有异常才存在",聚合平台里别按字符串匹配,用字段是否存在来判断。


4. MDC:traceId 与 userId 的传递

一次性写入上下文,之后所有日志自动带上:

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceFilter extends OncePerRequestFilter {
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws IOException, ServletException {
        try (MDC.MDCCloseable ignored = MDC.putCloseable("traceId", TraceId.current(req))) {
            MDC.put("path", req.getRequestURI());
            SecurityContext.userId().ifPresent(id -> MDC.put("userId", id));
            chain.doFilter(req, res);
        }                                   // 自动清理,避免线程复用串号
    }
}

异步线程会丢 MDC,因为底层是 ThreadLocal。给线程池加 TaskDecorator 是最省事的统一解:

executor.setTaskDecorator(runnable -> {
    Map<String, String> ctx = MDC.getCopyOfContextMap();
    return () -> {
        Map<String, String> old = MDC.getCopyOfContextMap();
        if (ctx != null) MDC.setContextMap(ctx);
        try { runnable.run(); } finally { MDC.setContextMap(old); }
    };
});
MDC 丢失场景 TaskDecorator 覆盖 补充做法
@Async 默认执行器 否(另一个池) 单独配 AsyncConfigurer
自定义 ExecutorService 否 手动包装 Runnable
CompletableFuture.supplyAsync() 否(ForkJoinPool) 显式传自定义 executor
Reactor / WebFlux 不适用 用 Reactor Context
定时任务 否 任务入口显式设置

必须在 finally 恢复旧 map,否则池里复用线程时,上一个请求的 traceId 会串到下一个请求。


5. 日志内容规范

该记 不该记
做了什么(event 名) 全量实体对象 log.info("{}", order)
结果(成功/失败/状态码) 大字段(报文、图片 base64)
关键参数(ID、类型、数量) 密码、token、身份证号
耗时(costMs) 循环体内每个元素各打一条
失败根因所在的那一行 每层 catch 重复打完整栈

在循环里打日志是高频雷:1000 条数据的批量任务产出 1000 行 INFO,把有用的日志挤走。改成聚合输出,聚合行一定存在,明细行可能被采样丢掉:

log.atWarn().addKeyValue("event", "batch.import.done")
   .addKeyValue("total", total).addKeyValue("failed", failed.get())
   .addKeyValue("firstFailedIds", firstFailedIds(3))
   .log("批量导入结束,存在失败项");

6. 敏感信息脱敏

字段 处理方式 错误做法
手机号 留前 3 后 4 整串打印
身份证 留前 6 后 4 明文
银行卡 只留后 4 明文
密码 / 密钥 完全不输出 输出哈希前 8 位
token / Cookie 只输出前 8 位供比对 整串输出
地址 只留到市 精确到门牌
public static String phone(String v) {          // 集中在统一出口,不靠每人记得手写
    return v == null || v.length() < 7 ? "***"
         : v.substring(0, 3) + "****" + v.substring(v.length() - 4);
}

脱敏方法集中放在一个 Mask 工具类里,并在 encoder provider 层对关键 key 再兜一层;漏调工具方法时仍有兜底。


7. 日志量与成本的取舍

手段 效果 代价
只记关键路径 降幅最大 需事先想清关键在哪
采样(如 1/100) 线性降量 低频问题可能采不到
动态调级 排查时临时开 DEBUG 必须记得关掉
字段化替代长文本 单条更小更可查 需团队约定
循环内聚合 去掉批量噪声 需要改造代码
# Actuator 动态调级;排查结束记得改回 INFO,否则第二天日志量翻几倍
curl -X POST http://localhost:8080/actuator/loggers/com.example.order \
     -H 'Content-Type: application/json' -d '{"configuredLevel":"DEBUG"}'

这条命令值得写进处置手册,而不是临时从记忆里找。


8. 慢日志与异常日志的格式约定

慢日志不要另起一套文本,直接复用带 costMs 的结构化事件,阈值从配置注入:

if ((cost = stopwatch.elapsed(MILLISECONDS)) >= slowThresholdMs) {
    log.atWarn().addKeyValue("event", "slow.query")
       .addKeyValue("costMs", cost).addKeyValue("thresholdMs", slowThresholdMs)
       .addKeyValue("stmt", "order.findByUser")   // 写语句标识,不写拼接后的 SQL
       .log("慢查询");
}

异常日志三条约定:文案写结论、细节进字段;用 setCause 而不是拼字符串(否则 stackTrace 字段为空);要么记要么抛,不要记完继续当无事发生。

} catch (PaymentException e) {
    log.atError().setCause(e).addKeyValue("event", "order.pay.failed")
       .addKeyValue("orderId", order.getId()).log("支付失败");
    throw e;
}

9. 排错:八种"有日志但查不出问题"

现象 原因 处理
聚合平台搜不到字段 字段名与索引映射冲突 检查索引模板与字段类型
traceId 串号 MDC 未在 finally 清理 用 putCloseable 或显式 clear()
异步日志没有 traceId 线程池未配 TaskDecorator 按 §4 包装,一个池都不能漏
异常栈丢了 用 e.getMessage() 拼字符串 改用 setCause(e)
日志暴涨 动态调级没关回去 加巡检或定时重置
时间对不上 应用 UTC、看板本地时区 统一 UTC 存、展示层转换
关键字段为空 没用 {} 占位符或 fluent API 统一写法
检索超时 单条日志过大 截断长字段,只记摘要

最后一条容易被忽略:一条几 MB 的日志会把索引写入放大几十倍,成本问题会先于功能问题爆发。


10. 边界:什么时候日志解释不了问题

场景 更该用 理由
跨服务一次请求的完整路径 分布式追踪(traceId + span) 日志只能看单点
整体趋势与突增 指标(Micrometer + Prometheus) 日志没有聚合曲线
用户行为复盘 埋点 / 事件流 日志不是行为数据
合规审计 独立审计表 日志会被轮转删除
高频内部循环 采样或指标计数 日志写不全也读不完

一句话收口:日志回答"这一次发生了什么",指标回答"整体怎么样",追踪回答"经过哪些环节"。


11. 自测

  1. 一条 ERROR 该不该进告警通道,判据是哪三条?
  2. log.info("x=" + v) 与 log.atInfo().addKeyValue("x", v) 在聚合平台上的差别是什么?
  3. TaskDecorator 里为什么必须在 finally 恢复旧的 MDC map?
  4. LoggingEventCompositeJsonEncoder 里 mdc provider 的作用是什么?
  5. 循环内逐条打日志,应该改成什么写法?
  6. 排查完临时开的 DEBUG 级别,为什么必须显式改回去?
S16-X06 · 日志规范与结构化日志:从 print 到可检索
https://www.clxhxhhr.top/posts/5732/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。