第3章:日志格式规范——时间戳、线程ID、请求ID、类名、消息体的标准格式
日志格式这件事,说实话,很多团队一开始都不太在意。我见过不少项目,日志写得随心所欲,等到线上出问题要排查时,几个人围着一堆乱七八糟的文本抓狂。嗯,那时候再回头规范格式,代价就大了。
我个人习惯是,在项目第一天就把日志格式定死。你想想看,一个交易系统每天产生几千万条日志,如果没有统一格式,别说自动化分析了,人眼都看不过来。今天我们就聊聊,一个标准的日志行应该长什么样。
3.1 标准日志行的骨架
一条完整的日志,至少包含五个核心字段。我把它叫做「日志五要素」:
| 字段 | 含义 | 示例 |
|---|---|---|
| 时间戳 | 日志产生的时间点 | 2025-03-21 14:30:00.123 |
| 线程ID | 当前执行线程的标识 | http-nio-8080-exec-3 |
| 请求ID | 一次请求的唯一追踪号 | req-7a9f2b1c |
| 类名 | 产生日志的类或方法 | com.trade.order.OrderService |
| 消息体 | 具体的日志内容 | 订单创建成功,订单号:ORD20250321001 |
这五个字段缺一不可。我曾经在一个项目中,日志里没有请求ID,结果一次并发故障,几十个请求的日志混在一起,根本分不清谁是谁。那一次排查花了整整两天,后来我强制要求所有日志必须带上请求ID。
3.2 时间戳:精度与时区
时间戳是日志的灵魂。我个人推荐使用 ISO 8601 格式,精确到毫秒。为什么?因为交易系统中,很多操作在几十毫秒内完成,没有毫秒级精度,你根本看不出先后顺序。
举个例子:
# 推荐格式
2025-03-21T14:30:00.123+08:00
# 不推荐格式
2025-03-21 14:30:00 # 没有毫秒,也没有时区
这里有个坑要注意:时区必须明确。我遇到过分布式部署的场景,服务器在北京、新加坡、伦敦各有一台。如果日志里不写时区,你看到的时间到底是哪个时区的?我曾经因为这个原因,把三台机器的日志对齐花了半天。所以,要么统一用 UTC,要么在时间戳里带上时区偏移。
3.3 线程ID:并发问题的追踪利器
线程ID的作用,说白了就是告诉你「这条日志是谁写的」。在 Java 中,线程ID通常用 Thread.currentThread().getName() 获取。
为什么需要它?因为在高并发场景下,多个线程同时处理请求,日志是交错输出的。如果没有线程ID,你看到两条相邻的日志,可能根本不是同一个线程写的。有了线程ID,你可以把同一个线程的日志过滤出来,还原它的执行路径。
我建议的格式是:
# 线程ID格式
[thread: http-nio-8080-exec-3]
注意,线程池中的线程名通常有规律,比如 http-nio-8080-exec-3 表示这是第3个工作线程。这个信息在分析线程池负载时很有用。
3.4 请求ID:全链路追踪的基石
请求ID是分布式系统中最重要的字段,没有之一。它的作用是把一次请求经过的所有服务、所有模块串联起来。
我个人习惯在网关层生成请求ID,然后通过 HTTP 头或 RPC 上下文传递到下游。格式上,我推荐使用 UUID 的简短版本,比如去掉连字符,只取前16位:
# 请求ID格式
[req: a7f92b1c3d4e5f60]
为什么不用完整 UUID?因为完整 UUID 有36个字符,太长了。日志行本身就很长,再加上冗长的请求ID,阅读体验很差。16位的十六进制字符串,足够保证唯一性,也足够短。
3.5 类名:快速定位问题源头
类名的作用是告诉你「这条日志是从哪个类里打出来的」。在 Java 中,通常用 LoggerFactory.getLogger(ClassName.class) 来获取 Logger 实例,日志框架会自动记录类名。
我建议使用 全限定类名,而不是简单类名。为什么?因为不同包下可能有同名类,比如 com.trade.order.service.OrderService 和 com.trade.payment.service.OrderService,如果只写 OrderService,你根本分不清是哪个。
# 推荐格式
[c: com.trade.order.service.OrderService]
# 不推荐格式
[c: OrderService]
3.6 消息体:结构化与可读性的平衡
消息体是日志的核心内容。我见过两种极端:一种是纯文本,随意写;另一种是纯 JSON,结构复杂但可读性差。我的建议是:关键信息用结构化格式,辅助说明用自然语言。
举个例子:
# 好的消息体
订单创建成功 | orderId=ORD20250321001 | userId=U10086 | amount=299.00 | status=PAID
# 不好的消息体
订单创建成功,订单号是ORD20250321001,用户ID是U10086,金额是299.00,状态是已支付
第一种格式,用竖线分隔字段,既保留了可读性,又方便用 grep 或 awk 提取特定字段。第二种格式,虽然读起来顺口,但机器解析起来很麻烦。
3.7 完整示例:一条标准日志长什么样
把上面五个字段组合起来,一条完整的日志行应该是这样的:
2025-03-21T14:30:00.123+08:00 [thread: http-nio-8080-exec-3] [req: a7f92b1c3d4e5f60] [c: com.trade.order.service.OrderService] 订单创建成功 | orderId=ORD20250321001 | userId=U10086 | amount=299.00 | status=PAID
你看,从左到右依次是:时间戳、线程ID、请求ID、类名、消息体。每个字段用空格或方括号分隔,一目了然。
在 Logback 配置中,对应的 pattern 是这样的:
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder>
<pattern>%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} [thread: %thread] [req: %X{reqId}] [c: %logger{36}] %msg%n</pattern>
</encoder>
</appender>
这个 pattern 里,%d 是时间戳,%thread 是线程ID,%X{reqId} 是从 MDC 中取请求ID,%logger{36} 是类名,%msg 是消息体。一行代码,搞定所有字段。
3.8 格式规范的核心逻辑
为了让你更直观地理解这五个字段之间的关系,我画了一张图:
从这张图你可以看到,五个要素围绕「标准日志行」展开,各自承担不同的职责。时间戳解决「什么时候」的问题,线程ID解决「谁在执行」的问题,请求ID解决「属于哪次请求」的问题,类名解决「从哪里来」的问题,消息体解决「发生了什么」的问题。五个问题都回答了,日志才算完整。
3.9 避坑总结
最后,我把自己踩过的坑总结一下,你写日志格式时注意避开:
- 不要用默认时区:服务器时区可能被运维改掉,一定要在时间戳里显式写明时区。
- 不要省略毫秒:交易系统很多操作在毫秒级,没有毫秒你无法排序。
- 不要用简单类名:不同包下的同名类会让你抓狂。
- 不要用纯文本消息体:机器解析困难,后期做日志分析时你会后悔。
- 不要忘记请求ID:没有请求ID,分布式系统里的日志就是一团乱麻。
嗯,格式规范这件事,看起来简单,但做对了能省下无数排查时间。我个人觉得,花半小时把日志格式定好,比花两天去排查问题要划算得多。
公众号:蓝海资料掘金营,微信deep3321