3、日志格式标准化:时间戳、线程ID、交易员ID、合约代码、日志来源模块、消息体

日志这东西,很多人觉得不就是print一下嘛?

我以前也这么想。直到有一次生产环境出了个诡异的问题——某个期权合约的报价半天没更新,但系统日志里全是「正常」两个字。我翻了几万行日志,愣是找不到是谁、在哪个模块、什么时候出的问题。嗯,从那以后,我彻底改了日志的写法。

说白了,日志格式标准化,就是为了让你在出问题时,能像侦探一样快速定位。今天我就聊聊我常用的日志格式,以及每个字段背后的设计思路。

3.1 标准日志格式长什么样?

我个人习惯用这种格式:

[2025-03-15 14:30:22.456] [Thread-12] [Trader-10086] [IO2406-C-3.20] [QuoteEngine] 收到行情更新,最新价: 3.25,波动率: 18.5%

你仔细看,这里面包含了六个核心字段:

字段 示例 作用
时间戳 2025-03-15 14:30:22.456 精确到毫秒,用于时序分析
线程ID Thread-12 定位并发问题,哪个线程在干活
交易员ID Trader-10086 区分不同交易员的操作
合约代码 IO2406-C-3.20 具体是哪个期权合约
日志来源模块 QuoteEngine 哪个模块输出的日志
消息体 收到行情更新... 具体的业务信息

核心原则:每个字段都要有明确的业务含义,不要为了格式而格式。日志是给人看的,不是给机器看的。

3.2 时间戳:精度与时区

时间戳是日志的灵魂。我见过太多系统用「yyyy-MM-dd HH:mm:ss」这种格式,结果毫秒级的问题根本查不出来。

为什么?因为做市系统里,很多操作是毫秒级的。比如一个报价的生成、发送、成交确认,可能就在几十毫秒内完成。没有毫秒精度,你根本分不清先后顺序。

我建议用这种格式:

yyyy-MM-dd HH:mm:ss.SSS

注意那个 .SSS,就是毫秒。如果你用的是Java,可以用 SimpleDateFormat 或者 DateTimeFormatter 来格式化。

小技巧:我习惯在时间戳后面加上时区信息,比如 +08:00。因为做市系统可能跨时区部署,不加时区容易搞混。

3.3 线程ID:并发问题的照妖镜

做市系统是典型的高并发系统。一个交易员可能同时监控几十个合约,每个合约都有自己的报价线程、风控线程、成交处理线程。

线程ID的作用是什么?就是告诉你「这条日志是哪个线程写的」。如果某个合约的报价出了问题,你可以通过线程ID找到对应的线程,看看它之前干了什么。

我曾经遇到过一个bug:某个线程因为死循环卡住了,导致所有合约的报价都停了。当时就是通过线程ID发现,所有日志都来自同一个线程,其他线程根本没干活。

注意:线程ID不要用 Thread.currentThread().getName() 的默认值,比如 Thread-0Thread-1。我建议在创建线程时给它一个有意义的名称,比如 QuoteEngine-IO2406,这样一看就知道是哪个合约的报价线程。

3.4 交易员ID:谁干的?

这个字段可能有人觉得多余——系统日志为什么要记录交易员?

你想想看,做市系统里,交易员可能会手动干预报价、调整参数、甚至取消订单。如果出了问题,你得知道是谁操作的。交易员ID就是用来做这个的。

我一般会在系统启动时,从配置中心读取当前登录的交易员ID,然后注入到日志上下文里。这样每条日志都会自动带上这个ID,不用手动传参。

// 伪代码示例
MDC.put("traderId", "Trader-10086");
logger.info("手动调整报价参数,波动率从18.5%改为19.0%");
MDC.remove("traderId");

这里用了 MDC(Mapped Diagnostic Context),是日志框架提供的一个上下文容器。你可以在一个请求的开始设置,在结束时清除,非常方便。

3.5 合约代码:哪个品种?

合约代码是期权做市系统的核心标识。没有它,你根本不知道这条日志说的是哪个合约。

我见过有人用 symbol 或者 instrument 这种模糊的字段名。我个人建议直接用 contractCode,并且格式要统一。比如:

  • IO2406-C-3.20:IO是上证50ETF期权,2406是到期月份,C是看涨,3.20是行权价
  • IO2406-P-3.20:P是看跌

为什么要统一?因为日志分析工具(比如ELK)需要根据合约代码做聚合和过滤。格式不统一,分析起来就是灾难。

避坑指南:我曾经在一个项目里,合约代码用了两种格式:一种是 IO2406C320,另一种是 IO2406-C-3.20。结果做统计时,同一个合约被当成了两个不同的品种。嗯,从那以后我强制要求所有模块用同一套格式。

3.6 日志来源模块:谁写的?

这个字段用来标识日志是从哪个模块输出的。比如:

  • QuoteEngine:报价引擎
  • RiskControl:风控模块
  • OrderManager:订单管理
  • MarketData:行情接收

模块名称要简短、有辨识度。我一般用英文,因为日志分析工具对英文支持更好。而且模块名称不要超过10个字符,太长会影响阅读。

为什么需要这个字段?因为做市系统模块很多,出了问题你得知道是哪个模块的锅。比如行情没更新,可能是 MarketData 模块没收到数据,也可能是 QuoteEngine 模块没处理数据。有了模块名,你一眼就能看出来。

3.7 消息体:说人话

消息体是日志里最灵活的部分。它用来描述具体的业务信息。

我见过最差的日志是这种:

[2025-03-15 14:30:22.456] [Thread-12] [Trader-10086] [IO2406-C-3.20] [QuoteEngine] 更新报价

「更新报价」这四个字,跟没写一样。更新了什么?从多少变成多少?为什么更新?完全没有信息量。

好的消息体应该是这样的:

[2025-03-15 14:30:22.456] [Thread-12] [Trader-10086] [IO2406-C-3.20] [QuoteEngine] 收到行情更新,最新价: 3.25,波动率: 18.5%,计算理论价: 3.22,调整后报价: 3.20/3.25

你看,这里面包含了输入(行情)、计算过程(理论价)、输出(报价)。如果报价有问题,你可以直接看到是哪个环节出了错。

我的习惯:消息体里尽量包含「输入-处理-输出」三个环节的关键信息。不要怕日志太长,怕的是信息不够。

3.8 日志格式的代码实现

说了这么多,我们来看看代码怎么写。以Java的Logback为例:

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <encoder>
        <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traderId}] [%X{contractCode}] [%logger{36}] %msg%n</pattern>
    </encoder>
</appender>

这里:

  • %d:时间戳
  • %thread:线程ID
  • %X{traderId}:从MDC中取交易员ID
  • %X{contractCode}:从MDC中取合约代码
  • %logger{36}:日志来源模块(类名)
  • %msg:消息体

如果你用Python的logging模块,可以这样:

import logging
from pythonjsonlogger import jsonlogger

log_format = "%(asctime)s [%(threadName)s] [%(traderId)s] [%(contractCode)s] [%(name)s] %(message)s"
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(log_format))

3.9 日志格式的SVG结构图

下面这张图,展示了标准日志格式的完整结构:

标准日志格式结构图 [2025-03-15 14:30:22.456] [Thread-12] [Trader-10086] [IO2406-C-3.20] [QuoteEngine] 收到行情更新... 时间戳 线程ID 交易员ID 合约代码 日志来源模块 消息体(业务信息) 每个字段都有明确的业务含义,缺一不可 时间戳精确到毫秒,线程ID用于并发分析 交易员ID和合约代码用于快速定位问题 模块名和消息体提供上下文信息

3.10 总结一下

日志格式标准化,说白了就是给每条日志打上「身份标签」。时间戳告诉你什么时候,线程ID告诉你谁在干活,交易员ID告诉你谁操作的,合约代码告诉你哪个品种,模块名告诉你哪个环节,消息体告诉你具体发生了什么。

这六个字段,缺一个都不行。我见过太多系统因为日志格式不规范,出了问题查半天。你想想看,如果每条日志都像上面那样标准,出问题后你只需要用grep或者ELK一搜,几秒钟就能定位到问题。

嗯,这就是我为什么这么强调日志格式标准化的原因。不是矫情,是吃过亏。

一句话总结:日志格式标准化,是系统可观测性的基石。别嫌麻烦,做对了,以后能省你无数时间。


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