4、结构化日志:JSON格式日志、字段设计、上下文信息注入
说实话,我见过太多团队在日志上栽跟头了。
项目初期,大家图省事,直接 console.log 一把梭。等上了生产环境,日志量一上来,那叫一个惨不忍睹。满屏的字符串拼接,想查个用户ID得用眼睛扫半天。你想想看,这哪是日志,分明是噪音。
所以这一章,我们来聊聊结构化日志。说白了,就是把日志从「散文」变成「表格」。让机器能读懂,让人也能快速定位问题。
4.1 为什么非得用 JSON 格式?
我个人的习惯是,只要日志输出到文件或日志中心,一律用 JSON 格式。为什么?
- 机器友好:Elasticsearch、Splunk 这些工具,天生就爱 JSON。字段自动解析,搜索效率翻倍。
- 扩展性强:今天加个字段,明天删个字段,完全不影响老日志的解析。字符串拼接的话,改个格式就得改解析脚本。
- 上下文清晰:每个字段都有名字,一眼就知道这个值代表什么。
我在项目中遇到过最典型的反面教材:
// 千万别这么干
logger.info('用户 ' + userId + ' 在 ' + new Date() + ' 执行了订单创建,订单号:' + orderId);
这种日志,你告诉我怎么用 Kibana 做聚合分析?想统计某个用户的订单量,得写正则表达式去匹配字符串里的数字。嗯,这里要注意,正则匹配不仅慢,还容易出错。
正确的做法是这样:
// 这才是结构化日志
logger.info({
event: 'order.created',
userId: 'u_12345',
orderId: 'ord_67890',
amount: 299.00,
timestamp: '2024-01-15T10:30:00Z'
});
你看,一目了然。想查某个用户的所有操作?直接搜 userId: "u_12345" 就行。
4.2 字段设计:别乱来,得有规矩
日志字段不是随便起的。我见过有人用 uid,有人用 userId,还有人用 user_id。同一个项目里,三种写法都有。这要是放到日志中心,同一个用户的数据会被分到三个字段里,查起来得写三个查询条件。
所以,我建议团队里统一一个规范。下面是我常用的字段设计模板:
| 字段名 | 类型 | 说明 | 示例 |
|---|---|---|---|
timestamp |
string (ISO 8601) | 事件发生时间 | 2024-01-15T10:30:00.123Z |
level |
string | 日志级别 | INFO / WARN / ERROR |
logger |
string | 记录器名称(通常是类名) | OrderService |
traceId |
string | 全链路追踪ID | abc123def456 |
spanId |
string | 当前调用跨度ID | span_001 |
event |
string | 事件名称(动词+名词) | order.created |
message |
string | 人类可读的描述 | 订单创建成功 |
userId |
string | 操作用户ID | u_12345 |
requestId |
string | 请求唯一标识 | req_abc |
duration |
number | 耗时(毫秒) | 150 |
error |
object | 错误详情(仅ERROR级别) | { "stack": "...", "code": 500 } |
核心原则:字段名统一用小驼峰(camelCase),类型要明确,不要混用。比如 duration 永远是数字,别有时候传字符串 "150ms"。
4.3 上下文信息注入:别让日志「断片」
你有没有遇到过这种情况?线上出了个错误,日志里只有一句 Error: something went wrong。你翻遍所有日志,就是不知道是哪个用户、哪个请求、哪个参数导致的。
为什么会这样?因为日志「断片」了。它丢失了上下文信息。
我常用的解决方案是 MDC(Mapped Diagnostic Context)。说白了,就是一个线程级别的上下文容器。你在请求入口处把关键信息塞进去,后续所有日志都会自动带上这些字段。
在 Node.js 里,我习惯用 cls-hooked 或者 async_hooks 来实现:
// 中间件:注入上下文
app.use((req, res, next) => {
const session = cls.getNamespace('app');
session.run(() => {
session.set('traceId', req.headers['x-trace-id'] || uuid());
session.set('userId', req.user?.id);
session.set('requestId', uuid());
next();
});
});
// 业务代码中直接使用
function createOrder(userId, orderData) {
const session = cls.getNamespace('app');
logger.info({
event: 'order.created',
userId: session.get('userId'),
traceId: session.get('traceId'),
// ... 其他字段
});
}
小技巧:我习惯在日志工具内部封装一个 getContext() 方法,自动从 MDC 里读取公共字段。这样业务代码里只需要关注业务字段,公共字段自动注入,省心不少。
4.4 实战:一个完整的结构化日志工具
光说不练假把式。下面是我个人项目中常用的一个简化版日志工具:
class StructuredLogger {
constructor(options = {}) {
this.level = options.level || 'info';
this.context = options.context || {};
}
// 核心方法:格式化输出
_log(level, event, message, extra = {}) {
if (!this._shouldLog(level)) return;
const logEntry = {
timestamp: new Date().toISOString(),
level,
event,
message,
// 合并上下文和额外字段
...this.context,
...extra,
};
// 输出到控制台(生产环境会输出到文件或日志中心)
console.log(JSON.stringify(logEntry));
}
info(event, message, extra) {
this._log('INFO', event, message, extra);
}
warn(event, message, extra) {
this._log('WARN', event, message, extra);
}
error(event, message, error, extra) {
this._log('ERROR', event, message, {
...extra,
error: {
message: error?.message,
stack: error?.stack,
code: error?.code,
},
});
}
_shouldLog(level) {
const levels = ['DEBUG', 'INFO', 'WARN', 'ERROR'];
return levels.indexOf(level) >= levels.indexOf(this.level);
}
}
// 使用示例
const logger = new StructuredLogger({
level: 'info',
context: { service: 'order-service', version: '1.0.0' },
});
// 注入请求上下文
logger.context = { ...logger.context, traceId: 'abc123', userId: 'u_12345' };
logger.info('order.created', '订单创建成功', { orderId: 'ord_67890', amount: 299 });
// 输出:{"timestamp":"2024-01-15T10:30:00.123Z","level":"INFO","event":"order.created","message":"订单创建成功","service":"order-service","version":"1.0.0","traceId":"abc123","userId":"u_12345","orderId":"ord_67890","amount":299}
注意:千万不要在日志里记录敏感信息,比如密码、身份证号、银行卡号。我曾经见过有人把用户密码明文打到了日志里,结果日志文件泄露,那叫一个惨。记住,日志是给运维和开发看的,不是给黑客看的。
4.5 总结一下
结构化日志,说白了就是三个要点:
- 格式统一:用 JSON,别用字符串拼接。
- 字段规范:统一命名、统一类型、统一含义。
- 上下文注入:用 MDC 或类似机制,让日志不「断片」。
做到这三点,你的日志就不再是噪音,而是真正的「数据资产」。出了问题,几分钟就能定位到根因。嗯,这才是工程化该有的样子。