可观测性与插桩 是做什么的?
概述
你无法观测的代码,就是无法运维的代码。可观测性是从外部,利用代码发射的遥测数据来回答“系统在做什么以及为什么”的能力。插桩不是上线后的附加项——它应该与特性开发同步完成,就像编写测试一样。如果一个特性在上线时没有遥测数据,那么用户报告的第一个 Bug 就会变成考古学,而不是一次数据库查询。
何时使用
- 构建任何将在生产环境中运行的特性
- 添加新的服务、端点、后台任务或外部集成
- 生产事件花费了太长时间才诊断清楚——“我们不清楚发生了什么”
- 设置或审查告警规则
- 评审涉及 I/O、重试、队列或跨服务调用的 PR
不适用于:
- 诊断正在发生的故障——请使用
debugging-and-error-recovery技能(可观测性正是为了让该技能下次更快工作而存在的) - 对已测量的缓慢问题进行性能分析和优化——请使用
performance-optimization技能 - 发布日的监控检查清单和回滚触发器——请参阅
shipping-and-launch技能;本技能涵盖的是为它们提供数据源的插桩工作
流程
1. 在插桩之前定义“正常工作”
没有问题的遥测数据就是噪音。在添加任何插桩之前,写下值班工程师会问的关于此特性的 2-4 个问题:
特性:结账支付重试
值班工程师会问的问题:
1. 首次尝试成功的支付比例是多少?重试后成功的比例呢?
2. 当支付永久失败时,原因是什么?(提供商错误?超时?校验失败?)
3. 支付提供商是否比平时慢?
→ 下面的每个信号都必须能帮助回答其中一个问题。
如果你无法命名这些要回答的问题,说明你还没准备好进行插桩——你会记录一切,却什么也学不到。
2. 为每个问题选择合适的信号
| 信号 | 回答的问题 | 成本概况 | 示例 |
|---|---|---|---|
| 结构化日志 | “这个具体事例发生了什么?” | 每条事件计费;随流量增长 | payment_failed 附带提供商错误码 |
| 指标 | “总体上,发生频率/速度如何?” | 固定序列计费;查询成本低 | 提供商调用 p99 延迟 |
| 追踪 | “时间在服务间消耗在哪里?” | 每次请求计费;通常采样 | 一个慢速结账,按跳转点细分 |
经验法则:指标告诉你有问题,追踪告诉你在哪里,日志告诉你为什么。
3. 结构化日志
记录事件,而不是散文。每一行日志都是一个 JSON 对象,包含稳定的事件名称和机器可读的字段:
// 错误:字符串插值——不可查询,不一致
logger.info(`Payment ${id} failed for user ${userId} after ${n} retries`);
// 正确:稳定的事件名称 + 结构化字段
logger.warn({
event: 'payment_failed',
paymentId: id,
provider: 'stripe',
errorCode: err.code,
attempt: n,
}, 'payment failed');
日志级别——保持一致地使用:
| 级别 | 含义 | 值班操作 |
|---|---|---|
error | 不变式被破坏;可能需要有人采取行动 | 调查 |
warn | 降级但已处理(重试成功,已使用备用方案) | 关注趋势 |
info | 重要的业务事件(订单已下,任务已完成) | 无 |
debug | 诊断详情 | 默认在生产中关闭 |
关联 ID 是强制性的。 在系统边界生成(或接受)一个请求 ID,并将其附加到每一行日志、每个 Span 和每个出站调用中。没有它,你无法从交织的日志中重构单个请求:
// Express:每个请求创建子日志器,ID 向下游传播
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] ?? crypto.randomUUID();
req.log = logger.child({ requestId: req.id });
res.setHeader('x-request-id', req.id);
next();
});
当多个入口点写入同一日志时,需命名入口点。 关联 ID 标识一次运行;但它并不说明是哪个代码路径启动了它。同一个任务被调度器、重放端点以及手动 CLI 调用执行时,会在同一个日志汇中产生可互换的行,因此归因于某一行就回到了排除法——交叉阅读调度器的历史记录、进程表、部署日志——而且这个论点只在那些外部记录恰好仍然存在时才成立。在运行开始时标记入口点,放在关联 ID 旁边,并以相同方式传播两者:
// 每个入口点一个辅助函数:运行自身的日志器携带两个字段。
// 使用 `entryPoint`,而不是 `source`——ECS 保留 `source.*` 用于网络字段。
export const runLog = (entryPoint: 'scheduler' | 'replay_endpoint' | 'cli', runId: string) =>
logger.child({ entryPoint, requestId: runId });
// 调度器 tick -> runLog('scheduler', crypto.randomUUID())
// POST /jobs/:id/replay -> runLog('replay_endpoint', req.id)
// CLI 调用 -> runLog('cli', process.env.RUN_ID ?? crypto.randomUUID())
这两个字段必须跨越与关联 ID 相同的边界——队列元数据、HTTP 头部——否则工作者会重新派生出入口点并猜测。一个仅仅与入口点相关联的字段是一个提示,而不是归因:任何可以调用该任务的东西都可以重现它。
永远不要记录密钥、令牌、密码或完整的个人身份信息。 这是来自 security-and-hardening 技能的硬性规定——遥测管道是一个经典的数据泄露路径。采用字段白名单;不要记录整个请求体。
4. 指标
对于请求驱动的服务,在每个端点和每个外部依赖上插桩 RED:Rate(请求/秒)、Errors(失败率)、Duration(延迟直方图,而不是平均值)。对于资源(队列、池、主机),使用 USE:Utilization(利用率)、Saturation(饱和量)、Errors(错误数)。
与追踪一样,厂商中立的路径是 OpenTelemetry 指标 API(与步骤 5 相同的 SDK 和上下文)。下面的示例使用 Prometheus 的 prom-client——这是一种常见的后端选择,但不是唯一的选择;RED/USE 和基数规则无论哪种方式都是相同的。
import { Histogram } from 'prom-client';
const httpDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration',
labelNames: ['method', 'route', 'status_class'], // '2xx', 而不是 '200'
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});
基数(Cardinality)是故障模式。 每个独特的标签组合都是一个独立的时间序列。标签必须来自小的、固定的集合(路由模板、状态类、提供商名称)。切勿将用户 ID、原始 URL、错误消息或其他无界值用作标签——这些属于日志和追踪。
可作为标签: route="/api/tasks/:id" status_class="5xx" provider="stripe"
绝不能作为标签: user_id, email, request_id, full URL, error message text
永远不要追踪平均值,始终使用百分位数:平均值隐藏了那 1% 体验极差的用户。使用直方图并读取 p50/p95/p99。
5. 分布式追踪
使用 OpenTelemetry——它是厂商中立的标准化方案,自动插桩几乎零代码即可覆盖 HTTP、gRPC 和常见的数据库客户端:
// tracing.ts — 必须在其任何导入之前导入
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
const sdk = new NodeSDK({
serviceName: 'checkout-service',
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
仅在有意义的内在工作单元(例如 applyDiscounts、chargeProvider)周围添加手动 Span,并附加值班人员会用来筛选的属性。在每一个异步边界(HTTP 头部、队列消息元数据)上传播上下文,否则追踪会在间隙处中断。默认以低速率进行基于头的头采样;如果后端支持尾部采样,则保留 100% 的错误。
6. 告警
对用户能感受到的症状进行告警,而不是原因:
症状(应触发分页): 原因(应放入仪表盘,不是分页):
错误率在 5 分钟内 > 1% CPU 使用率 85%
p99 延迟 > 2 秒 一个 Pod 重启
队列年龄 > 10 分钟 磁盘使用率 70%
基于原因的告警会在没有问题时触发,并且会漏掉你未预料到的故障。基于症状的告警会在用户受到影响的时刻确切触发,无论原因是什么。
你创建的每个告警都应遵循的规则:
- 必须是可操作的。 如果响应是“忽略它,它会自我修复”,就删掉这个告警。
- 必须链接到一个 Runbook——即使只有三行:含义是什么、要运行的第一个查询是什么、升级路径是什么。
- 必须具有由 SLO 或历史数据证明合理的阈值和持续时间,而不是猜测。
- **只使用两种严重级别:**页面(面向用户,立即行动)和 工单(降级,本周内行动)。第三级就会变成噪音,让人习惯忽略一切。
编写 Runbook
上述规则 2 要求每个告警都链接到一个 Runbook。Runbook 的职责是回答三个问题,无需读者思考:发生了什么、首先检查什么、如果未能解决应该找谁。存储在 docs/runbooks/ 中,以告警名称命名。
最小可行 Runbook(三行):
# Runbook: /api/tasks 端点上错误率高
**含义:** 数据库连接池可能已耗尽,或者部署有问题。
**首先检查:** `SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend';`
— 如果计数 > 池限制,请参阅步骤 2。(替换为你的数据库的等价命令。)
**升级至:** #db-oncall 或工程值班轮岗。
何时扩展到三行以上: 仅在第一个检查项本身不足以做出决定时添加步骤。一个涵盖三种最常见原因的五步 Runbook 优于一个涵盖所有边缘情况、包含二十个步骤但会被快速浏览的文档。
保持 Runbook 最新。 作为关闭每次使用 Runbook 的事件的部分,更新它——过时的 Runbook 会建立错误的信心。如果某一步是错误的或缺失的,在标记事件已解决之前修复它。
7. 验证遥测数据本身
插桩是代码;它可能出错。在宣布工作完成之前,触发这些路径并查看实际输出:
- 在测试环境中强制产生一个错误→通过
requestId在日志中找到它,确认字段是结构化的(而不是[object Object]) - 发送测试流量→确认指标序列出现,并带有预期的标签和合理的值
- 在追踪 UI 中跟随一个请求穿越多个服务→没有断开的 Span
- 每个新告警触发一次(暂时降低阈值)→确认它到达正确的通道并且 Runbook 链接有效
常见合理化
| 合理化 | 现实 |
|---|---|
| “等它正常工作后我再添加日志” | “之后”变成了“第一次事件之后”,这是发现你处于盲区的最昂贵时刻。边构建边插桩。 |
| “日志越多 = 可观测性越好” | 非结构化的噪音使事件处理更慢,而不是更快。三个可查询的事件胜过三百行散文。 |
| “现在用 console.log 就行了” | 非结构化输出无法被过滤、关联或进行告警。结构化的日志器只多花你五分钟的一次性时间。 |
| “出问题时我们看看仪表盘就行了” | 没有定义问题就构建的仪表盘会向你展示一切,唯独不展示答案。从值班人员的问题开始。 |
| “对所有重要的东西进行告警,我们以后再调整” | 一个吵闹的寻呼机会训练人们忽略它。调整永远不会发生;但真正的分页反而错过了。 |
| “把用户 ID 作为指标标签可以让调试更容易” | 这也会让你的指标后端崩溃。高基数的查询属于日志和追踪。 |
| “对我们的两个服务来说,追踪是过度工程了” | 两个服务已经意味着跨服务延迟问题日志无法回答。自动插桩使得成本微不足道。 |
红旗警示
- 一个包含重试、队列或外部调用,且没有任何新的遥测数据的功能 PR
- 通过字符串插值而不是结构化字段构建的日志行
- 没有关联 ID/请求 ID——每个日志行都是孤儿
- 一个日志流同时由调度器、Webhook 和手动运行提供,但没有字段说明是哪一行产生了该行
- 指标标签包含用户 ID、原始 URL 或错误消息文本(基数炸弹)
- 延迟以平均值跟踪,没有百分位数
- 每天触发且被确认但未采取行动的告警
- 基于原因(CPU、内存)的告警向人类分页,而面向用户的错误率却未被监控
- 密钥、令牌或完整的请求体出现在日志中
- “在我机器上能用”作为生产特性健康的唯一证据
验证
在插桩一个特性后,确认:
- 此特性的值班问题已写下,并且每个信号都映射到其中一个问题
- 所有日志输出都是结构化的(JSON),具有稳定的事件名称,并且每行都有关联 ID
- 每个由多个入口点写入的日志汇都带有一个入口点字段,该字段在运行开始时设置,并与关联 ID 一起传播,而不是在下游推断
- 没有密钥、令牌或未经编辑的个人身份信息出现在任何日志行中(抽查实际输出)
- 每个新端点以及每个外部依赖都存在 RED 指标,且标签集有界
- 延迟是直方图;可查询 p95/p99
- 可以在追踪 UI 中端到端地跟随单个请求,没有断开的 Span
- 每个新告警都是基于症状的,有 Runbook 链接,并且已触发过一次
- 在测试环境中诱导的故障仅通过遥测数据就被定位,无需阅读源代码
如需此列表的快速概览版本,包括预发布插桩检查项,请参阅 ../../references/observability-checklist.md。