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-0、Thread-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结构图
下面这张图,展示了标准日志格式的完整结构:
3.10 总结一下
日志格式标准化,说白了就是给每条日志打上「身份标签」。时间戳告诉你什么时候,线程ID告诉你谁在干活,交易员ID告诉你谁操作的,合约代码告诉你哪个品种,模块名告诉你哪个环节,消息体告诉你具体发生了什么。
这六个字段,缺一个都不行。我见过太多系统因为日志格式不规范,出了问题查半天。你想想看,如果每条日志都像上面那样标准,出问题后你只需要用grep或者ELK一搜,几秒钟就能定位到问题。
嗯,这就是我为什么这么强调日志格式标准化的原因。不是矫情,是吃过亏。
一句话总结:日志格式标准化,是系统可观测性的基石。别嫌麻烦,做对了,以后能省你无数时间。
公众号:蓝海资料掘金营,微信deep3321