背景与问题界定

日志是分布式系统中最基础也是最重要的可观测性数据来源。然而,在许多 Node.js 项目中,日志体系的设计仍然停留在"console.log 加个时间戳"的阶段。当系统流量上升、出现线上问题需要排查时,这种原始日志方案暴露出诸多问题:日志散落在标准输出和文件中,没有统一的查询入口;日志级别混乱——info、warn、error 的使用没有统一标准,导致告警噪音极高;关键的业务请求缺乏链路标识,无法将一个请求在多个微服务和函数调用间的日志串联起来。更严重的是,高性能场景下频繁的日志 I/O 可能导致线程阻塞,影响服务吞吐量。这些问题直指一个核心矛盾:日志既要详尽到可以排查问题,又要精简到不影响性能和存储成本。

目标拆解与工程约束

  • 结构化日志输出:所有日志必须以 JSON 格式输出,包含时间戳、级别、模块名、请求 ID(traceId)和结构化消息体,禁止拼接字符串的日志模式。
  • 日志级别的严格语义:trace 用于开发调式;debug 用于诊断;info 记录关键业务流程节点;warn 表示非正常但可恢复的状态;error 表示功能失败或异常。error 级别必须直接触发告警通知。
  • 性能开销可控:日志写入不应导致请求处理的 TP99 增加超过 1ms。生产环境日志的采样率可通过配置动态调整,降级时关闭 debug+trace 级别。
  • 链路追踪集成:请求进入服务时生成或传递 traceId,日志中自动携带该 ID,并与下游的 HTTP/gRPC 调用关联,形成完整的调用链视图。

方案设计

我们基于 pino + pino-opentelemetry 构建日志体系。pino 是 Node.js 中性能最好的日志库之一,相比 winston 吞吐量高出 5~10 倍,适合高并发场景。

日志核心封装

创建一个统一的日志工厂函数,确保所有模块的日志输出格式一致:

import pino from 'pino'
import { AsyncLocalStorage } from 'async_hooks'

const asyncLocalStorage = new AsyncLocalStorage<{ traceId: string }>()

function createLogger(module: string) {
  const transport = pino.transport({
    target: 'pino/file',
    options: { destination: process.env.LOG_FILE || '/dev/stdout' }
  })

  return pino(
    {
      level: process.env.LOG_LEVEL || 'info',
      mixin() {
        const store = asyncLocalStorage.getStore()
        return {
          module,
          traceId: store?.traceId,
          pid: process.pid,
          host: process.env.HOSTNAME
        }
      },
      formatters: {
        level(label) { return { level: label } },
        bindings() { return {} }  // 不输出默认的 bindings
      },
      timestamp: pino.stdTimeFunctions.isoTime
    },
    transport
  )
}

// 中间件设置 traceId
async function tracingMiddleware(ctx, next) {
  const traceId = ctx.request.headers['x-trace-id'] || crypto.randomUUID()
  await asyncLocalStorage.run({ traceId }, next)
  ctx.set('x-trace-id', traceId)
}

日志分级与动态采样

生产环境默认开启 info 及以上级别。当需要调试特定问题时,通过配置中心的动态开关,对指定模块或指定用户开启 debug 级别,同时通过采样率控制日志量:

// 运行时动态调整日志级别
function updateLogLevel(module: string, level: pino.Level) {
  const childLogger = loggers.get(module)
  if (childLogger) {
    childLogger.level = level
  }
}

采样策略:对于高频日志点(如每分钟超 1000 条的 info 日志),配置采样率(如 10%),避免存储爆炸。但 error 级别的日志永远不采样。

链路关联

使用 async_hooksAsyncLocalStorage 在整个请求生命周期中维护 traceIdspanId。配合 OpenTelemetry SDK,自动将 HTTP 请求的父 traceId 传播到下游服务(通过 W3C traceparent header),使跨服务的日志可以按 traceId 聚合查询。

实施路径与关键决策

  • 统一日志格式标准:团队内定义一份日志规范文档,明确每个字段的语义、类型和取值范围,所有新接入服务强制遵守。
  • 禁用 console.log:通过 ESLint 插件 eslint-plugin-no-console 在开发阶段即阻止 console 日志,引导使用统一日志库。
  • 日志采集与存储:stdout 日志被 Docker 或 K8s 的日志采集代理捕获后,统一发送到 Loki 或 Elasticsearch,通过 Grafana 或 Kibana 进行查询和告警。
  • 敏感信息脱敏:在日志输出前过滤密码、Token、身份证号等敏感字段,使用 pino-noir 或自定义 redact 配置。

验证指标与可持续迭代

日志写入性能测试:在 1 万 QPS 的 HTTP 服务中,日志写入对端到端时延的影响控制在 2% 以内。日志查询体验:通过 traceId 可以在 3 秒内查询到该请求在同一个服务内的全部日志行。告警准确率:error 级别日志触发的告警,人工确认为真实问题的比例不低于 90%。后续迭代方向:日志的自动异常检测(基于日志模式的异常偏离检测)和日志成本优化(冷热数据分层存储)。

工程落地思考

日志体系的设计是"为故障排查做准备"的过程,其价值只有在真正发生故障时才会被验证。一个好的日志体系应该让开发者像读代码一样读日志——结构清晰、语义明确、上下文完整。但也要警惕过度日志化:每一个 logger.info('xxx') 调用都应该有明确的读者和目的,如果开发者无法回答"这条日志写给谁看、用来排查什么",那它就是一条噪音。在成本压力下,清晰的日志级别和采样策略比完美的数据模型更有实践价值。