日志是排障的第一手证据。当你凌晨 3 点被叫醒排查问题时,第一件事就是看日志。但如果你的日志是这样的:
2026-06-19 03:14:22 ERROR Failed to process request2026-06-19 03:14:22 WARN Retrying...2026-06-19 03:14:23 ERROR Failed again你几乎无法从中获取任何有用信息——哪个请求失败了?失败的原因是什么?这个请求的 TraceID 是什么?它和其他日志有什么关联?
结构化日志解决的就是这个问题。它让日志从”人类可读的文本”变成”机器可解析的数据”,从而实现快速搜索、高效聚合和跨信号关联。
一、为什么需要结构化日志
1.1 非结构化日志的困境
非结构化日志(纯文本日志)有三个根本问题:
问题 1:搜索效率低
# 搜索"用户 12345 的订单失败"——需要正则匹配grep "user_id=12345.*order.*failed" /var/log/app.log# 在 TB 级日志中,这可能需要数十分钟问题 2:无法聚合
# 统计各错误码的出现次数——需要复杂的正则和 awkgrep "error_code=" /var/log/app.log | \ sed 's/.*error_code=\([^ ]*\).*/\1/' | \ sort | uniq -c | sort -rn问题 3:无法关联
# 找到某个 TraceID 的所有日志——如果日志里没有 TraceID,就做不到grep "trace_id=abc123" /var/log/app.log1.2 结构化日志的优势
结构化日志将每条日志表示为一个键值对集合(通常是 JSON),解决了以上三个问题:
{ "timestamp": "2026-06-19T03:14:22.456Z", "level": "ERROR", "service": "order-service", "trace_id": "abc123def456", "span_id": "789ghi012", "user_id": "12345", "order_id": "ORD-67890", "error_code": "DB_CONNECTION_TIMEOUT", "message": "Failed to process order", "duration_ms": 5000, "db_pool_active": 50, "db_pool_max": 50}| 操作 | 非结构化 | 结构化 |
|---|---|---|
| 搜索用户 12345 的日志 | grep "12345" (可能误匹配) | {user_id="12345"} (精确匹配) |
| 统计错误码分布 | 复杂正则 + awk | count by (error_code) |
| 关联追踪 | 不可能(没有 TraceID) | {trace_id="abc123"} |
| 聚合延迟分布 | 不可能 | quantile by (service) |
二、结构化日志的设计原则
2.1 核心字段设计
一条好的结构化日志应该包含以下几类字段:
| 类别 | 字段 | 说明 | 示例 |
|---|---|---|---|
| 时间 | timestamp | ISO 8601 格式,带时区 | 2026-06-19T03:14:22.456Z |
| 上下文 | trace_id | 分布式追踪 ID | abc123def456789 |
| 上下文 | span_id | 当前 Span ID | 789ghi012 |
| 上下文 | request_id | 请求唯一 ID | req-uuid-1234 |
| 来源 | service | 服务名称 | order-service |
| 来源 | version | 服务版本 | v2.3.1 |
| 来源 | host | 主机名 | pod-order-7b8c9 |
| 业务 | user_id | 用户 ID | 12345 |
| 业务 | order_id | 订单 ID | ORD-67890 |
| 业务 | error_code | 错误码 | DB_CONNECTION_TIMEOUT |
| 度量 | duration_ms | 耗时(毫秒) | 5000 |
| 度量 | retry_count | 重试次数 | 3 |
| 消息 | message | 人类可读消息 | Failed to process order |
| 消息 | stack_trace | 异常堆栈 | java.lang.NullPointerException... |
2.2 字段命名约定
统一的字段命名是日志可搜索的前提。推荐遵循 OpenTelemetry 语义约定(Semantic Conventions):
# 推荐命名(遵循 OTel 语义约定)service.name: order-serviceservice.version: v2.3.1http.method: GEThttp.status_code: 500http.url: /api/v1/orders/12345db.system: postgresqldb.operation: SELECTerror.code: DB_CONNECTION_TIMEOUT
# 不推荐命名(不一致、难搜索)svc: order-servicever: v2.3.1method: GETstatus: 500url: /api/v1/orders/12345db: pgop: selecterr: DB_TIMEOUTOpenTelemetry 语义约定定义了一套标准化的属性名称,覆盖 HTTP、数据库、消息队列等常见场景。使用统一的命名约定可以避免”同一个概念在不同服务中有不同字段名”的问题。详见 OTel Semantic Conventions。
2.3 日志级别策略
日志级别是控制日志量和成本的第一道防线:
| 级别 | 用途 | 保留策略 | 典型场景 |
|---|---|---|---|
| TRACE | 最详细的调试信息 | 开发环境全量,生产环境关闭 | 函数入口/出口参数 |
| DEBUG | 调试信息 | 开发环境全量,生产环境按需开启 | 中间状态、决策分支 |
| INFO | 关键业务事件 | 全量保留(但控制量) | 请求处理完成、订单创建 |
| WARN | 潜在问题 | 全量保留 | 重试成功、降级处理 |
| ERROR | 错误事件 | 全量保留 | 请求失败、连接超时 |
| FATAL | 致命错误 | 全量保留 | 服务无法启动、数据损坏 |
日志级别膨胀是最常见的反模式。如果 INFO 级别日志量过大,说明你把 DEBUG 级别的信息写到了 INFO。一个经验法则:INFO 级别的日志应该是”业务事件”,而非”技术细节”。例如,“订单创建成功”是 INFO,“进入函数 processOrder”是 DEBUG。
三、关联 ID:日志与追踪的桥梁
3.1 什么是关联 ID
关联 ID(Correlation ID)是连接日志、指标、追踪三大信号的关键。最常见的关联 ID 是 TraceID 和 SpanID:
3.2 关联 ID 注入模式
模式 1:自动注入(推荐)
使用 OTel SDK 自动注入 TraceID 和 SpanID 到日志上下文:
// Go: 使用 OTel SDK 自动注入import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" "log/slog")
func setupLogger() *slog.Logger { handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, })
// 包装 handler,自动注入 TraceID 和 SpanID return slog.New(otelHandler{handler})}
type otelHandler struct { inner slog.Handler}
func (h otelHandler) Handle(ctx context.Context, r slog.Record) error { span := trace.SpanFromContext(ctx) if span.SpanContext().IsValid() { r.AddAttrs( slog.String("trace_id", span.SpanContext().TraceID().String()), slog.String("span_id", span.SpanContext().SpanID().String()), ) } return h.inner.Handle(ctx, r)}模式 2:手动注入
在无法使用 OTel SDK 的场景下,手动传递和注入关联 ID:
# Python: 手动注入关联 IDimport loggingimport jsonfrom contextvars import ContextVar
trace_id_var: ContextVar[str] = ContextVar('trace_id', default='')span_id_var: ContextVar[str] = ContextVar('span_id', default='')
class StructuredFormatter(logging.Formatter): def format(self, record): log_entry = { 'timestamp': self.formatTime(record), 'level': record.levelname, 'message': record.getMessage(), 'service': 'order-service', 'trace_id': trace_id_var.get(''), 'span_id': span_id_var.get(''), } if hasattr(record, 'props'): log_entry.update(record.props) if record.exc_info: log_entry['stack_trace'] = self.formatException(record.exc_info) return json.dumps(log_entry)
# 使用def handle_request(request): trace_id_var.set(request.headers.get('traceparent', '').split('-')[1] if 'traceparent' in request.headers else '') logger.info("Processing request", extra={ 'props': { 'user_id': request.user_id, 'order_id': request.order_id, } })模式 3:中间件注入
在 HTTP 中间件层统一注入关联 ID:
// Java: Spring Boot 中间件注入@Componentpublic class TraceIdFilter implements Filter {
@Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request;
// 从 W3C TraceContext 头部提取 TraceID String traceparent = httpRequest.getHeader("traceparent"); String traceId = extractTraceId(traceparent);
// 注入到 MDC(Mapped Diagnostic Context) MDC.put("trace_id", traceId); MDC.put("span_id", UUID.randomUUID().toString().replace("-", "").substring(0, 16));
try { chain.doFilter(request, response); } finally { MDC.clear(); } }
// logback.xml 配置 // <pattern>{"timestamp":"%d","level":"%-5level","trace_id":"%X{trace_id}","span_id":"%X{span_id}","message":"%msg"}%n</pattern>}3.3 关联 ID 传播
关联 ID 需要在服务间传播。W3C TraceContext 是当前的标准传播格式:
traceparent: 00-abc123def45678901234567890123456-789ghi01234567890-01 │ │ │ │ │ │ │ │ │ └─ 采样标志 │ │ │ └─ 父 Span ID │ │ └─ Trace ID (32 hex) │ └─ 版本号 └─ 格式前缀在第 4 章:分布式追踪中详细讨论 W3C TraceContext 的传播机制。
四、日志框架对比与选型
4.1 各语言主流日志框架
| 语言 | 框架 | 结构化支持 | OTel 集成 | 性能 | 推荐度 |
|---|---|---|---|---|---|
| Go | slog | 原生 JSON | 官方桥接 | 高 | |
| Go | zap | 原生 JSON | 社区桥接 | 极高 | |
| Go | zerolog | 原生 JSON | 社区桥接 | 极高 | |
| Java | Logback | JSON encoder | OTel appenders | 中 | |
| Java | Log4j2 | JSON layout | OTel appenders | 高 | |
| Python | structlog | 原生 JSON | 社区桥接 | 中 | |
| Python | logging + JSON | 需要 formatter | 需要手动 | 中 | |
| Node.js | pino | 原生 JSON | 社区桥接 | 极高 | |
| Node.js | winston | JSON format | 需要手动 | 中 | |
| Rust | tracing | 原生 JSON | 社区桥接 | 极高 |
4.2 Go slog 实战
Go 1.21 引入的 slog 是当前 Go 生态中最推荐的日志框架:
package main
import ( "context" "log/slog" "os" "time" "go.opentelemetry.io/otel/trace")
// 自定义 Handler,自动注入 OTel 上下文type OTelHandler struct { inner slog.Handler}
func (h OTelHandler) Enabled(ctx context.Context, level slog.Level) bool { return h.inner.Enabled(ctx, level)}
func (h OTelHandler) Handle(ctx context.Context, r slog.Record) error { // 自动注入 TraceID 和 SpanID span := trace.SpanFromContext(ctx) if span.SpanContext().IsValid() { r.AddAttrs( slog.String("trace_id", span.SpanContext().TraceID().String()), slog.String("span_id", span.SpanContext().SpanID().String()), ) } return h.inner.Handle(ctx, r)}
func (h OTelHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return OTelHandler{inner: h.inner.WithAttrs(attrs)}}
func (h OTelHandler) WithGroup(name string) slog.Handler { return OTelHandler{inner: h.inner.WithGroup(name)}}
func main() { // 创建结构化日志器 baseHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, }) logger := slog.New(OTelHandler{inner: baseHandler})
// 设置全局默认日志器 slog.SetDefault(logger)
// 使用 slog.Info("Order processed", slog.String("order_id", "ORD-12345"), slog.String("user_id", "user-67890"), slog.Duration("duration", 150*time.Millisecond), slog.Int("items_count", 3), )}输出:
{ "time": "2026-06-19T03:14:22.456Z", "level": "INFO", "msg": "Order processed", "trace_id": "abc123def45678901234567890123456", "span_id": "789ghi01234567890", "order_id": "ORD-12345", "user_id": "user-67890", "duration": 150000000, "items_count": 3}4.3 Node.js pino 实战
const pino = require('pino');
const logger = pino({ level: process.env.LOG_LEVEL || 'info', formatters: { level: (label) => ({ level: label }), }, // 默认字段 base: { service: 'order-service', version: process.env.SERVICE_VERSION || 'unknown', }, // 时间戳格式 timestamp: pino.stdTimeFunctions.isoTime,});
// OTel 上下文注入中间件function injectTraceContext(logger, context) { const activeSpan = context?.activeSpan; if (activeSpan) { const spanContext = activeSpan.spanContext(); return logger.child({ trace_id: spanContext.traceId, span_id: spanContext.spanId, }); } return logger;}
// 使用const reqLogger = injectTraceContext(logger, otelContext);reqLogger.info({ order_id: 'ORD-12345', user_id: 'user-67890', duration_ms: 150 }, 'Order processed');五、日志采集与聚合
5.1 日志采集架构
5.2 两种采集模式对比
| 模式 | 推送式(Push) | 拉取式(Pull) |
|---|---|---|
| 原理 | 应用主动推送日志到 Collector | Agent 定期读取日志文件 |
| 代表 | OTel SDK → OTel Collector | Fluent Bit → 文件 |
| 优点 | 实时性好、无需文件 I/O | 与应用解耦、支持遗留系统 |
| 缺点 | 需要修改应用代码 | 有延迟、需要管理日志文件轮转 |
| 推荐 | 新服务 | 遗留服务 |
5.3 Loki 日志聚合
Loki 是 Grafana 生态的日志聚合系统,采用”仅索引元数据”的设计,成本远低于 Elasticsearch:
# Loki 配置auth_enabled: false
server: http_listen_port: 3100
common: path_prefix: /loki storage: filesystem: chunks_directory: /loki/chunks rules_directory: /loki/rules replication_factor: 1
schema_config: configs: - from: 2024-01-01 store: tsdb object_store: filesystem schema: v13 index: prefix: loki_index_ period: 24h
limits_config: # 查询限制 max_query_length: 721h max_query_parallelism: 32 # 写入限制 ingestion_rate_mb: 20 ingestion_burst_size_mb: 30 # 保留期 retention_period: 744h # 31 天5.4 LogQL 查询语言
Loki 使用 LogQL 查询日志,语法类似 Prometheus 的 PromQL:
# 基础查询:查找 order-service 的 ERROR 日志{service="order-service"} |= "ERROR"
# 结构化查询:查找特定用户的所有日志{service="order-service"} | json | user_id="12345"
# 追踪关联:查找某个 TraceID 的所有日志{service="order-service"} | json | trace_id="abc123def456"
# 聚合查询:统计各错误码的出现次数sum(count_over_time({service="order-service"} | json | level="ERROR" [5m])) by (error_code)
# 延迟分析:P99 延迟趋势quantile_over_time(0.99, {service="order-service"} | json | unwrap duration_ms [5m]) by (endpoint)六、日志成本控制
6.1 日志成本模型
日志是可观测性中成本最高的信号。理解成本模型是控制成本的前提:
| 成本项 | 占比 | 说明 |
|---|---|---|
| 存储 | 40-50% | 日志数据量最大 |
| 索引 | 20-30% | Elasticsearch 的倒排索引开销 |
| 网络 | 10-15% | 日志传输带宽 |
| 计算 | 10-15% | 日志解析和聚合 |
| 人力 | 5-10% | 日志运维和治理 |
6.2 降本策略
策略 1:日志分级保留
# 不同级别的保留策略retention: fatal: 90d # 致命错误保留 90 天 error: 30d # 错误保留 30 天 warn: 14d # 警告保留 14 天 info: 7d # 信息保留 7 天 debug: 1d # 调试保留 1 天策略 2:采样
// 采样策略:INFO 级别采样 10%,ERROR 级别全量func shouldLog(level slog.Level) bool { switch level { case slog.LevelError, slog.LevelWarn: return true case slog.LevelInfo: return rand.Float64() < 0.1 // 10% 采样 default: return false }}策略 3:字段裁剪
# 裁剪高基数字段fields_to_drop: - request_body # 请求体太大 - response_body # 响应体太大 - session_token # 敏感信息 - x_forwarded_for # 高基数 IP 地址策略 4:Loki vs Elasticsearch 选型
| 维度 | Loki | Elasticsearch |
|---|---|---|
| 存储成本 | 低(仅索引标签) | 高(全文索引) |
| 搜索灵活性 | 中(LogQL) | 高(Lucene) |
| 全文搜索 | 慢(需要扫描) | 快(倒排索引) |
| 结构化搜索 | 快(标签索引) | 快 |
| 适用场景 | 结构化日志 + 追踪关联 | 全文搜索 + 日志分析 |
不要把所有日志都发到 Elasticsearch。对于结构化日志,Loki 的成本通常是 Elasticsearch 的 1/5 到 1/10。只有在需要全文搜索的场景(如非结构化日志分析)时才使用 Elasticsearch。
七、日志最佳实践
7.1 十条黄金法则
| 法则 | 说明 | 反模式 |
|---|---|---|
| 1. 结构化一切 | 所有日志都是 JSON | fmt.Println("something happened") |
| 2. 注入关联 ID | 每条日志都有 trace_id | 日志里没有追踪信息 |
| 3. 使用语义约定 | 遵循 OTel 属性命名 | 每个服务用不同的字段名 |
| 4. 控制日志级别 | INFO 是业务事件 | INFO 日志量 > ERROR 日志量 |
| 5. 避免敏感信息 | 不记录密码、Token | password=abc123 |
| 6. 记录度量字段 | duration_ms, bytes_sent | 只有消息没有数值 |
| 7. 限制消息长度 | 消息 < 200 字符 | 把整个 JSON 响应写入消息 |
| 8. 使用上下文日志 | logger.With() 附加上下文 | 每条日志都重复写 service 名 |
| 9. 异步写入 | 不阻塞业务线程 | 直接写文件 |
| 10. 测试日志 | 验证日志格式和字段 | 从不检查日志输出 |
7.2 上下文日志模式
上下文日志(Contextual Logging)让日志自动携带上下文信息,避免重复:
// 反模式:每条日志都重复写上下文slog.Info("Processing order", slog.String("service", "order-service"), slog.String("order_id", orderID), slog.String("user_id", userID),)slog.Error("Database timeout", slog.String("service", "order-service"), slog.String("order_id", orderID), slog.String("user_id", userID),)
// 最佳实践:使用上下文日志orderLogger := slog.With( slog.String("service", "order-service"), slog.String("order_id", orderID), slog.String("user_id", userID),)orderLogger.Info("Processing order")orderLogger.Error("Database timeout", slog.String("db_host", dbHost))7.3 错误日志模板
// 标准错误日志模板func logError(ctx context.Context, err error, msg string, attrs ...slog.Attr) { span := trace.SpanFromContext(ctx)
logger := slog.With( slog.String("trace_id", span.SpanContext().TraceID().String()), slog.String("span_id", span.SpanContext().SpanID().String()), slog.String("error_type", fmt.Sprintf("%T", err)), slog.String("error_message", err.Error()), )
// 如果有堆栈信息 var stackErr interface{ StackTrace() errors.StackTrace } if errors.As(err, &stackErr) { logger = logger.With(slog.String("stack_trace", fmt.Sprintf("%+v", stackErr.StackTrace()))) }
logger.Error(msg, attrs...)}八、动手实践:搭建结构化日志系统
8.1 部署 Loki
# 使用 Docker Compose 启动 Lokidocker compose up -d loki
# 验证 Loki 运行curl http://localhost:3100/ready8.2 发送结构化日志
# 通过 OTel Collector 发送日志curl -X POST http://localhost:4318/v1/logs \ -H "Content-Type: application/json" \ -d '{ "resourceLogs": [{ "resource": { "attributes": [ {"key": "service.name", "value": {"stringValue": "demo-api"}}, {"key": "service.version", "value": {"stringValue": "1.0.0"}} ] }, "scopeLogs": [{ "scope": {"name": "io.opentelemetry.sdk.log"}, "logRecords": [{ "timeUnixNano": "1700000000000000000", "severityNumber": 17, "severityText": "ERROR", "body": {"stringValue": "Database connection timeout"}, "attributes": [ {"key": "trace_id", "value": {"stringValue": "abc123def45678901234567890123456"}}, {"key": "span_id", "value": {"stringValue": "789ghi01234567890"}}, {"key": "error_code", "value": {"stringValue": "DB_CONNECTION_TIMEOUT"}}, {"key": "duration_ms", "value": {"intValue": "5000"}} ] }] }] }] }'8.3 在 Grafana 中查询
# 打开 Grafana Exploreopen http://localhost:3000/explore
# LogQL 查询示例# 1. 查找所有 ERROR 日志{service="demo-api"} | json | level="ERROR"
# 2. 按 TraceID 关联查询{service="demo-api"} | json | trace_id="abc123def45678901234567890123456"
# 3. 统计错误码分布sum(count_over_time({service="demo-api"} | json | level="ERROR" [1h])) by (error_code)8.4 验证清单
| 检查项 | 验证方式 | 预期结果 |
|---|---|---|
| Loki 接收日志 | Grafana Explore 查询 | 能看到日志 |
| 结构化字段可搜索 | {service="demo-api"} | json | error_code="DB_CONNECTION_TIMEOUT" | 精确匹配 |
| TraceID 关联 | 按 trace_id 过滤 | 找到关联的追踪 |
| 日志级别过滤 | level="ERROR" | 只返回错误日志 |
九、本章小结
上一章建立了可观测性全景与三大信号的认知框架。 可观测性的第一个信号——结构化日志的要点基本都在这里了。
| 主题 | 核心要点 | 关键词 |
|---|---|---|
| 结构化日志 | 将日志从纯文本变成 JSON,实现精确搜索、高效聚合和跨信号关联。 | 结构化日志 |
| 关联 ID | TraceID 和 SpanID 是连接日志与追踪的桥梁,应该自动注入每条日志。 | 关联 ID |
| 日志级别策略 | ERROR 全量保留,INFO 控制量,DEBUG 按需开启。 | 日志级别策略 |
| 框架选型 | Go 用 slog、Python 用 structlog、Node.js 用 pino——都支持结构化和 OTel 集成。 | 框架选型 |
| 成本控制 | 日志是可观测性最贵的信号,分级保留 + 采样 + 字段裁剪是降本三大法宝。 | 成本控制 |
| Loki | 仅索引元数据的设计让成本远低于 Elasticsearch,适合结构化日志场景。 | Loki |
参考
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






