Skip to content

필사 모드: 结构化日志设计 — 做出故障时真正派得上用场的日志

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 当你在故障正中间没法 grep 日志时

凌晨收到告警,打开日志。这是一个要穿过三个服务的请求,而每个服务的日志格式都不一样。一边是本地时间,一边是 UTC。没有可以标识请求的公共键,于是只能用眼睛把时间戳相近的行两两配对。好不容易找到一行「user 8812 checkout failed after 4102ms」,可要数清这样的用户有多少,又得重新写一条正则。

这种局面的原因不是日志太少。恰恰相反,日志每天都在以数亿行的速度堆积。问题在于这些日志是按照「人一行一行读」的前提设计的。而事故响应需要的不是阅读,是过滤和聚合。

本文要讲的就是改变这个前提的设计。

给人看的日志和给机器看的日志是两样东西

本地开发环境的日志是给人看的。有颜色,是句子,按顺序滚下去就是一个故事。生产环境的日志是给机器看的。它们会被过滤、被聚合、被关联。想用一种格式同时满足两种需求,正是大多数日志被毁掉的原因。

正解是把同一条日志用不同方式输出。生产环境输出一行 JSON,本地渲染成 pretty 格式。而且有一条决定性的规则。

// 差: 值被熔进了消息字符串里
logger.info(`user ${userId} checkout failed after ${ms}ms: ${err.message}`)
// -> 要数同一类事件就得写正则,而 err.message 一变正则就废了

// 好: 消息是常量,会变的值全部作为字段
logger.error(
  {
    event: 'checkout.failed',
    user_id: userId,
    order_id: orderId,
    duration_ms: ms,
    payment_provider: 'tosspay',
    err: { type: err.name, code: err.code, message: err.message },
  },
  'checkout failed'
)

消息字符串要保持为常量。只有这样才能用一个键去数同一类事件。变化的值一旦进入消息内部,这条日志就变成了句子,从可聚合的范畴里掉队。

单单守住这一条规则,就能立刻回答下面这些问题。这次失败是从几分钟前开始的,是不是集中在某个支付服务商上,是不是只发生在某个租户身上。如果日志是以句子形式留下的,这些问题全都需要重新解析一遍。

event 字段值得单独强调。把用点分隔的领域事件名作为稳定标识符放进去,它在日志后端就直接成了一个计数器。消息文本是给人的,event 是给机器的。

每一行日志都必须具备的字段

字段不需要多。缺了就会让排查卡住的字段是固定的那几个。

字段示例值缺了就做不了的事
timestamp2026-07-26T04:12:07.481Z跨服务按时间排序
levelerror按严重度过滤、接入告警
service.namecheckout-api识别是哪个服务
service.version1.42.3确认发布与故障的相关性
deployment.environmentprod去掉预发环境的噪声
trace_id4bf92f3577b34da6a3ce929d0e0e4736在追踪与日志之间互跳
request_id01J3Q7K8Z2M4F5N6P7R8S9T0V1汇集单次请求的全部日志
tenant_idacme-corp估算影响范围
duration_ms4102过滤慢请求
eventcheckout.failed按事件维度聚合

时间戳要单独说一句。一定要用带偏移量的 RFC 3339 格式,并且尽量用 UTC。不带偏移量的本地时间会造成这样的事。

# payment 服务留的是不带偏移量的 KST,checkout 服务留的是 UTC
grep 'payment accepted' payment.log | tail -1
# 2026-07-26T13:02:11.004 payment accepted order_id=A-99183

grep 'checkout completed' checkout.log | tail -1
# 2026-07-26T04:02:11.118Z checkout completed order_id=A-99183

# 明明是同一件事,却看起来差了 9 小时。没有办法把两份日志排在同一个界面里。

以 pino 为例,在基础配置里这样固定下来。

import pino from 'pino'

export const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  timestamp: pino.stdTimeFunctions.isoTime, // 带偏移量的 ISO 8601
  formatters: {
    level: (label) => ({ level: label }), // 用 "info" 而不是 30
  },
  base: {
    'service.name': process.env.OTEL_SERVICE_NAME,
    'service.version': process.env.SERVICE_VERSION,
    'deployment.environment': process.env.APP_ENV,
  },
})

放进 base 的值会自动附加到每一行上。资源级别的字段不要在各个调用点重复写。一旦重复,就一定会有地方漏掉。

真正能区分日志级别的唯一标准

级别的定义在任何文档里都有,但每个团队实际的用法都不一样。把判断标准固定成一条,争论就结束了。

标准是这条。ERROR 意味着我们的系统没有尽到自己的责任。如果是用户操作有误、是预期之内的失败、或者重试后已经恢复,那就不是 ERROR。

  • ERROR:因为我们这边的缺陷而没能处理请求。累积起来应该要触发呼叫。例:依赖服务超时后重试耗尽、反序列化失败、连接池耗尽。
  • WARN:处理成功了但发生了劣化。现在不需要动手,但趋势要盯着。例:重试后成功、使用了降级响应、缓存未命中率骤升、即将过期的证书。
  • INFO:状态发生了变化的事件。只留下之后复盘时需要的事实。例:订单创建、发布完成、配置重载。
  • DEBUG:给开发者的叙述。生产环境默认关闭,并且必须能针对特定请求或特定租户动态打开。

这里最常出错的地方是 400 系列的响应。请求体不合法、令牌过期、没有权限,这些都是我们的系统正常运作的结果。一旦把它们打成 ERROR,ERROR 日志的绝大部分就会被正常流量填满,真正的缺陷被埋在里面。客户端错误用 INFO 或 WARN 记录,比例骤升则交给指标来监控。

// 错误处理器: 按责任归属划分级别
app.use((err, req, res, next) => {
  const status = err.status ?? 500
  const fields = {
    event: 'http.request.failed',
    http_status: status,
    route: req.route?.path,
    err: { type: err.name, code: err.code, message: err.message },
  }

  if (status >= 500) {
    req.log.error(fields, 'request failed') // 我们的责任
  } else if (status === 429) {
    req.log.warn(fields, 'request throttled') // 劣化
  } else {
    req.log.info(fields, 'request rejected') // 客户端的责任
  }

  res.status(status).json({ error: err.publicMessage ?? 'request failed' })
})

级别策略是否真正落地,用一个指标来确认。每出现一条 ERROR 日志,是否值得有人去看一眼。如果不值得,那条日志就不是 ERROR。

把关联 ID 传播到服务边界之外

只在一个服务内部有效的请求 ID 只算半个。它真正体现价值的地方,是跨越服务边界的时候。

标准是 W3C Trace Context,请求头是 traceparent。格式固定为四段。

curl -sD - -o /dev/null https://checkout.internal/v1/orders \
  -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
  -H 'tracestate: acme=t61rcWkgMzE'

# 00                                 版本
# 4bf92f3577b34da6a3ce929d0e0e4736   追踪 ID (16 字节,整个请求中保持一致)
# 00f067aa0ba902b7                   父跨度 ID (8 字节,每一跳调用都不同)
# 01                                 标志位 (最后一位为 1 表示已被采样)

要留意最后那个标志位。01 表示这条追踪会被保存。00 表示会被丢弃。把这个值一并写进日志,日后遇到「点了追踪链接却什么都没有」的情况,就能立刻知道原因。

在应用侧,关键是让日志器自动附上追踪上下文。让调用点每次自己传,就一定会漏。

import { context, trace } from '@opentelemetry/api'
import { AsyncLocalStorage } from 'node:async_hooks'

const requestStore = new AsyncLocalStorage()

// 在请求入口处只植入一次上下文
export function requestContext(req, res, next) {
  const requestId = req.headers['x-request-id'] ?? crypto.randomUUID()
  res.setHeader('x-request-id', requestId)
  requestStore.run({ requestId, tenantId: req.auth?.tenantId }, () => next())
}

// 所有日志调用都会经过的薄封装
export function ctx(fields = {}) {
  const store = requestStore.getStore() ?? {}
  const span = trace.getSpan(context.active())
  const sc = span?.spanContext()
  return {
    ...fields,
    request_id: store.requestId,
    tenant_id: store.tenantId,
    trace_id: sc?.traceId,
    span_id: sc?.spanId,
    trace_sampled: sc ? Boolean(sc.traceFlags & 1) : undefined,
  }
}

logger.error(ctx({ event: 'checkout.failed', order_id: orderId }), 'checkout failed')

传播真正会断掉的地方有三处。第一,使用自研 HTTP 客户端的代码路径 — 自动埋点包不住,请求头就附不上去。第二,消息队列 — 不直接注入到消息头里,生产者与消费者的追踪就断了。第三,批处理作业与定时任务 — 不在入口处开启新的追踪,日志里的 trace_id 就完全是空的。

基数与成本 — 什么东西绝对不能放进去

日志不是免费的。先从算账开始。

# 每行平均 1.2KB,每秒 4,000 行
python3 -c "print(f'{1.2*1024*4000*86400/1e9:.1f} GB/day')"
# 424.7 GB/day

# 假设保留 30 天,含索引的单价按每 GB 2.5 个货币单位计算
python3 -c "print(f'{424.7*30*2.5:,.0f} per month')"
# 31,853 per month

这里要削减的对象不是行数,而是每行的大小和被索引的字段。下面这些不要放进日志。

  • 个人信息:完整邮箱、电话号码、住址、出生日期、身份证号。有需要时用哈希或内部 ID 代替。
  • 密钥:Authorization 请求头、Cookie、API 密钥、卡号、刷新令牌。这些值一旦进入日志,就会同时被复制到备份、索引和冷存储里。
  • 大体积载荷:完整的请求与响应体、反复输出的堆栈跟踪、序列化后的对象转储。请求体只留大小和哈希,确有需要就放到单独的存储里并设置很短的 TTL。
  • 没有意义的唯一值:往日志后端会索引的字段里无限制地塞 UUID,索引就会比数据本身还大。要区分「需要索引的字段」和「只需存储的字段」。

脱敏原则上要在应用内部完成。在采集管道里删除的做法,是在数据已经离开进程之后才动手,会让泄露路径变多。

export const logger = pino({
  redact: {
    paths: [
      'req.headers.authorization',
      'req.headers.cookie',
      'req.headers["x-api-key"]',
      'req.body.password',
      'req.body.card_number',
      '*.email',
      '*.phone',
      'user.ssn',
    ],
    censor: '[REDACTED]',
  },
})

采样是压缩体量的最后手段,但做法很关键。常见的建议「只保留 10% 的 INFO 日志」,如果按行随机实现,结果是最糟的。一次请求的日志被切得七零八落,没有任何一次请求拥有完整的故事。

采样要按请求而不是按行来决定。用 trace_id 做哈希判定,同一次请求的所有日志就会一起留下或一起丢弃。

const KEEP_RATIO = 0.05

// 用 trace_id 做确定性判定 — 一次请求的日志要么全留要么全丢
function keepLowSeverity(traceId) {
  if (!traceId) return true
  const bucket = parseInt(traceId.slice(-4), 16) / 0xffff
  return bucket < KEEP_RATIO
}

export function emit(level, fields, msg) {
  const enriched = ctx(fields)
  const alwaysKeep = level === 'error' || level === 'warn' || enriched.trace_sampled
  if (!alwaysKeep && !keepLowSeverity(enriched.trace_id)) return
  logger[level](enriched, msg)
}

ERROR 和 WARN 绝对不做采样。另外,追踪被采样的那些请求,其日志要一并保留。只有这样,从追踪跳到日志时才不会看到一片空白。

日志、指标与追踪,各自什么时候看

三种信号不是彼此的替代品,而是一条排查顺序。三样都开着却排查很慢的团队,通常就是没有顺序。

  • 指标在最前。它回答从什么时候开始出问题、影响范围有多大。它便宜、常开、基数低,因此是唯一能作为告警依据的信号。
  • 追踪在其次。它打开一次变慢的请求,回答时间是花在哪个服务、哪个区间。要用指标给出的时间段和路由把范围收窄之后再进去。
  • 日志在最后。它回答那个跨度里到底发生了什么,收到了什么值、走了哪个分支。用 trace_id 过滤后进去,要读的行数就降到几十行。

要让这个顺序运转,信号之间需要连接的桥。从指标到追踪的桥是 exemplar。把 trace_id 附在直方图观测上,就能点击图上跳起来的那个点,直接打开那条追踪。

# 只要挂上了 exemplar,就能从这张图的点上直接跳到追踪
histogram_quantile(
  0.99,
  sum by (le, route) (rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)

从追踪到日志的桥,是前面做好的 trace_id 字段。从日志到指标的桥,是 event 字段。三座桥都搭起来,排查时间就从几十分钟量级降到几分钟量级。少了任何一座,从那个点开始又要靠眼睛去配对了。

结语 — 日志不是拿来读的,是拿来筛的

要记住的只有一句话。生产日志的目的不是被阅读,而是被筛选。

所有规则都从这个目的推导出来。消息保持为常量,把值抽成字段。每一行都放上 trace_id、service.name 和带偏移量的时间戳。ERROR 只在是我们的责任时才用。敏感值在离开进程之前就抹掉。采样按请求来做。

如果眼下只能改一件事,就从把变量从消息字符串里抽出来开始。仅这一个改动,就能把只有正则才回答得了的问题,立刻变成可以聚合的问题。

值得继续深入的资料。

현재 단락 (1/167)

凌晨收到告警,打开日志。这是一个要穿过三个服务的请求,而每个服务的日志格式都不一样。一边是本地时间,一边是 UTC。没有可以标识请求的公共键,于是只能用眼睛把时间戳相近的行两两配对。好不容易找到一行「...

작성 글자: 0원문 글자: 7,066작성 단락: 0/167