可观测性驱动架构:先设计怎么排障

0阅读11分钟

架构图没告诉你的事

我做过很多次线上复盘,发现一个重复出现的规律:问题不在代码写错了,而在出了问题之后,没人能解释清楚「到底发生了什么」。

架构图能说明系统应该如何工作,但线上问题会告诉你它实际如何工作。一个请求从网关进来到数据库返回,中间经过了鉴权、限流、业务逻辑、缓存、消息队列,任何一个环节变慢或出错,都可能让用户体验降级。如果这些环节没有埋点,排障就只能靠猜和临时加日志。

可观测性不是一个「上线之后再加」的功能,它应该在架构设计阶段就进入讨论。先想清楚怎么排障,再决定怎么实现。

可观测性的理论基础

Google 在 2016 年发布的《Site Reliability Engineering》一书中,把可观测性定义为「根据系统外部输出理解内部状态的能力」。这个定义和传统的监控(Monitoring)有本质区别。

监控是「对照已知指标判断系统是否正常」——CPU 超 80% 就告警、错误率超 1% 就报警。它适合已知的故障模式,但面对没见过的异常,预定义的阈值帮不上忙。

可观测性是「从外部信号反推内部状态」——即使从没见过的故障,只要信号足够丰富,工程师也能提出假设、验证、定位。

Honeycomb 的工程副总裁 Charity Majors 在多篇技术文章中强调:可观测性的核心不是「收集更多数据」,而是「能问出更多问题」。当线上出现一个从未见过的性能毛刺,你不需要重新发布代码加日志,而是直接从高维数据中切出新的维度来排查。

这种差异在实际工作中体现得很具体:

维度传统监控可观测性
目标确认系统是否正常理解系统为什么不正常
数据来源预定义指标日志 + 指标 + Trace 三类信号
排查方式看仪表盘是否越线自由组合维度探索
适用场景已知故障模式未知的未知
数据要求低基数聚合支持高基数(用户 ID、请求 ID 级别)
工具形态Grafana / Prometheus 仪表盘支持 ad-hoc 查询的可观测性平台

三类信号各有用途,不能互相替代,也不能只看其中一类。

信号类型记录什么典型用途局限
Metrics(指标)随时间变化的数值趋势监控、告警、容量规划没有上下文细节
Logs(日志)离散事件的文本记录调试、审计、错误诊断量大时难以检索,容易噪声过多
Traces(链路追踪)跨服务请求的调用链定位慢调用、分析跨服务依赖需要全链路埋点,采样策略影响完整性

三者的关系是:指标发现异常,Trace 定位问题节点,日志解释具体原因。排查一个接口变慢问题时,先看 P99 延迟是否升高(指标),再看慢请求的调用链停在哪个服务(Trace),最后看那个服务的日志里报了哪些错误(日志)。

案例一:支付回调处理延迟

场景

某电商系统的支付回调接口,正常 P99 延迟在 200ms 以内。上线三个月后,偶尔出现 P99 飙升到 5 秒的情况,但没有稳定复现规律。

翻车

团队最初的做法是只记录了接口总耗时和一个简单的成功/失败日志。排障时只能看到「某个时刻延迟很高」,但无法确定是数据库慢了、第三方接口超时了,还是内部逻辑阻塞了。

更麻烦的是,日志里没有请求级别的关联 ID。每次出问题,运维和开发要对着时间戳手动匹配多个服务的日志,往往花两三个小时才能拼出完整链路。

// ❌ 坏做法:没有 Trace 上下文,没有结构化字段
app.post('/payment/callback', async (req, res) => {
  const startTime = Date.now()
  try {
    const order = await db.orders.findById(req.body.orderId)
    await paymentService.verify(req.body)
    order.status = 'paid'
    await order.save()
    console.log('payment success')
    res.json({ ok: true })
  } catch (err) {
    console.log('payment failed', err.message)
    res.status(500).json({ ok: false })
  }
  // 只记录了总耗时,没有分步耗时
  console.log('duration:', Date.now() - startTime)
})

修复

引入 OpenTelemetry,为每个请求生成 Trace ID,自动传播到数据库调用和第三方 API 调用。每个 Span 记录分步耗时和状态。日志统一使用结构化格式,携带 traceId 和 spanId。

// ✅ 好做法:结构化日志 + Trace 上下文 + 分步耗时
import { trace } from '@opentelemetry/api'
 
app.post('/payment/callback', async (req, res) => {
  const tracer = trace.getTracer('payment-callback')
  const span = tracer.startSpan('payment.callback')
 
  try {
    // 分步记录 Span,每步都有独立耗时
    const order = await tracer.startActiveSpan('db.findOrder', async (childSpan) => {
      try {
        return await db.orders.findById(req.body.orderId)
      } finally {
        childSpan.end()
      }
    })
 
    await tracer.startActiveSpan('payment.verify', async (childSpan) => {
      await paymentService.verify(req.body)
      childSpan.end()
    })
 
    order.status = 'paid'
    await db.orders.save(order)
 
    // 结构化日志携带 traceId,可在可观测性平台关联
    logger.info('payment.success', {
      traceId: span.spanContext().traceId,
      orderId: order.id,
      amount: order.amount,
    })
 
    span.setStatus({ code: SpanStatusCode.OK })
    res.json({ ok: true })
  } catch (err) {
    // 记录错误细节到 Span,而不只是日志
    span.recordException(err)
    span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
    logger.error('payment.failed', {
      traceId: span.spanContext().traceId,
      error: err.message,
      orderId: req.body.orderId,
    })
    res.status(500).json({ ok: false })
  } finally {
    span.end()
  }
})

上线两周后,通过 Trace 视图发现:延迟飙升都发生在调用第三方银行验证接口时,该接口偶尔会超时 3 秒才返回。根因定位后,团队针对该接口增加了超时控制和熔断逻辑,问题不再复现。

案例二:订单状态不一致

场景

一个多服务架构的订单系统:订单服务创建订单 → 库存服务扣减库存 → 支付服务处理支付 → 通知服务发送确认。四个服务之间通过消息队列异步通信。

翻车

上线初期,团队对每个服务各自定义了日志格式:订单服务用 JSON、库存服务用纯文本、支付服务用 log4j 默认格式、通知服务直接写到 stdout。

某天用户投诉:支付了但没收到确认通知。排查时,团队需要分别登录四个系统,用不同的日志查询工具,手动按时间戳对齐。由于缺少统一的请求关联标识,排查了四个小时才确认是消息队列中某条消息丢失导致的。

// ❌ 坏做法:每个服务日志格式不同,无关联 ID
// 订单服务
console.log(JSON.stringify({ event: 'order.created', id: '12345' }))
 
// 库存服务
console.log(`Stock deducted for order 12345 at ${new Date().toISOString()}`)
 
// 支付服务
log4j.info('Payment processed: 12345')
 
// 通知服务 - 直接 stdout,没有日志收集
console.log('notification sent')

修复

引入统一的 correlation ID 机制。订单创建时生成全局唯一的 orderId,通过消息队列的 header 传播到所有下游服务。所有服务使用相同的结构化日志格式,包含 correlationId 字段。

// ✅ 好做法:统一结构化日志 + 全链路 correlationId
// 公共日志格式定义
interface StructuredLog {
  level: 'info' | 'warn' | 'error'
  service: string
  correlationId: string
  event: string
  timestamp: string
  data?: Record<string, unknown>
}
 
// 订单服务
logger.info({
  service: 'order-service',
  correlationId: orderId,
  event: 'order.created',
  timestamp: new Date().toISOString(),
  data: { userId: 'u-001', amount: 299 },
})
 
// 库存服务 - 从消息 header 取 correlationId
logger.info({
  service: 'inventory-service',
  correlationId: message.headers.correlationId,
  event: 'inventory.deducted',
  timestamp: new Date().toISOString(),
  data: { skuId: 'sku-100', quantity: 1 },
})
 
// 支付服务
logger.info({
  service: 'payment-service',
  correlationId: message.headers.correlationId,
  event: 'payment.completed',
  timestamp: new Date().toISOString(),
  data: { method: 'alipay', transactionId: 'txn-789' },
})
 
// 通知服务
logger.info({
  service: 'notification-service',
  correlationId: message.headers.correlationId,
  event: 'notification.sent',
  timestamp: new Date().toISOString(),
  data: { channel: 'sms', template: 'order-confirmed' },
})

在可观测性平台中按 correlationId 检索,可以看到一个订单从创建到通知发送的完整生命周期。消息丢失时,最后一个有记录的 event 会告诉你断在哪个环节。

案例三:缓存击穿导致服务雪崩

场景

一个内容平台的商品详情页,使用 Redis 做缓存。正常流量下缓存命中率 95%。大促期间,某个热门商品的缓存恰好过期,大量请求穿透到数据库。

翻车

团队的告警规则是基于 CPU 使用率和内存占用设置的。缓存击穿发生时,CPU 和内存没有立刻飙升(数据库连接池耗尽前有一段缓冲期),所以告警没有触发。等到发现系统变慢时,数据库连接池已经满了,整个商品服务不可用,连带影响其他依赖该服务的页面。

事后复盘发现,如果有一条关于「缓存未命中率」的告警,问题可以在 30 秒内被发现。

修复

补充业务指标层面的监控和告警,不只依赖基础设施指标。

// ❌ 坏做法:只监控基础设施指标
// 没有监控缓存命中率,问题发现太晚
 
// ✅ 好做法:缓存指标 + 熔断 + 降级
import { metrics } from '@opentelemetry/api'
 
const cacheHitCounter = metrics
  .getMeter('cache')
  .createCounter('cache.hits', { description: 'Cache hit count' })
const cacheMissCounter = metrics
  .getMeter('cache')
  .createCounter('cache.misses', { description: 'Cache miss count' })
 
async function getProductDetail(productId: string) {
  const cacheKey = `product:${productId}`
 
  // 1. 先查缓存
  const cached = await redis.get(cacheKey)
  if (cached) {
    cacheHitCounter.add(1, { resource: 'product-detail' })
    return JSON.parse(cached)
  }
 
  // 2. 缓存未命中,记录指标
  cacheMissCounter.add(1, { resource: 'product-detail' })
 
  // 3. 防止缓存击穿:互斥锁只让一个请求穿透
  const lockKey = `lock:${cacheKey}`
  const acquired = await redis.set(lockKey, '1', 'EX', 10, 'NX')
 
  if (!acquired) {
    // 没拿到锁,等一小段时间后重试缓存
    await new Promise((r) => setTimeout(r, 100))
    const retryCached = await redis.get(cacheKey)
    if (retryCached) return JSON.parse(retryCached)
 
    // 降级:返回简化版本,不查数据库
    return getSimplifiedProduct(productId)
  }
 
  try {
    const product = await db.products.findById(productId)
    await redis.setex(cacheKey, 300, JSON.stringify(product))
    return product
  } finally {
    await redis.del(lockKey)
  }
}

配套告警规则:

# 缓存未命中率告警
- alert: HighCacheMissRate
  expr: |
    rate(cache_misses_total{resource="product-detail"}[5m])
    / rate(cache_hits_total{resource="product-detail"}[5m])
    > 0.2
  for: 1m
  labels:
    severity: warning
  annotations:
    summary: "商品详情缓存未命中率超过 20%"
    action: "检查缓存是否大面积过期,考虑预热或增加 TTL"

信号协同:从发现到定位的完整链路

三类信号不是独立工作的,它们形成一个排查闭环。下面的流程图展示了一个典型的线上问题排查过程:

流程图画布 · 115%
Mermaid 流程图加载中...

这个过程中,指标负责「发现问题」,Trace 负责「定位环节」,日志负责「解释原因」。三者缺一不可。

排查阶段使用的信号典型问题
发现Metrics哪个指标异常?影响了多少用户?
定位Traces慢在哪个服务?哪个 Span 耗时最长?
诊断Logs具体报了什么错?入参是什么?
验证Metrics修复后指标是否恢复正常?

SLO 与错误预算:让可观测性对接业务目标

技术指标最终要服务于业务目标。SLO(Service Level Objective,服务等级目标)是连接技术指标和业务体验的桥梁。

Google SRE Workbook 中对 SLO 的定义是:「一组 SLI(Service Level Indicator)在特定时间窗口内需要满足的目标值或范围」。

以接口可用性为例:

SLI = 成功请求数 / 总请求数 × 100%
SLO = SLI ≥ 99.9%(按 30 天滚动窗口)
错误预算 = 1 - SLO = 0.1%(30 天内允许 43.2 分钟的不可用时间)

错误预算不只是个数字,它驱动团队的决策:

错误预算状态团队行为
预算充足(剩余 > 50%)正常发布新功能,快速迭代
预算告急(剩余 < 20%)减少新功能发布,优先修复稳定性问题
预算耗尽(剩余 = 0)冻结发布,全力恢复可靠性,事后复盘

Prometheus 中计算 SLI 的示例:

# 可用性 SLI:非 5xx 请求占比
sum(rate(http_requests_total{service="api", status!~"5.."}[30d]))
/
sum(rate(http_requests_total{service="api"}[30d]))
 
# P99 延迟 SLI
histogram_quantile(0.99,
  sum(rate(http_request_duration_seconds_bucket{service="api"}[30d])) by (le)
)

多窗口告警(Multi-Window Burn Rate Alerting)是 Google 推荐的告警模式,比简单的阈值告警更有效。它通过在不同时间窗口(1 小时、6 小时、24 小时)同时检查错误预算的消耗速率,减少误报:

# 1 小时窗口:短窗口高消耗速率,说明正在快速消耗预算
- alert: BurnRateHighShort
  expr: |
    sum(rate(http_requests_total{status=~"5.."}[1h]))
    / sum(rate(http_requests_total[1h]))
    > 14.4 * (1 - 0.999)
  for: 2m
 
# 24 小时窗口:长窗口持续消耗,说明问题不是瞬时的
- alert: BurnRateHighLong
  expr: |
    sum(rate(http_requests_total{status=~"5.."}[24h]))
    / sum(rate(http_requests_total[24h]))
    > 3 * (1 - 0.999)
  for: 5m

业务事件也要可见

技术指标之外,还有一类容易被忽略的信号:业务事件。

订单创建、支付成功、内容发布、任务完成、审核失败——这些事件本身不是技术指标,但它们能告诉你「系统是否按预期工作」。一个接口延迟正常、没有 5xx 错误的系统,可能因为消息队列积压导致用户支付成功但订单状态没更新。

业务事件应有稳定的命名规范和字段结构,不能只靠自由文本日志。

做法问题
console.log('订单创建了')没有结构化字段,无法统计和检索
logger.info(&#123; event: 'order.created', orderId, userId, amount &#125;)结构化、可检索、可统计

业务事件日志的推荐结构:

interface BusinessEvent {
  // 事件名称,使用 domain.action 格式
  event: string
  // 关联 ID,用于跨服务追踪
  correlationId: string
  // 事件时间
  timestamp: string
  // 业务数据
  data: {
    userId?: string
    orderId?: string
    amount?: number
    // 其他业务字段
  }
  // 事件结果
  outcome: 'success' | 'failure'
  // 失败原因(仅 failure 时)
  failureReason?: string
}
 
// 使用示例
function emitOrderEvent(order: Order, outcome: 'success' | 'failure', reason?: string) {
  logger.info({
    event: 'order.completed',
    correlationId: order.id,
    timestamp: new Date().toISOString(),
    data: {
      userId: order.userId,
      orderId: order.id,
      amount: order.totalAmount,
    },
    outcome,
    failureReason: reason,
  })
}

业务事件可以转化为可观测性指标。例如,统计 order.completed 事件中 outcome=failure 的比例,就能得到一个业务维度的 SLI。

告警要可行动

告警太多的后果是告警疲劳——值班人员对告警脱敏,真正严重的问题被淹没。可观测性的价值不只在于收集数据,还在于让正确的人在正确的时间收到正确的告警。

一条好告警应该满足三个条件:

  1. 指向用户影响:「支付接口错误率超过 5%」比「CPU 使用率 85%」更可行动
  2. 包含上下文:告警消息里带上受影响的接口、地域、时间段
  3. 有明确处理建议:值班人看到告警后知道第一步该做什么
告警类型示例是否可行动
症状告警P99 延迟从 200ms 升到 2s✅ 直接影响用户
原因告警数据库连接池使用率 95%⚠️ 需要进一步确认影响
噪声告警CPU 使用率 > 70% 持续 5 分钟❌ 太频繁,无明确处理动作
业务告警过去 10 分钟订单创建量下降 50%✅ 直接反映业务影响

告警路由也很关键:不同级别的告警应该通知到不同的人,走不同的渠道。

# 告警路由示例
routes:
  # P0:直接影响用户,需要立即响应
  - match:
      severity: critical
    receiver: 'pagerduty-oncall'
    repeat_interval: 5m
 
  # P1:影响部分用户,需要 30 分钟内响应
  - match:
      severity: warning
    receiver: 'slack-engineering'
    repeat_interval: 30m
 
  # P2:潜在风险,工作时间处理
  - match:
      severity: info
    receiver: 'slack-engineering'
    group_wait: 1h

OpenTelemetry:统一的遥测标准

OpenTelemetry(OTel)是 CNCF 的开源可观测性标准,已经逐渐成为事实上的行业标准。它提供了统一的 API 和 SDK 来生成、收集和导出 Metrics、Logs、Traces 三类信号,并且厂商无关——采集到的数据可以发送到任何兼容的后端(Jaeger、Zipkin、Prometheus、Grafana Tempo 等)。

采用 OpenTelemetry 的核心收益是避免厂商锁定,同时统一三类信号的采集标准。

// 初始化 OpenTelemetry SDK
import { NodeSDK } from '@opentelemetry/sdk-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
 
const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: 'http://otel-collector:4318/v1/traces',
  }),
  metricReader: new OTLPMetricExporter({
    url: 'http://otel-collector:4318/v1/metrics',
  }),
  instrumentations: [getNodeAutoInstrumentations()],
})
 
sdk.start()

OTel Collector 作为遥测数据的中转层,负责接收、处理和转发数据到各个后端:

应用程序 (OTel SDK)
    │
    ▼
OTel Collector (接收 → 处理 → 路由)
    │
    ├──▶ Jaeger / Tempo (Traces)
    ├──▶ Prometheus / Mimir (Metrics)
    └──▶ Loki / Elasticsearch (Logs)

可观测性驱动架构检查清单

以下清单按软件生命周期的不同阶段分组,可以在架构评审或上线前检查时使用。

设计阶段

  • 为每个服务定义 SLO 和 SLI,并记录在案
  • 确定关键业务事件清单(订单创建、支付完成等),设计统一的事件结构
  • 规划 Trace 传播方案,确定跨服务的关联 ID 生成和传递方式
  • 确定日志格式规范(结构化 JSON、必填字段、级别定义)

实现阶段

  • 集成 OpenTelemetry SDK,确保 Traces、Metrics、Logs 三类信号统一采集
  • 所有日志使用结构化格式,包含 traceId、spanId、correlationId 字段
  • 关键业务操作埋点,记录业务事件日志
  • 数据库查询、外部 API 调用、消息队列收发均有独立 Span 记录
  • 错误信息同时写入 Span(span.recordException)和日志

部署阶段

  • 配置 OTel Collector,确保数据能正确路由到各个后端
  • 仪表盘覆盖所有 SLO 对应的 SLI
  • 告警规则按严重级别分级,配置不同的通知渠道和响应 SLA
  • 告警消息包含上下文信息和处理建议

运行阶段

  • 定期审查告警有效性,关闭或调整持续误报的告警规则
  • 事后复盘时,确认已有的可观测性数据是否足以定位问题,不足则补充埋点
  • 错误预算使用情况定期回顾,驱动发布节奏决策
  • 采样策略定期评估,在数据完整性和存储成本之间取得平衡

参考资料

  1. Google SRE Book - Site Reliability Engineering: How Google Runs Production Systems
  2. Google SRE Workbook - Implementing SLOs
  3. Honeycomb - What is Observability? Key Components and Best Practices
  4. Elastic - The 3 Pillars of Observability: Unified Logs, Metrics, and Traces
  5. OpenTelemetry Official - OpenTelemetry Documentation
  6. Microsoft Azure Well-Architected Framework - Observability
  7. Charity Majors (Honeycomb) - Observability vs. Monitoring

评论 0

0 / 1000