后端日志系统设计:结构化日志与链路追踪实战指南
引言
在微服务架构盛行的今天,日志不再是简单的 console.log。当你的系统由十几个服务组成,一个请求可能跨越多个节点,传统的无格式日志就像大海捞针。结构化日志(Structured Logging)和链路追踪(Distributed Tracing)是解决这一问题的关键。本文将通过实战,带你设计一套兼具可读性与可查询性的日志系统。
1. 为什么需要结构化日志?
传统日志:
2023-10-01 12:00:00 [INFO] User login success: user_id=123
- 难以解析:不同开发者格式不统一
- 难以查询:无法按字段过滤
- 难以聚合:无法做统计
结构化日志:
{"timestamp":"2023-10-01T12:00:00Z","level":"info","message":"User login success","user_id":123,"request_id":"abc-123"}
- 机器可读:可被 logstash、fluentd 直接消费
- 字段可索引:在 Kibana 中快速过滤
- 易于扩展:添加自定义字段无需改解析逻辑
2. 设计结构化日志格式
2.1 核心字段
| 字段 | 类型 | 说明 | |——|——|——| | timestamp | ISO8601 | 日志产生时间 | | level | string | 日志级别:debug, info, warn, error | | message | string | 人类可读的描述 | | service | string | 服务名 | | traceid | string | 链路追踪 ID | | spanid | string | 当前 span ID | | caller | string | 调用位置(文件:行号) | | data | object | 业务相关数据 |
2.2 日志级别规范
- error: 需要人工干预的异常(如数据库连接失败)
- warn: 不影响服务但值得注意(如慢查询)
- info: 关键业务流程(如订单创建)
- debug: 开发调试信息,生产环境应关闭
3. 实战:Node.js 结构化日志实现
3.1 使用 pino 日志库
Pino 是 Node.js 最快的 JSON 日志库,默认输出结构化日志。
const pino = require('pino');
// 创建 logger 实例
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level(label) {
return { level: label };
}
},
timestamp: pino.stdTimeFunctions.isoTime,
// 自定义序列化器
serializers: {
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
err: pino.stdSerializers.err
}
});
// 使用示例
logger.info({ user_id: 123 }, 'User login success');
logger.error({ err: new Error('DB connection failed'), db: 'mysql' }, 'Database error');
输出:
{"level":30,"time":1696147200000,"pid":12345,"hostname":"server-1","msg":"User login success","user_id":123}
> 注意:默认 level 是数字,可通过 formatters 改为字符串。
3.2 添加调用位置信息
在生产环境中,我们希望知道日志出自哪个文件。使用 pino-caller 扩展:
const pinoCaller = require('pino-caller');
const baseLogger = pino();
const logger = pinoCaller(baseLogger, {
relativeTo: __dirname // 可选,使路径相对
});
logger.info('Hello'); // 输出包含 caller 字段
3.3 集成链路追踪
我们使用 OpenTelemetry 实现链路追踪,并将 trace_id 注入日志。
npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node
初始化 SDK:
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { ConsoleSpanExporter } = require('@opentelemetry/sdk-trace-node');
const sdk = new NodeSDK({
traceExporter: new ConsoleSpanExporter(),
instrumentations: [getNodeAutoInstrumentations()]
});
sdk.start();
为了让日志包含 trace_id,我们需要创建一个自定义日志处理器:
const { context, trace } = require('@opentelemetry/api');
function enrichLogger(logger) {
return new Proxy(logger, {
get(target, prop) {
const original = target[prop];
if (typeof original !== 'function') return original;
return function(...args) {
const span = trace.getSpan(context.active());
if (span) {
const spanContext = span.spanContext();
// 将 trace_id 和 span_id 注入到第一个参数(如果第一个参数是对象)
if (args[0] && typeof args[0] === 'object') {
args[0].trace_id = spanContext.traceId;
args[0].span_id = spanContext.spanId;
} else {
args.unshift({ trace_id: spanContext.traceId, span_id: spanContext.spanId });
}
}
return original.apply(target, args);
};
}
});
}
const enrichedLogger = enrichLogger(logger);
// 使用
enrichedLogger.info({ user_id: 123 }, 'User login');
现在每条日志都会携带当前请求的 trace_id。
4. 日志收集与存储:ELK 栈
4.1 Filebeat 配置
Filebeat 作为日志采集器,将日志发送到 Elasticsearch。
# filebeat.yml
filebeat.inputs:
- type: log
paths:
- /var/log/app/*.log
json.keys_under_root: true
json.add_error_key: true
json.message_key: message
output.elasticsearch:
hosts: ["localhost:9200"]
index: "app-logs-%{+yyyy.MM.dd}"
# 可选:使用 Logstash 中转
# output.logstash:
# hosts: ["localhost:5044"]
4.2 Elasticsearch 索引模板
为了优化字段映射,创建索引模板:
PUT _template/app-logs
{
"index_patterns": ["app-logs-*"],
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1
},
"mappings": {
"properties": {
"@timestamp": { "type": "date" },
"level": { "type": "keyword" },
"message": { "type": "text" },
"service": { "type": "keyword" },
"trace_id": { "type": "keyword" },
"span_id": { "type": "keyword" },
"caller": { "type": "keyword" },
"data": { "type": "object", "enabled": false } // 不索引 data 内部
}
}
}
4.3 Kibana 可视化
- 创建索引模式
app-logs-* - 使用 Discover 按
trace_id过滤,查看一次请求的所有日志 - 创建仪表盘:错误率趋势、慢请求分布等
5. 最佳实践与坑
5.1 避免日志轰炸
- 使用采样:对于高频日志(如健康检查),只记录部分
- 动态调整日志级别:通过配置中心实时修改
5.2 敏感信息脱敏
const sensitiveFields = ['password', 'credit_card'];
function sanitize(obj) {
const clone = { ...obj };
for (const key of sensitiveFields) {
if (clone[key]) clone[key] = '***';
}
return clone;
}
logger.info(sanitize({ password: 'secret' }), 'Login attempt');
5.3 异步日志写入
Pino 默认是同步写入,但可以配置异步:
const pino = require('pino');
const logger = pino({
transport: {
target: 'pino/file',
options: { destination: '/var/log/app.log', sync: false }
}
});
注意:异步写入可能导致进程退出时丢失日志,需配合 pino.final 在退出前 flush。
5.4 常见坑
- 时区问题:始终使用 UTC 时间
- 日志轮转:使用 logrotate 或配置 pino 的
rotation参数 - 磁盘空间:设置日志保留策略(如保留 7 天)
6. 进阶:基于日志的告警
利用 ElastAlert 或 Kibana Alerting,根据日志字段触发告警。例如,当 level: error 且 service: payment 在 5 分钟内出现超过 10 次时,发送邮件。
# elastalert rule.yaml
name: Payment Error Spike
type: frequency
index: app-logs-*
num_events: 10
timeframe:
minutes: 5
filter:
- term:
level: error
- term:
service: payment
alert:
- email
email:
- admin@example.com
总结
本文从结构化日志格式设计开始,到 Node.js 中的具体实现,再到 ELK 栈的集成,最后给出最佳实践和常见坑。关键点:
- 统一日志格式:所有服务输出 JSON,包含 trace_id
- 链路追踪:使用 OpenTelemetry 自动注入 trace_id
- 集中收集:Filebeat + Elasticsearch + Kibana
- 监控告警:基于日志指标触发通知
下一步,你可以探索更高级的日志分析,比如使用机器学习检测异常模式,或者将日志与指标(Metrics)、追踪(Traces)结合实现可观测性三大支柱。