绝大多数"查不到问题"的时刻,问题不在日志太少,而在日志没法用。文本日志里的 订单处理失败 有几百条,你不知道是哪一单、哪个用户、哪次请求;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. 自测
- 一条 ERROR 该不该进告警通道,判据是哪三条?
log.info("x=" + v)与log.atInfo().addKeyValue("x", v)在聚合平台上的差别是什么?TaskDecorator里为什么必须在 finally 恢复旧的 MDC map?LoggingEventCompositeJsonEncoder里mdcprovider 的作用是什么?- 循环内逐条打日志,应该改成什么写法?
- 排查完临时开的 DEBUG 级别,为什么必须显式改回去?