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 或类似机制,让日志不「断片」。

做到这三点,你的日志就不再是噪音,而是真正的「数据资产」。出了问题,几分钟就能定位到根因。嗯,这才是工程化该有的样子。