mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2371 字
7 分钟
结构化日志
2025-07-16

日志是排障的第一手证据。当你凌晨 3 点被叫醒排查问题时,第一件事就是看日志。但如果你的日志是这样的:

2026-06-19 03:14:22 ERROR Failed to process request
2026-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:无法聚合

# 统计各错误码的出现次数——需要复杂的正则和 awk
grep "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.log

1.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"} (精确匹配)
统计错误码分布复杂正则 + awkcount by (error_code)
关联追踪不可能(没有 TraceID){trace_id="abc123"}
聚合延迟分布不可能quantile by (service)

二、结构化日志的设计原则#

2.1 核心字段设计#

一条好的结构化日志应该包含以下几类字段:

graph TB subgraph 日志字段分类 TIME["⏰ 时间字段<br/>timestamp"] CTX[" 上下文字段<br/>trace_id, span_id, request_id"] SRC[" 来源字段<br/>service, version, host, pod"] BIZ[" 业务字段<br/>user_id, order_id, error_code"] METRIC[" 度量字段<br/>duration_ms, bytes_sent, retry_count"] MSG[" 消息字段<br/>message, stack_trace"] end TIME --> CTX --> SRC --> BIZ --> METRIC --> MSG style TIME fill:#e3f2fd,stroke:#1565c0 style CTX fill:#e8f5e9,stroke:#2e7d32 style SRC fill:#fff3e0,stroke:#e65100 style BIZ fill:#fce4ec,stroke:#880e4f style METRIC fill:#f3e5f5,stroke:#6a1b9a style MSG fill:#efebe9,stroke:#4e342e
类别字段说明示例
时间timestampISO 8601 格式,带时区2026-06-19T03:14:22.456Z
上下文trace_id分布式追踪 IDabc123def456789
上下文span_id当前 Span ID789ghi012
上下文request_id请求唯一 IDreq-uuid-1234
来源service服务名称order-service
来源version服务版本v2.3.1
来源host主机名pod-order-7b8c9
业务user_id用户 ID12345
业务order_id订单 IDORD-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-service
service.version: v2.3.1
http.method: GET
http.status_code: 500
http.url: /api/v1/orders/12345
db.system: postgresql
db.operation: SELECT
error.code: DB_CONNECTION_TIMEOUT
# 不推荐命名(不一致、难搜索)
svc: order-service
ver: v2.3.1
method: GET
status: 500
url: /api/v1/orders/12345
db: pg
op: select
err: DB_TIMEOUT
Note

OpenTelemetry 语义约定定义了一套标准化的属性名称,覆盖 HTTP、数据库、消息队列等常见场景。使用统一的命名约定可以避免”同一个概念在不同服务中有不同字段名”的问题。详见 OTel Semantic Conventions

2.3 日志级别策略#

日志级别是控制日志量和成本的第一道防线:

级别用途保留策略典型场景
TRACE最详细的调试信息开发环境全量,生产环境关闭函数入口/出口参数
DEBUG调试信息开发环境全量,生产环境按需开启中间状态、决策分支
INFO关键业务事件全量保留(但控制量)请求处理完成、订单创建
WARN潜在问题全量保留重试成功、降级处理
ERROR错误事件全量保留请求失败、连接超时
FATAL致命错误全量保留服务无法启动、数据损坏
Warning

日志级别膨胀是最常见的反模式。如果 INFO 级别日志量过大,说明你把 DEBUG 级别的信息写到了 INFO。一个经验法则:INFO 级别的日志应该是”业务事件”,而非”技术细节”。例如,“订单创建成功”是 INFO,“进入函数 processOrder”是 DEBUG。

三、关联 ID:日志与追踪的桥梁#

3.1 什么是关联 ID#

关联 ID(Correlation ID)是连接日志、指标、追踪三大信号的关键。最常见的关联 ID 是 TraceID 和 SpanID:

sequenceDiagram participant Client participant Gateway as API Gateway participant Order as Order Service participant DB as Database Client->>Gateway: GET /orders/123 Note right of Gateway: trace_id=abc123<br/>span_id=span1 Gateway->>Order: ProcessOrder(123) Note right of Order: trace_id=abc123<br/>span_id=span2<br/>parent_span_id=span1 Order->>DB: SELECT * FROM orders Note right of DB: trace_id=abc123<br/>span_id=span3<br/>parent_span_id=span2 DB-->>Order: Result Order-->>Gateway: Response Gateway-->>Client: 200 OK

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: 手动注入关联 ID
import logging
import json
from 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 中间件注入
@Component
public 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 集成性能推荐度
Goslog原生 JSON官方桥接
Gozap原生 JSON社区桥接极高
Gozerolog原生 JSON社区桥接极高
JavaLogbackJSON encoderOTel appenders
JavaLog4j2JSON layoutOTel appenders
Pythonstructlog原生 JSON社区桥接
Pythonlogging + JSON需要 formatter需要手动
Node.jspino原生 JSON社区桥接极高
Node.jswinstonJSON format需要手动
Rusttracing原生 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 日志采集架构#

graph LR subgraph 应用[" 应用服务"] A1["Service A<br/>slog/pino"] A2["Service B<br/>structlog"] A3["Service C<br/>Logback"] end subgraph 采集[" 采集层"] OTEL["OTel Collector<br/>OTLP Receiver"] FLUENT["Fluent Bit<br/>文件采集"] end subgraph 存储[" 存储层"] LOKI["Loki"] ES["Elasticsearch"] end subgraph 查询[" 查询层"] GRAFANA["Grafana"] KIBANA["Kibana"] end A1 -->|"OTLP"| OTEL A2 -->|"OTLP"| OTEL A3 -->|"文件"| FLUENT OTEL --> LOKI FLUENT --> LOKI FLUENT --> ES LOKI --> GRAFANA ES --> KIBANA style 应用 fill:#e8eaf6,stroke:#283593 style 采集 fill:#e0f2f1,stroke:#00695c style 存储 fill:#fff3e0,stroke:#e65100 style 查询 fill:#e8f5e9,stroke:#2e7d32

5.2 两种采集模式对比#

模式推送式(Push)拉取式(Pull)
原理应用主动推送日志到 CollectorAgent 定期读取日志文件
代表OTel SDK → OTel CollectorFluent 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 选型

维度LokiElasticsearch
存储成本低(仅索引标签)高(全文索引)
搜索灵活性中(LogQL)高(Lucene)
全文搜索慢(需要扫描)快(倒排索引)
结构化搜索快(标签索引)
适用场景结构化日志 + 追踪关联全文搜索 + 日志分析
Warning

不要把所有日志都发到 Elasticsearch。对于结构化日志,Loki 的成本通常是 Elasticsearch 的 1/5 到 1/10。只有在需要全文搜索的场景(如非结构化日志分析)时才使用 Elasticsearch。

七、日志最佳实践#

7.1 十条黄金法则#

法则说明反模式
1. 结构化一切所有日志都是 JSONfmt.Println("something happened")
2. 注入关联 ID每条日志都有 trace_id日志里没有追踪信息
3. 使用语义约定遵循 OTel 属性命名每个服务用不同的字段名
4. 控制日志级别INFO 是业务事件INFO 日志量 > ERROR 日志量
5. 避免敏感信息不记录密码、Tokenpassword=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 启动 Loki
docker compose up -d loki
# 验证 Loki 运行
curl http://localhost:3100/ready

8.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 Explore
open 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"只返回错误日志
flowchart LR APP["应用输出<br/>结构化日志"] --> AGENT["日志 Agent<br/>Filebeat/Fluentd"] --> BUF["缓冲队列<br/>Kafka/Redis"] BUF --> INDEX["索引引擎<br/>Elasticsearch"] --> QUERY["查询/分析<br/>Kibana"] APP -.->|"直接推送"| INDEX style APP fill:#bbdefb,stroke:#1565c0 style INDEX fill:#c8e6c9,stroke:#2e7d32

九、本章小结#

上一章建立了可观测性全景与三大信号的认知框架。 可观测性的第一个信号——结构化日志的要点基本都在这里了。

主题核心要点关键词
结构化日志将日志从纯文本变成 JSON,实现精确搜索、高效聚合和跨信号关联。结构化日志
关联 IDTraceID 和 SpanID 是连接日志与追踪的桥梁,应该自动注入每条日志。关联 ID
日志级别策略ERROR 全量保留,INFO 控制量,DEBUG 按需开启。日志级别策略
框架选型Go 用 slog、Python 用 structlog、Node.js 用 pino——都支持结构化和 OTel 集成。框架选型
成本控制日志是可观测性最贵的信号,分级保留 + 采样 + 字段裁剪是降本三大法宝。成本控制
Loki仅索引元数据的设计让成本远低于 Elasticsearch,适合结构化日志场景。Loki

参考#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

结构化日志
https://blog.souloss.com/posts/observability/structured-logging/
作者
Souloss
发布于
2025-07-16
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时