第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,要么在时间戳里带上时区偏移。

避坑指南: 千万不要用 System.currentTimeMillis() 直接输出长整型数字。那种日志在人工排查时毫无可读性。一定要格式化为人类可读的字符串。

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位的十六进制字符串,足够保证唯一性,也足够短。

小技巧: 在日志框架中,可以用 MDC(Mapped Diagnostic Context)来传递请求ID。比如 Logback 的 %X{reqId} 占位符,可以自动从 MDC 中取出请求ID。这样你就不用在每个日志调用里手动传参了。

3.5 类名:快速定位问题源头

类名的作用是告诉你「这条日志是从哪个类里打出来的」。在 Java 中,通常用 LoggerFactory.getLogger(ClassName.class) 来获取 Logger 实例,日志框架会自动记录类名。

我建议使用 全限定类名,而不是简单类名。为什么?因为不同包下可能有同名类,比如 com.trade.order.service.OrderServicecom.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 提取特定字段。第二种格式,虽然读起来顺口,但机器解析起来很麻烦。

核心原则: 日志消息体应该同时被人类和机器理解。人类需要快速扫读,机器需要方便解析。用「key=value」或「JSON」格式,是当前业界的最佳实践。

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 格式规范的核心逻辑

为了让你更直观地理解这五个字段之间的关系,我画了一张图:

日志格式五要素关系图 标准日志行 时间戳 ISO 8601 + 毫秒 + 时区 线程ID Thread.currentThread() 请求ID UUID简短版 / MDC传递 类名 全限定类名 消息体 key=value 结构化 五个字段缺一不可,共同构成可追溯、可分析的日志体系

从这张图你可以看到,五个要素围绕「标准日志行」展开,各自承担不同的职责。时间戳解决「什么时候」的问题,线程ID解决「谁在执行」的问题,请求ID解决「属于哪次请求」的问题,类名解决「从哪里来」的问题,消息体解决「发生了什么」的问题。五个问题都回答了,日志才算完整。

3.9 避坑总结

最后,我把自己踩过的坑总结一下,你写日志格式时注意避开:

  • 不要用默认时区:服务器时区可能被运维改掉,一定要在时间戳里显式写明时区。
  • 不要省略毫秒:交易系统很多操作在毫秒级,没有毫秒你无法排序。
  • 不要用简单类名:不同包下的同名类会让你抓狂。
  • 不要用纯文本消息体:机器解析困难,后期做日志分析时你会后悔。
  • 不要忘记请求ID:没有请求ID,分布式系统里的日志就是一团乱麻。

嗯,格式规范这件事,看起来简单,但做对了能省下无数排查时间。我个人觉得,花半小时把日志格式定好,比花两天去排查问题要划算得多。

一句话总结: 标准日志格式 = 时间戳 + 线程ID + 请求ID + 类名 + 结构化消息体。五个字段,一个都不能少。

公众号:蓝海资料掘金营,微信deep3321