可观测性驱动架构:先设计怎么排障
架构图没告诉你的事
我做过很多次线上复盘,发现一个重复出现的规律:问题不在代码写错了,而在出了问题之后,没人能解释清楚「到底发生了什么」。
架构图能说明系统应该如何工作,但线上问题会告诉你它实际如何工作。一个请求从网关进来到数据库返回,中间经过了鉴权、限流、业务逻辑、缓存、消息队列,任何一个环节变慢或出错,都可能让用户体验降级。如果这些环节没有埋点,排障就只能靠猜和临时加日志。
可观测性不是一个「上线之后再加」的功能,它应该在架构设计阶段就进入讨论。先想清楚怎么排障,再决定怎么实现。
可观测性的理论基础
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"信号协同:从发现到定位的完整链路
三类信号不是独立工作的,它们形成一个排查闭环。下面的流程图展示了一个典型的线上问题排查过程:
这个过程中,指标负责「发现问题」,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({ event: 'order.created', orderId, userId, amount }) | 结构化、可检索、可统计 |
业务事件日志的推荐结构:
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。
告警要可行动
告警太多的后果是告警疲劳——值班人员对告警脱敏,真正严重的问题被淹没。可观测性的价值不只在于收集数据,还在于让正确的人在正确的时间收到正确的告警。
一条好告警应该满足三个条件:
- 指向用户影响:「支付接口错误率超过 5%」比「CPU 使用率 85%」更可行动
- 包含上下文:告警消息里带上受影响的接口、地域、时间段
- 有明确处理建议:值班人看到告警后知道第一步该做什么
| 告警类型 | 示例 | 是否可行动 |
|---|---|---|
| 症状告警 | 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: 1hOpenTelemetry:统一的遥测标准
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
- 告警消息包含上下文信息和处理建议
运行阶段
- 定期审查告警有效性,关闭或调整持续误报的告警规则
- 事后复盘时,确认已有的可观测性数据是否足以定位问题,不足则补充埋点
- 错误预算使用情况定期回顾,驱动发布节奏决策
- 采样策略定期评估,在数据完整性和存储成本之间取得平衡
参考资料
- Google SRE Book - Site Reliability Engineering: How Google Runs Production Systems
- Google SRE Workbook - Implementing SLOs
- Honeycomb - What is Observability? Key Components and Best Practices
- Elastic - The 3 Pillars of Observability: Unified Logs, Metrics, and Traces
- OpenTelemetry Official - OpenTelemetry Documentation
- Microsoft Azure Well-Architected Framework - Observability
- Charity Majors (Honeycomb) - Observability vs. Monitoring