3. 日志格式设计:时间戳、模块名、线程ID、上下文ID的标准格式

日志格式这东西,看着简单,但真踩过坑的人才知道有多重要。我刚入行那会儿,觉得能打出信息就行,结果有一次线上定位问题,日志乱成一锅粥,光对齐时间就花了半天。从那以后,我对日志格式就特别较真。

说白了,日志格式就是协议栈的“黑匣子”。格式设计得好,排查问题事半功倍;设计得不好,你就是在垃圾堆里找针。这一节,我把我这些年沉淀下来的格式规范分享给你。

3.1 一条好日志的四个要素

我个人习惯,一条日志必须包含四个核心字段。缺一个,排查时就会多一分痛苦。

字段 作用 我踩过的坑
时间戳 确定事件发生的绝对时刻 曾经用毫秒级精度,结果两个事件在同一毫秒内,根本分不清先后
模块名 快速定位问题出在哪一层 曾经不写模块名,满屏日志不知道是RRC还是NAS出的
线程ID 区分并发路径,追踪异步流程 曾经两个线程同时写日志,输出顺序和实际执行顺序完全对不上
上下文ID 串联一次完整的事务流程 曾经没有上下文ID,一个信令流程的日志散落在各处,手动拼凑到崩溃

核心原则:日志格式要让人一眼就能回答三个问题——什么时候发生的?谁产生的?属于哪个流程?

3.2 时间戳:精度与可读性的平衡

时间戳是日志的灵魂。我建议用 UTC+8 的绝对时间,别用相对时间。为什么?因为协议栈经常跨设备、跨板卡,相对时间一同步就乱。

格式上,我推荐这个:

2025-03-15 14:23:45.678 123

解释一下:

  • 日期+时分秒:人类可读,一眼知道大概时间
  • 毫秒:三位数,够用
  • 微秒:后三位,用于高精度排序

我的小技巧:微秒部分用系统 tick 的低位来填充,这样即使两个事件在同一毫秒内,也能通过微秒区分先后顺序。我曾经靠这个特性,在 5G NR 的 HARQ 重传排查中,精确锁定了时序冲突的根因。

3.3 模块名:分层清晰,一目了然

协议栈分层多,模块名必须能反映层级关系。我习惯用 大模块.子模块 的命名方式。

模块名 含义 典型日志内容
RRC.ASN1 RRC层的ASN.1编解码模块 解码SIB1成功,cellId=0x1234
MAC.SCHED MAC层的调度器模块 UL grant分配,rnti=0xC123,size=512
PDCP.CIPHER PDCP层的加密模块 加密算法NEA2,keyId=3
NAS.MOB NAS层的移动性管理模块 TAU请求,oldGuti=...,newGuti=...

注意:模块名不要超过 16 个字符。太长的话,日志对齐会很难看,而且 grep 的时候也麻烦。我见过有人用 RRC_CONNECTION_ESTABLISHMENT_HANDLER 这种名字,说实话,看着就累。

3.4 线程ID:区分并发,还原时序

协议栈是多线程的,这一点你肯定知道。但你知道线程ID在日志里有多重要吗?

我建议用 系统线程ID(TID),而不是 pthread_t。因为 TID 在系统层面是唯一的,而且可以跟 perf top 等工具关联起来。

格式上,用十六进制,固定 4 字节宽度:

[TID:0x3A1B]

为什么用十六进制?因为线程ID在 /proc/<pid>/task/ 目录下就是十六进制显示的,保持一致方便交叉排查。

实战经验:我曾经排查过一个偶发性的 RRC 重建失败问题。日志里看到两条日志时间戳完全一样,但线程ID不同。一条是主线程发的 RRCReconfiguration,另一条是定时器线程发的 T310 超时。两个线程同时操作同一个 UE 上下文,导致了竞态。如果没有线程ID,这个 bug 我可能永远都找不到。

3.5 上下文ID:串联流程的“线”

上下文ID,也叫 Correlation ID 或 Transaction ID。它的作用是把一次完整的事务流程串起来。

我推荐用 UUID 的短格式,取前 8 位十六进制字符:

[CTX:A3F9C2E1]

为什么用短格式?因为长 UUID 太占地方,而且日志里不需要全局唯一,只需要在短时间内不重复就行。8 位十六进制,理论上可以支持 4 亿个并发事务,够用了。

上下文ID的生成时机:

  • 新流程开始时(如收到 RRCSetupRequest)生成一个新的上下文ID
  • 流程结束时(如 RRCSetupComplete 发送完成)释放该上下文ID
  • 子流程继承父流程的上下文ID,方便追溯

3.6 标准格式:一条完整的日志长什么样

好了,四个要素都讲完了。把它们拼起来,就是一条标准日志:

2025-03-15 14:23:45.678 123 [RRC.ASN1] [TID:0x3A1B] [CTX:A3F9C2E1] 解码SIB1成功,cellId=0x1234,plmn=46001

这个格式有几点好处:

  • 固定宽度:时间戳、模块名、线程ID、上下文ID都是固定长度,日志对齐漂亮
  • 可解析:用空格或制表符分隔,脚本解析非常方便
  • 可读性强:人类阅读时,关键信息一目了然

我的习惯:在代码里定义一个日志宏,自动填充这些字段。这样开发人员写日志时,只需要关心消息内容,格式由宏统一保证。我见过太多因为手写格式导致的不一致问题,用宏可以一劳永逸。

3.7 格式设计的避坑指南

这些年我踩过的坑,总结成几条,你记一下:

  1. 不要用浮点数表示时间:浮点数精度问题会导致时间戳错乱。我见过有人用 gettimeofday 的 double 值,结果毫秒位偶尔会跳变。
  2. 模块名不要用数字编号:比如 MOD_01、MOD_02,时间久了没人记得住编号对应哪个模块。直接用英文单词,清晰明了。
  3. 线程ID不要用线程名:线程名可能重复,而且长度不固定。用 TID 最可靠。
  4. 上下文ID要保证在日志文件中唯一:至少在同一秒内不能重复。我曾经因为上下文ID生成算法有 bug,导致两个不同流程用了同一个 ID,排查时被误导了整整两天。

3.8 知识体系:日志格式设计全景图

下面这张图,把日志格式设计的核心逻辑串起来了。你可以把它当作一个检查清单,设计日志格式时对照着来。

日志格式设计全景图 日志格式设计核心要素 标准日志格式 时间戳 YYYY-MM-DD HH:mm:ss.fff uuu 模块名 大模块.子模块 线程ID [TID:0x3A1B] 上下文ID [CTX:A3F9C2E1] 设计原则:固定宽度 · 可解析 · 人类可读 · 宏统一生成 避坑指南 ❌ 不用浮点数表示时间 ❌ 模块名不用数字编号 ❌ 线程ID不用线程名 ❌ 上下文ID不能重复 2025-03-15 14:23:45.678 123 [RRC.ASN1] [TID:0x3A1B] [CTX:A3F9C2E1] 解码SIB1成功 ↑ 时间戳 ↑ 模块名 ↑ 线程ID ↑ 上下文ID ↑ 消息内容

这张图把日志格式设计的核心逻辑都画出来了。你设计日志格式时,对着这张图检查一遍,基本不会出大问题。

嗯,格式设计就讲到这里。记住,好的日志格式是排查问题的第一道防线。格式设计得越规范,后面排查问题就越轻松。这个道理,我是在吃了无数次亏之后才真正理解的。