背景与问题界定
日志是分布式系统中最基础也是最重要的可观测性数据来源。然而,在许多 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_hooks 的 AsyncLocalStorage 在整个请求生命周期中维护 traceId 和 spanId。配合 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') 调用都应该有明确的读者和目的,如果开发者无法回答"这条日志写给谁看、用来排查什么",那它就是一条噪音。在成本压力下,清晰的日志级别和采样策略比完美的数据模型更有实践价值。