日志是分布式系统中最基础的可观测性手段。一份设计良好的日志规范,不仅能在故障排查时节省数小时甚至数天的时间,还能成为系统行为审计、性能分析和业务洞察的数据源。本文从级别定义、格式标准、输出策略、内容规范、记录位置、使用模式和工具链七个维度,建立一套可在团队层面统一执行的日志规范。
在同一个项目乃至公司范围内,日志级别的选取必须遵循统一的规则。级别的划分依据应当是客观的关注层次,而非工程师对某条日志"重不重要"的主观判断。
| 级别 | 关注层次 | 典型场景 | 体量要求 |
|---|---|---|---|
| ERROR | 需要立即响应的异常 | 支付失败、数据库连接断开、核心服务不可用 | 极少,每条都应被处理 |
| WARN | 不需要立即响应的异常 | 降级服务触发、限流生效、非关键依赖超时 | 少量,每日百条级别 |
| INFO | 关键决策与状态变更 | 订单状态流转、配置加载完成、服务启停 | 中等,可承受每日万条 |
| DEBUG | 执行过程的参考信息 | 方法入参详情、循环内部状态、SQL 执行计划 | 大量,仅开发/测试环境开启 |
| TRACE | 最细粒度的追踪信息 | 每次 RPC 调用的完整请求/响应体 | 极大量,按需临时开启 |
⚠️ 常见误区:以"异常本身的重要性"来决定级别。异常是否重要是主观的,而异常是否需要立即响应是客观的。作为级别设定,应当以客观因素为基准,否则不利于团队统一理解。
严格红线:要么处理异常,要么打 ERROR。严格避免既不处理异常,然后打 INFO 或根本不打日志的情况。这种"静默失败"是生产环境中最难排查的问题之一。
日志级别的选择发生在两个不同的环节,必须清晰区分:
| 环节 | 决策依据 | 示例 |
|---|---|---|
| 记录级别 | 事情本身的性质 | IO 异常的本质是 WARN 或 ERROR |
| 输出级别 | 事情发生的环境和上下文 | 应用层库已处理好 IO 异常,无需输出 WARN |
关键原则:应当避免"因为不想输出出来,就去调整记录日志的级别"的情况。
// ❌ 错误示范:为了控制输出而篡改记录级别
if (env == "prod") {
log.error("Database timeout") // 生产环境才记 ERROR
} else {
log.info("Database timeout") // 其它环境降级为 INFO
}
// ✅ 正确做法:记录级别始终反映事件本质
log.warn("Database timeout after ${retryCount} retries")
// 输出级别通过配置控制,而非代码分支
恰恰是因为日志的输出级别常常需要结合上层服务的上下文,所以并不适合在公共的库中进行统一的控制。 需要每个服务结合自身的性质和环境,合理地设定日志输出的级别。公共库应当忠实地记录事件本质,由调用方决定哪些信息需要被看见。
不同环境中的日志记录级别应当保持一致,以保持环境之间行为的一致性(本地开发环境可除外)。
禁止在代码中以环境决定记录级别:
/** ❌ DON'T DO THIS **/
if (env == "prod") {
log.error("Critical failure")
} else {
log.info("Critical failure")
}
尽量避免以环境决定输出级别的配置方式:
# ❌ 不推荐:不同环境使用截然不同的输出级别
logging:
level:
root: INFO
---
spring:
profiles: prod
logging:
level:
root: WARN # 生产环境只输出 WARN 以上?你会错过大量关键信息
推荐做法:各环境保持相同的输出级别基线,通过包/类级别的细粒度控制来调整,而非全局一刀切。
# ✅ 推荐:各环境保持一致的基线,细粒度调整
logging:
level:
root: INFO
com.yourcompany.payment: DEBUG # 支付核心模块始终详细
com.yourcompany.cache: WARN # 缓存模块只关注异常
使用空格或固定分隔符分隔不同信息要素的纯文本格式,一般每条日志独占一行(考虑到异常堆栈和数据信息本身可能存在换行)。
优点:直接可读性好,适合 tail -f 实时查看,适合小型项目和简单场景。
缺点:不便于后期的结构化数据处理与分析,正则解析成本高且容易出错。
2024-01-15T08:23:45.123Z INFO [payment-service] [worker-3] OrderService - Order 12345 created for user 67890, amount=99.99, currency=CNY
使用 JSON 格式化的日志可以承载更多结构化信息,数据解析质量更高,与 ELK/Loki 等日志平台天然兼容。
优点:
缺点:直接可读性略差,需要配合 jq 等工具查看。
{
"timestamp": "2024-01-15T08:23:45.123Z",
"level": "INFO",
"service": "payment-service",
"thread": "worker-3",
"logger": "com.yourcompany.payment.OrderService",
"message": "Order created",
"context": {
"orderId": "12345",
"userId": "67890",
"amount": 99.99,
"currency": "CNY",
"paymentMethod": "wechat_pay"
},
"traceId": "abc123def456",
"spanId": "span789"
}
在微服务体系下,日志格式的统一性至关重要。格式不一致会给后续的数据分析和处理带来巨大困难:
userId vs user_id vs uid)ERROR vs error vs Error)建议:在组织层面定义统一的日志 Schema,所有服务通过共享的日志库或配置来强制执行。
日志输出的方式直接决定了日志采集的方式。需要根据系统架构选取合适的组合:
| 输出方式 | 采集方式 | 适用场景 | 优缺点 |
|---|---|---|---|
| 标准输出 (stdout/stderr) | 容器运行时采集(Docker logging driver、Kubernetes log collector) | 容器化微服务 | 符合 12-Factors,无文件管理负担 |
| 本地文件 | Filebeat、Fluentd、Vector 等日志代理 | 传统虚拟机部署 | 需要管理磁盘空间、日志轮转 |
| 远程直接推送 | 直接写入 Kafka、Redis、日志服务 | 高吞吐场景 | 增加应用复杂度,需处理网络故障 |
| 结构化事件流 | OpenTelemetry Collector | 云原生可观测体系 | 与 Metrics、Traces 统一管道 |
根据 12-Factors App - Logs 原则,在虚拟化的微服务架构体系下,建议将日志输出到标准输出,而非文件中。
A twelve-factor app never concerns itself with routing or storage of its output stream. It should not attempt to write to or manage logfiles. Instead, each running process writes its event stream, unbuffered, to stdout.
这一原则的核心价值在于:
即使输出到 stdout,在日志采集和存储层面仍需考虑:
服务端日志应当包含以下基础信息,缺一不可:
| 要素 | 说明 | 示例 |
|---|---|---|
| 时间戳 | 精确到至少毫秒,建议全部使用 UTC | 2024-01-15T08:23:45.123Z |
| 日志级别 | 大写标准化字符串 | INFO, ERROR |
| 执行线程 | 线程名及可选的线程 ID | worker-3, http-nio-8080-exec-5 |
| 输出主体 | 一般是类名或 logger 名 | com.yourcompany.OrderService |
| 日志消息体 | 事件描述,见下文文体规范 | Order 12345 created successfully |
| Trace ID | 分布式追踪标识,跨服务关联 | abc123def456 |
| Span ID | 当前操作单元标识 | span789 |
时间戳应当是事件的发生时间,而非日志被记录的时间。在异步或批处理场景下,这两者可能有显著差异。
日志消息体由软件工程师编写,建议遵循以下原则:
// ✅ 推荐
Order 12345 created for user 67890
Payment processed: transaction_id=tx_abc, amount=99.99
// ❌ 避免
Creating order... // 进行时,不确定是否完成
Order create // 语法不完整
// ✅ 推荐
Failed to connect to database 'primary_db' after 3 retries, last error: connection timeout (3000ms)
// ❌ 避免
db error // 信息量不足
cannot connect // 缺少上下文
// ✅ 推荐:包含关键上下文
Refund request rejected: order_id=12345, reason="order_not_found", requested_by=user_67890, request_id=req_abc
// ❌ 避免:缺少可操作的上下文
Refund failed
// ✅ 推荐:错误 + 上下文 + 建议
Database connection pool exhausted: max_connections=50, active=50, waiting=12. Consider increasing pool size or investigating connection leaks.
// ❌ 避免:仅陈述事实
Connection pool exhausted
日志中严禁出现以下敏感信息:
// ❌ 严重违规:记录敏感信息
log.info("User login: phone=${user.phone}, password=${user.password}")
// ✅ 正确处理:脱敏或记录标识符
log.info("User login success: user_id=${user.id}, login_method=phone")
log.debug("Login request details: phone=${maskPhone(user.phone)}")
分支表达某种决策,关键决策的结果应当输出到日志中:
// ✅ 记录关键决策
if (user.balance >= order.amount) {
log.info("Payment authorized: user_id=${user.id}, order_id=${order.id}, amount=${order.amount}")
processPayment()
} else {
log.warn("Payment declined: user_id=${user.id}, order_id=${order.id}, reason=insufficient_balance, required=${order.amount}, available=${user.balance}")
throw InsufficientBalanceException()
}
所有方法的入口及代码过程中的 return 及 break 语句,如果涉及到"事件做与不做"的问题,应当记录成日志。
fun processRefund(request: RefundRequest): RefundResult {
log.info("Refund processing started: request_id=${request.id}, order_id=${request.orderId}, amount=${request.amount}")
// ... 业务逻辑 ...
if (!eligible) {
log.warn("Refund rejected: request_id=${request.id}, reason=not_eligible, policy=${violatedPolicy}")
return RefundResult.Rejected(violatedPolicy)
}
val result = executeRefund(request)
log.info("Refund completed: request_id=${request.id}, transaction_id=${result.transactionId}, status=${result.status}")
return result
}
所有捕获的异常,如果没有做业务层面的处置,则必须输出成错误日志;如果有做处置,也应当作为关键信息记录。
try {
paymentGateway.charge(request)
} catch (e: PaymentException) {
// 做了业务处置:重试或降级
log.warn("Payment gateway error, falling back to backup gateway: order_id=${order.id}, error=${e.message}", e)
fallbackGateway.charge(request)
}
try {
database.save(order)
} catch (e: SQLException) {
// 未做业务处置:必须记录 ERROR
log.error("Failed to save order to database: order_id=${order.id}, sql_state=${e.sqlState}", e)
throw OrderPersistenceException("Database error", e)
}
并非所有方法都需要记录日志。避免在以下场景过度记录:
过度记录会导致:日志噪声、存储成本上升、关键信息被淹没、性能开销。
| 反模式 | 说明 | 风险 |
|---|---|---|
| 传递 Logger 实例 | 除特殊类型的库或插件,不建议把 logger 作为方法参数传递 | 污染 API 签名,增加耦合 |
| 共享 Logger | 在不同的类中使用同一个 logger 类 | 无法区分日志来源,定位困难 |
| 全量序列化 POJO | 除非有实际的必要,不要直接将整个 POJO 类序列化输出 | 日志膨胀,可能泄露敏感字段 |
| 字符串拼接日志 | 使用 log.info("User " + userId + " logged in") |
即使日志级别不满足也会执行拼接 |
| 日志逻辑耦合业务 | 在业务代码中嵌入复杂的日志格式化逻辑 | 污染业务代码,增加复杂度 |
// ❌ 禁止:字符串拼接(即使 DEBUG 未开启也会执行)
log.debug("Processing order: " + order.toString() + " with items: " + items.size())
// ✅ 正确:使用占位符(只有 DEBUG 开启时才计算)
log.debug("Processing order: {}, items: {}", order.id, items.size())
| 模式 | 说明 | 示例 |
|---|---|---|
| 使用日志抽象层 | 如 SLF4J、Log4j2 API,不与具体实现绑定 | import org.slf4j.LoggerFactory |
| 私有静态 Logger | 将 logger 声明为私有静态成员 |
private static final Logger log = LoggerFactory.getLogger(Xxx.class) |
| 控制消息体大小 | 避免单条日志过大(建议 < 10KB) | 大数据对象记录摘要或 ID |
| 结构化上下文 | 使用 MDC (Mapped Diagnostic Context) 传递 Trace ID 等 | MDC.put("traceId", traceId) |
| 异步日志 | 高吞吐场景使用异步 Appender | Log4j2 AsyncLogger |
import org.slf4j.LoggerFactory
import org.slf4j.MDC
class OrderService {
// ✅ 私有静态 Logger
private val log = LoggerFactory.getLogger(OrderService::class.java)
fun processOrder(request: OrderRequest) {
// ✅ 使用 MDC 传递追踪上下文
MDC.put("traceId", request.traceId)
MDC.put("orderId", request.orderId)
try {
log.info("Order processing started: amount={}, currency={}", request.amount, request.currency)
// ... 业务逻辑 ...
} finally {
// ✅ 清理 MDC,避免线程池场景下的上下文泄漏
MDC.clear()
}
}
}
日志和监控(Metrics)是不同的可观测性手段,应当合理分工:
| 场景 | 使用日志 | 使用 Metrics |
|---|---|---|
| 单笔交易详情 | ✅ | ❌ |
| 错误堆栈 | ✅ | ❌ |
| 请求量统计 | ❌(可辅助) | ✅ |
| 延迟分布 | ❌(可辅助) | ✅(Histogram) |
| 系统资源使用 | ❌ | ✅ |
| 业务事件审计 | ✅ | ❌ |
原则:Metrics 用于聚合和告警,日志用于详情和诊断。两者结合,而非互相替代。
| 组件 | 推荐选择 | 说明 |
|---|---|---|
| 日志抽象层 | SLF4J | 业界标准,实现可替换 |
| 日志实现 | Log4j2 或 Logback | Log4j2 性能更优,支持异步 |
| 结构化日志 | Logstash Logback Encoder | 直接输出 JSON |
| 分布式追踪 | OpenTelemetry + MDC | 统一 Trace ID 传递 |
Logback JSON 配置示例:
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeContext>true</includeContext>
<includeMdc>true</includeMdc>
<customFields>{"service":"payment-service","version":"1.2.3"}</customFields>
</encoder>
</appender>
| 工具 | 用途 | 特点 |
|---|---|---|
| Fluentd / Fluent Bit | 日志采集和转发 | 轻量、插件丰富、CNCF 项目 |
| Vector | 日志采集、转换、路由 | Rust 编写、性能极高、统一管道 |
| ELK Stack | 日志存储和分析 | Elasticsearch + Logstash + Kibana |
| Grafana Loki | 日志聚合 | 与 Prometheus 生态集成、成本更低 |
| ClickHouse | 日志分析 | 列式存储、聚合查询性能极高 |
高效的日志查询需要遵循一定的规范:
# Loki 查询示例
# 查找特定服务的 ERROR 日志
{service="payment-service"} |= "ERROR" | json | line_format "{{.message}}"
# 查找特定订单的所有日志
{service=~"payment-service|order-service"} |= "order_id=12345"
# 统计每分钟 ERROR 数量
sum(rate({service="payment-service"} |= "ERROR" [1m]))
在代码评审和系统设计中,使用以下清单检查日志规范执行情况: