10 可观测性:trace、指标、日志、回放
线上报警说 p99 变慢,为什么 dashboard 全绿、日志里也找不到异常?一次用户点击为什么在 trace 里变成三个 root span?指标上的错误率 30%,用户真的失败了 30% 吗?trace、指标、日志各自看不见什么,怎样用它们回答一个没有预先想过的问题?回放生产流量为什么可能再扣一次钱?
能读写 W3C traceparent 并设计一棵可解释的 span 树;能区分头采样与尾采样,并估算 telemetry 的日增量与成本;能用 RED/USE 与 histogram 正确计算分位数;能写 SLI/SLO、错误预算与 burn rate 告警;能用 curl + jq 和 PromQL 定位 p99 的那一段;能设计隔离、幂等的回放与影子流量。
第 9 章把锁、租约和 fencing 讲到了资源端:即使旧持有者恢复,资源也会拒绝过期 token。但还有一个问题没有回答:你怎么知道凌晨两点锁过期了、旧持有者真的执行了、副作用落在了哪张表上? 锁的代码可以完全正确,系统仍然可以在无人察觉的情况下重复扣款;反过来,一段有 bug 的代码也可能在监控上看起来一切正常。
可观测性要解决的不是「多装几个 dashboard」,而是一个更窄、更硬的问题:当系统进入一个你没有预想过的状态时,你能否在不改代码、不重启、不猜的情况下,用已有数据回答「发生了什么、影响谁、从哪开始、能不能复现」。 这句话里有四个动词,恰好对应四种数据:指标告诉你「有多少、什么时候开始」,trace 告诉你「这一次请求花在哪」,日志告诉你「具体是哪条错误、哪个参数」,回放告诉你「同样的输入再来一次,行为是否一致」。
这一章的顺序是:先看三种数据各自的形状与盲区(10.1),再逐个深入 trace(10.2)、指标(10.3)、日志(10.4),然后把它们接成一条可运维的流水线(10.5),用 SLO 把「健康」变成可计算的预算(10.6),最后讲回放(10.7)。后半章回到失败:重试怎样制造重复 span 和双算指标(10.8),怎样用一条命令找到 p99 的那一段(10.9),GitLab 2017 年的数据库误删怎样暴露了「备份可观测性」的缺口(10.10),以及回放流量为什么是一个写负载(10.11)。
10.1 三支柱与它们的盲区
先看一次真实的排障。凌晨 02:14,checkout 服务的 p99 从 220 ms 涨到 1.9 s,告警触发。值班工程师打开 dashboard:CPU 42%,内存 61%,错误率 0.2%,下游 PSP 的成功率 99.98%,数据库连接池占用 30%。所有「资源指标」都正常。日志平台当天有 40 GB 日志,搜 timeout 只看到零星几条。trace 采样率 1%,翻了十分钟也没找到一条超过 1 s 的慢 trace。
四十分钟后,原因找到了:一个大租户的购物车有 300 个商品,结算路径对每个商品做了一次库存查询,形成 300 次串行 RPC。这个租户只占流量的 0.3%,所以它的慢请求几乎都被 1% 的头采样丢掉了;CPU 不高,是因为时间花在等下游,不在算;日志里没有慢,是因为应用只记录了「请求完成」,没有记录每个子调用的耗时。
这个例子把三支柱的盲区一次暴露完:
- 指标知道「整体变慢了」,但聚合把租户、请求、调用链都抹掉了;
- trace 能指出「慢在 300 次库存查询」,但采样把它丢了;
- 日志有请求记录,却没有 span 结构和耗时分解,无法告诉你「慢在哪一段」。
三种数据不是三选一,也不是「都接上就完事」。它们是三种不同的数据模型,各自擅长回答不同形状的问题。把它们混为一谈,就会得到「什么都有,什么都查不出来」的可观测性。
可观测性(observability)是系统根据其外部输出(telemetry)推断内部状态的能力。工程上把它落成三个可操作的问题:
- 给定一个现象,能否找到对应的请求、租户、版本、依赖和代码路径?
- 给定一个请求,能否还原它在时间轴上的每一段耗时与每一次失败?
- 给定一段历史流量,能否在隔离环境里重放,并解释行为差异?
能回答这三个问题,才算「可观测」;只有 dashboard 和告警,只能算「可监控」。监控回答的是你已经想到的问题,可观测性要支撑你还没想到的问题。
10.1.1 三种数据模型的形状
| 支柱 | 数据模型 | 回答什么问题 | 强项 | 盲区 | 成本驱动 |
|---|---|---|---|---|---|
| trace | 有向树:trace_id + span_id + parent_span_id | 这一次请求花在哪、经过谁 | 因果链、跨服务、单请求分解 | 采样、只覆盖已埋点代码、异步易断链、时钟偏斜 | span 数量 × 属性大小 × 保留天数 |
| metrics | 带标签的时间序列:名字 + labels + 时间戳 + 值 | 整体多少、趋势、何时开始 | 便宜、可长期保留、易聚合、适合告警 | 聚合丢身份、基数爆炸、分位数是估计、抓取间隔漏尖峰 | 活跃 series 数 × 抓取频率 |
| logs | 带时间戳的事件:结构化字段或文本 | 具体发生了什么、错误内容、参数 | 细节最全、字段可任意扩展、易读 | 量大、贵、难聚合、常缺上下文、易含敏感信息 | 字节数 × 索引 × 保留天数 |
三种模型的关键差异不是「格式」,而是基数(cardinality)和聚合方式:
- trace 的基数最高:每个请求一棵树,每个 span 一条记录;它天生是「按请求」的。
- metrics 的基数受 label 组合控制:同一个指标名下的 series 数量是乘积;它天生是「按维度聚合」的。
- logs 的基数是「事件数 × 字段数」;它天生是「按时间追加」的。
理解这一点,就能理解为什么「把 trace id 放进 metrics label」是灾难:trace id 的基数是请求数,metrics 后端会为每个请求建一条 series,几秒钟就能把内存打满。反过来,把 metrics 的聚合值塞进日志也没有意义:日志不适合做趋势。
10.1.2 trace 的盲区:采样与断链
trace 最容易被误解成「全量记录」。实际上,生产环境的 trace 几乎总是采样的,原因很简单:一次请求如果有 8 个 span,每个 span 平均 500 字节,20,000 req/s 就是:
6.9 TB/天只是原始 span,还没算索引、查询和副本。就算存储不要钱,网络和 collector 也扛不住。所以 1% 头采样对应的就是 69 GB/天,仍然不是一个小数目。采样不是可选项,而是可观测性设计的一部分;问题不是「要不要采样」,而是「采样决策在哪做、保留什么、丢了什么」。
头采样(在 trace 开始时按概率决定)的盲区是:它看不见未来。一个 0.3% 租户的慢请求,在 1% 采样下只有百分之一的概率被保留;如果这个租户的请求本身只占 0.3%,那它出现在样本里的概率是 。你想找的那条慢 trace,很可能从来就不存在。尾采样(等 trace 结束后按错误/延迟决定)能解决这个问题,但代价是 collector 要在内存里缓存整条 trace,而且所有 span 必须路由到同一个 collector 实例。
trace 的第二个盲区是断链:
- 数据库驱动没有埋点,trace 到服务边界就断了;
- 请求进了线程池/消息队列,
context没有传过去,子 span 变成孤儿; - 异步任务在另一个进程里执行,父 span 已经结束,时间轴上看起来像「凭空多了一段」;
- 批量消费把 100 条消息塞进一个 span,丢失了每条消息的因果。
第三个盲区是时钟。span 的 start_time 来自各自机器的墙上时钟;两台机器差 50 ms,waterfall 上就会出现「子 span 比父 span 早开始」或者「两个本应串行的 span 重叠」。跨主机比较绝对时间要谨慎,比较同一台机器上的 duration 才可靠。
10.1.3 指标的盲区:聚合与基数
指标的第一个盲区是聚合本身。sum(rate(http_requests_total[5m])) 回答的是「总共多少」,不是「谁失败了」。当错误率从 0.2% 涨到 5%,你从聚合值里看不出是哪个租户、哪个路由、哪个版本;如果 label 里没有这些维度,你只能去日志里捞。
第二个盲区是基数。给指标加 label 很容易,代价却是指数级的:
1000 个 pod × 200 个路由 × 60 个状态码 × 10 个租户 = 120,000,000 条 series
即使只保留其中一部分,也足以让 Prometheus 或任何 TSDB 的内存和查询延迟失控。user_id、request_id、trace_id、原始 URL、错误消息,都不应该出现在 metrics label 里。它们属于 trace 属性和日志字段。
第三个盲区是分位数是估计值。Prometheus 的 histogram 把观测值放进预先定义的 bucket,histogram_quantile 在 bucket 内做线性插值;bucket 设计得不好,p99 可能偏差很大。更常见的问题是「对分位数再求平均」——把 10 个实例的 p99 加起来除以 10,得到的数没有任何统计意义。10.3 会给出具体的反例和正确写法。
第四个盲区是抓取间隔。15 s 抓一次,持续 5 s 的尖峰可能只被采到一个点,甚至完全错过;rate(...[1m]) 在低流量下会抖动,在 counter 重置时会给出负值或跳变。指标适合看趋势和预算,不适合还原每一次请求。
10.1.4 日志的盲区:成本与上下文
日志的盲区不是「不够多」,而是「太多且没有结构」。一个服务每天 40 GB 日志,其中 95% 是 INFO 级的请求开始/结束;真正有用的错误行淹没在里面。更常见的问题是日志缺少关联字段:
2026-10-19 02:14:03 ERROR charge failed: timeout
这条日志无法回答:哪个请求?哪个租户?第几次重试?下游是谁?超时前等了多久?如果它带了 trace_id、span_id、idempotency_key、attempt、peer、deadline_remaining_ms,同样的字节数能回答的问题多一个数量级。
日志的第二个盲区是成本。按 5,000 req/s、每条 1 KB 估算:
按每 GB 几毛到几美元不等的摄入价格,每天的成本是几百到几千美元,还没算索引和长期存储。日志不是「免费的证据」,它是按字节计费的。10.4 会给出字段设计、采样和分层保留的具体做法。
10.1.5 互补:从告警到复现的闭环
三种数据不是并列的「三个 dashboard」,而是一条证据链:指标发现异常,exemplar 把它落到一条 trace,trace 落到日志,最后用回放验证。这条链上任何一段断掉,排障就会退化成猜。
监控(monitoring)回答「系统是否在已知的正常范围内」,依赖预先定义的指标和阈值。可观测性(observability)回答「为什么它不在正常范围内」,依赖高基数、可关联、可下钻的数据。前者是后者的入口,不是替代品。一个只有监控的系统可以告诉你「p99 超了」,但不能告诉你「哪个租户的哪一次调用慢在哪一段」。
三支柱之外还有第四类信号:持续 profiling(CPU/内存/锁的采样)。它不在本章的主线里,但在「CPU 不高、延迟却高」的场景里往往比 trace 更直接;第 11 章讲容量时会再遇到它。
10.2 Trace:context、span、采样
trace 的核心不是「一条时间轴」,而是一棵有因果关系的树。一次请求进入系统时创建 root span;每调用一个下游,创建一个 child span;child 的 parent_span_id 指向调用它的 span。所有 span 共享同一个 trace_id。把这棵树按开始时间画出来,就是常见的 waterfall。
trace 是一次逻辑请求的完整调用树,由全局唯一的 trace_id 标识。span 是树中的一个操作,包含 span_id、parent_span_id、名称、开始/结束时间、属性、事件和状态。span context 是跨进程传播的最小集合:trace_id、当前 span_id、采样标志,以及可选的 tracestate。
父子关系表示「谁发起了谁」;兄弟关系表示「同一个父 span 下的并发或串行调用」。没有父子关系的两个 span 即使时间重叠,也不能仅凭时间推断因果。
10.2.1 W3C traceparent:55 个字符的契约
跨服务传播 trace context 的事实标准是 W3C Trace Context。HTTP 请求头 traceparent 的格式是:
version-trace-id-parent-id-trace-flags
00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
| 字段 | 长度 | 示例 | 规则 |
|---|---|---|---|
version | 2 hex | 00 | 当前版本;ff 保留,禁止使用 |
trace-id | 32 hex | 4bf92f3577b34da6a3ce929d0e0e4736 | 16 字节;全零非法 |
parent-id | 16 hex | 00f067aa0ba902b7 | 8 字节;全零非法 |
trace-flags | 2 hex | 01 | 最低位是 sampled;其余位保留 |
几个容易写错的细节:
- 所有字段必须是小写十六进制;大写虽然能解析,但不符合规范,跨厂商时可能被拒绝。
trace-id和parent-id不能全零;全零通常意味着「未初始化」,应该当作没有 context。- 版本
ff非法;遇到未知版本时,规范要求按「不兼容」处理,而不是猜字段。 trace-flags的 bit 0 是 sampled,01表示采样,00表示不采样;其他 bit 目前没有定义,不能拿来做业务标记。tracestate是另一个头,逗号分隔的 vendor 列表(如vendor1=value1,vendor2=value2),有成员数量和长度限制;它适合放厂商的采样决策,不适合塞业务大字段。
一个最小但完整的 Python 实现:
import re
import secrets
from dataclasses import dataclass
TRACEPARENT_RE = re.compile(
r"^(?P<version>[0-9a-f]{2})-"
r"(?P<trace_id>[0-9a-f]{32})-"
r"(?P<parent_id>[0-9a-f]{16})-"
r"(?P<flags>[0-9a-f]{2})$"
)
@dataclass(frozen=True)
class SpanContext:
trace_id: str
span_id: str
sampled: bool
def new_root_context(sampled: bool = True) -> SpanContext:
return SpanContext(
trace_id=secrets.token_hex(16), # 32 hex = 16 bytes
span_id=secrets.token_hex(8), # 16 hex = 8 bytes
sampled=sampled,
)
def format_traceparent(ctx: SpanContext) -> str:
flags = "01" if ctx.sampled else "00"
return f"00-{ctx.trace_id}-{ctx.span_id}-{flags}"
def parse_traceparent(header: str) -> SpanContext | None:
m = TRACEPARENT_RE.match(header.strip().lower())
if not m:
return None
if m["version"] == "ff":
return None
trace_id, parent_id = m["trace_id"], m["parent_id"]
if trace_id == "0" * 32 or parent_id == "0" * 16:
return None
return SpanContext(
trace_id=trace_id,
span_id=parent_id,
sampled=bool(int(m["flags"], 16) & 0x01),
)
这段代码可以直接放进网关或 SDK 的注入/提取逻辑。实际实现还要处理两件事:未知版本(version 不是 00 时,按规范尝试解析并忽略无法识别的尾部字段)和多个头(HTTP 允许重复头,必须拒绝或取第一个,不能拼接)。tracestate 的解析更复杂,建议直接用 OpenTelemetry 的实现,不要自己写。
trace_id 是随机值,不是认证凭证。不要用它做权限判断,也不要在 URL 里暴露完整的内部调用链。它可以出现在日志和响应头里用于排障,但不能替代 request_id、idempotency_key 或用户身份。
10.2.2 span 树:时间轴之外还要看「谁是谁的孩子」
一棵典型的 checkout trace:
trace 4bf92f3577b34da6a3ce929d0e0e4736
POST /v1/pay server 1912 ms self 97 ms
├─ auth.verify client 21 ms
├─ cart.get client 38 ms
│ └─ SELECT cart_items server 31 ms (db)
├─ psp.charge client 1740 ms
│ └─ POST /charge server 1705 ms (external PSP)
└─ db.commit client 16 ms
读这棵树时,先看三件事:
- root 的 self time:
1912 - (21 + 38 + 1740 + 16) = 97 ms。如果 self time 很大,问题在本服务的排队、序列化、GC 或未埋点的代码;如果 self time 很小,问题在某个 child。 - child 的 span kind:
psp.charge是 CLIENT,POST /charge是 SERVER;两者相差 35 ms,是网络与 PSP 入口排队。如果只有 CLIENT 没有 SERVER,说明对方没有埋点,你只能看到「从本端看出去」的耗时。 - 串行还是并行:
auth、cart、psp在时间轴上是否重叠。如果它们串行,端到端是四段之和;如果并行,端到端接近最慢的一段。很多「优化」之所以无效,是因为优化了不在关键路径上的 span。
span 的 kind 不是装饰性的枚举,它决定了这个 span 的耗时包含什么:
| kind | 谁创建 | 时间范围包含 | 典型误用 |
|---|---|---|---|
SERVER | 服务端 | 从收到请求到发出响应(含排队,取决于埋点位置) | 把整个 handler 包括等待下游都算成「服务处理」 |
CLIENT | 调用方 | 从发起调用到收到响应(含网络、重试等待) | 把重试的多次尝试塞进一个 CLIENT span |
PRODUCER | 消息生产者 | 从发送到 broker 确认 | 把「业务写库」也算进 PRODUCER |
CONSUMER | 消息消费者 | 从收到消息到处理完成 | 批量消费只建一个 span,丢失单条消息的因果 |
INTERNAL | 进程内 | 一段本地逻辑(函数、锁、序列化) | 用它包住跨进程调用,导致 kind 语义混乱 |
一个常见错误是「一个 span 同时当 CLIENT 和 SERVER」:在网关里既记录收到请求,又记录转发下游,却只创建一个 span。这样 waterfall 上看起来只有一段,无法区分「网关自身处理」和「下游耗时」。正确做法是至少两个 span:网关的 SERVER span 包含一个指向下游的 CLIENT child。
10.2.3 属性、事件与链接
span 除了时间,还携带三类信息:
- 属性(attributes):键值对,描述这次操作的上下文。常用的是语义约定(semantic conventions)里的字段,例如
http.request.method、http.response.status_code、url.path、server.address、db.system、messaging.system。字段名会随 semconv 版本演进,落地时以 SDK 版本为准。 - 事件(events):带时间戳的离散事件,例如
retry.scheduled、exception、cache.miss。事件适合记录「在 span 内发生了一次值得注意的事」,不适合记录大段日志。 - 链接(links):指向其他 span 的因果引用,但不是父子关系。典型场景是批处理(一个 consumer span 链接到 100 个 producer span)和重试(第 2 次尝试链接到第 1 次)。
重试到底该用 child、event 还是 link?三种都可接受,但语义不同:
| 方式 | 树形状 | 适合回答 | 代价 |
|---|---|---|---|
| 同一个 CLIENT span + event | 一次调用,多个事件 | 「这次逻辑调用重试了几次」 | 看不到每次尝试的独立耗时 |
| 每次尝试一个 child span | 一个父下多个兄弟 | 「哪次尝试慢、打到哪个实例」 | span 数按尝试次数增长 |
| 每次尝试一个独立 trace + link | 多条 trace,用 link 关联 | 客户端 SDK 无法复用 trace 时的补救 | 后端要支持按 link 查询,成本高 |
无论选哪种,必须带 attempt 属性。没有它,后端和人都无法区分「两个并发下游调用」和「同一个调用的两次重试」;10.8 会展示这如何把指标算错。
10.2.4 头采样、尾采样与一致性
采样是 trace 成本的第一道闸门。三种常见策略:
| 策略 | 决策时机 | 决策依据 | 优点 | 缺点 |
|---|---|---|---|---|
| 头采样 | trace 开始时 | 概率、trace-id 哈希、请求属性 | 便宜、实现简单、无需缓存 | 看不到未来的错误/延迟;稀有事件容易丢 |
| 尾采样 | trace 结束后 | 错误、p99、特定属性、租户 | 保留所有错误和慢 trace | collector 要缓存整条 trace;路由要按 trace-id 亲和 |
| 自适应采样 | 运行时 | 当前 QPS、错误率、预算 | 在成本和覆盖率之间折中 | 实现复杂;采样率变化会影响同比 |
头采样最重要的是一致性:同一个 trace 的所有服务必须做同样的决定,否则会出现「A 采了、B 没采」的残缺 trace。做法有两种:
- 父级采样(parent-based):如果收到的
traceparent里sampled=1,就继续采;如果是0,就不采。根节点做概率决策。 - 按 trace-id 哈希:
hash(trace_id) % 100 < rate。这样即使各服务独立决策,只要 rate 相同,结果也一致;rate 变化时按新 rate 重新哈希,仍能保证同一 trace 一致。
OpenTelemetry 的 TraceIDRatioBased 采样器就是按 trace-id 的低位做一致性哈希;ParentBased 则把父级决策放在最外层。生产里通常组合成 ParentBased(TraceIDRatioBased(0.01))。
尾采样的工程约束更硬:
- 一条 trace 的所有 span 必须到达同一个 collector 实例,否则每个实例只看到一部分,无法判断「这条 trace 是否失败」。多 collector 部署时,要用按 trace-id 路由的负载均衡(例如 OTel Collector 的
loadbalancingexporter)。 - collector 要在内存里保留 trace 直到决策窗口结束。20,000 req/s × 30 s = 60 万条 trace;每条 8 个 span、500 字节,就是 2.4 GB 的原始数据,还没算对象开销。
- 长 trace(异步任务、流式处理)可能几分钟都不结束;决策窗口必须有上限,超时后按「未完成」处理,否则内存会被拖垮。
- 尾采样只能决定「存不存」,不能决定「采不采」:所有 span 已经产生并传输到 collector,网络和 collector 的 CPU 成本已经付了。
一个常见的折中是「头采样 + 尾采样」两级:入口按 10% 头采样,collector 再对这 10% 做尾采样,保留其中所有错误和慢 trace。这样传输成本降了一个数量级,错误覆盖率仍然很高。代价是「本来就没被头采样选中的错误」永远看不到;所以错误率极低的系统,头采样比例不能太低。
把采样率想成「你愿意为每一类问题付多少钱」。错误和慢请求是信息量最高的样本,应该给更高的保留率;健康请求的信息量低,可以大量丢弃。头采样的问题是它在「还不知道是不是错误」时就做了决定,所以它只能按先验概率分配预算;尾采样把决定推迟到信息出现之后,代价是缓存和路由。
10.2.5 时钟、异步与断链
trace 的时间轴假设「所有 span 的时钟可比」。这个假设在跨主机时不成立:
- NTP 可能让两台机器差几十毫秒,甚至瞬间倒退;
- 容器迁移后时钟源不同;
- 同一台机器上,不同语言的 monotonic clock 起点不同。
因此,不要用两个 span 的绝对开始时间推断因果,父子关系由 parent_span_id 决定,不由时间决定。waterfall 上如果 child 比 parent 早开始,先怀疑时钟,不要怀疑代码。
异步是另一个断链来源。一个请求把任务丢进线程池/队列后立即返回,父 span 结束;任务在另一个线程里执行时,context 没有传过去,子 span 要么没有 parent,要么挂在「当前活跃 span」上(可能是另一个请求)。修复方式:
- 线程池/协程:显式传递 context(Python 的
contextvars、Go 的context.Context、Java 的ThreadLocal+ 包装器); - 消息队列:把
traceparent放进消息头,消费端 extract 后创建 CONSUMER span; - 批处理:一条消息一个 link,不要只建一个「批次 span」;
- 定时任务:每次执行创建新的 root trace,不要复用上一次的 context。
10.3 指标:RED/USE、直方图、分位数
指标的数据模型是「名字 + 标签 + 时间戳 + 数值」。它便宜、可长期保留、适合聚合和告警,但也最容易被误用:一个 avg 可能掩盖长尾,一个 sum 可能把重试算成新流量,一个 histogram_quantile 可能因为 bucket 设计而偏差几十个百分点。
10.3.1 四种类型与它们的语义
| 类型 | 语义 | 例子 | 常见错误 |
|---|---|---|---|
| counter | 单调递增的累计值 | http_requests_total | 对 counter 求平均;忘记 rate 处理重置 |
| gauge | 可增可减的瞬时值 | queue_depth、inflight_requests | 对 gauge 用 rate;把采样值当总量 |
| histogram | 按 bucket 累计的分布 | request_duration_seconds | bucket 设计不合理;对分位数求平均 |
| summary | 客户端计算的分位数 | request_duration_seconds{quantile="0.99"} | 跨实例聚合;无法合并 |
Prometheus 生态里,histogram 会展开成三组 series:
http_request_duration_seconds_bucket{le="0.05"} 900
http_request_duration_seconds_bucket{le="0.1"} 990
http_request_duration_seconds_bucket{le="0.25"} 999
http_request_duration_seconds_bucket{le="0.5"} 1000
http_request_duration_seconds_bucket{le="+Inf"} 1000
http_request_duration_seconds_sum 84.2
http_request_duration_seconds_count 1000
le 是「小于等于」,bucket 是累计的;+Inf 必须存在,且等于 _count。histogram_quantile(0.99, ...) 会在第 99 百分位落在的 bucket 内做线性插值:上例中 99% 分位在第 990 个请求,正好落在 le="0.1" 的边界,估计值是 100 ms;如果真实分布集中在 0.1–0.25 s 的左侧,插值可能高估或低估。
bucket 的设计要围绕 SLO 阈值,而不是围绕「看起来均匀」:
- 如果 SLO 是「p99 ≤ 300 ms」,至少要有
0.1、0.25、0.3、0.5、1这些边界; - 只在
0.1和1两个 bucket 上算 p99,误差可能接近一个数量级; - bucket 越多,series 越多,成本越高;通常 10–20 个 bucket 足够。
10.3.2 RED 与 USE:服务看请求,资源看饱和度
RED 和 USE 是两套互补的检查表,不是两种产品:
| 框架 | 维度 | 适用对象 | 回答 |
|---|---|---|---|
| RED | Rate、Errors、Duration | 请求驱动的服务/接口 | 流量多少、失败多少、慢不慢 |
| USE | Utilization、Saturation、Errors | 资源(CPU、内存、磁盘、连接池、队列) | 资源是否忙、是否排队、是否出错 |
一个 checkout 服务的 RED 表:
| 指标 | 查询 | 正常范围 | 异常时先看 |
|---|---|---|---|
| Rate | sum(rate(http_server_requests_total{service="checkout"}[5m])) | 与上游 QPS 同量级 | 突增/突降、重试放大 |
| Errors | sum(rate(...{status=~"5.."}[5m])) / sum(rate(...[5m])) | < 0.5% | 按路由/依赖/版本拆 |
| Duration | histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m]))) | p99 < 300 ms | 按 span/下游拆 |
USE 用于资源:
| 资源 | Utilization | Saturation | Errors |
|---|---|---|---|
| CPU | 1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) | node_load1 / count(cpu) | throttling、steal |
| 内存 | 1 - MemAvailable/MemTotal | page cache 回收、OOM kill | OOM、分配失败 |
| 连接池 | pool_in_use / pool_size | pool_wait_seconds p99 | 获取超时、连接重置 |
| 队列 | queue_depth / queue_capacity | 入队等待时间 | 拒绝、丢弃 |
| 磁盘 | 1 - free/total | iowait、写放大 | 只读挂载、IO 错误 |
RED 的盲区是「资源饱和度」:请求成功率 100%、p99 200 ms,但连接池已经 95%,下一次流量突增就会排队。USE 的盲区是「业务结果」:CPU 只有 30%,但所有请求都返回了错误的业务码。两套一起看,才既知道「用户是否满意」,也知道「系统还有多少余量」。
10.3.3 分位数不能平均:一个反例
假设两个实例在同一个 5 分钟窗口内:
| 实例 | 请求数 | p99 |
|---|---|---|
| A | 995 | 100 ms |
| B | 5 | 1 s |
把两个 p99 平均,得到 550 ms。但合并后的真实 p99 是多少?总共 1000 个请求,第 990 个(99% 分位)落在 A 的 995 个请求里;B 的 5 个慢请求只占 0.5%,不足以把第 990 个请求推到 1 s。合并后的 p99 仍然接近 100 ms。平均 p99 既不是合并 p99,也不是任何一个实例的 p99;它只是一个没有统计意义的数。
反过来,如果慢请求占 2%(例如 A 有 20 个请求、全部 1 s,B 有 980 个请求、p99 100 ms),平均 p99 仍是 550 ms,但合并 p99 会落在 1 s 一侧。偏差方向取决于流量和尾部占比,不能靠「取最大」或「取平均」修正。
正确做法只有一种:先把原始 bucket 合并,再算分位数。
# 正确:先 sum bucket,再 histogram_quantile
histogram_quantile(0.99,
sum by (le) (
rate(http_server_request_duration_seconds_bucket{service="checkout"}[5m])
)
)
# 错误:对每个实例算完再平均
avg(
histogram_quantile(0.99,
sum by (le, instance) (
rate(http_server_request_duration_seconds_bucket{service="checkout"}[5m])
)
)
)
第二条查询在语法上能跑,在统计上没有意义。它把「分位数」当成了可加的标量;分位数不是线性算子,。
同样的错误也发生在时间维度:把周一的 p99 和周二的 p99 平均,不等于两天的合并 p99;如果周一的流量是周二的 10 倍,合并 p99 会更接近周一。要看趋势,应该画每天的 p99 曲线,而不是把曲线平均成一条。
summary 在客户端计算分位数,上报的是 {quantile="0.99"} 这样的瞬时值。两个实例的 p99 无法合并,因为你不知道它们各自的分布形状;Prometheus 也明确不建议对 summary 做聚合。需要跨实例分位数时,用 histogram,或者用支持合并的 sketch(如 DDSketch)。
10.3.4 exemplar:从分位数跳到那一条 trace
histogram 的另一个能力是 exemplar:在 bucket 上附带一个样本的 trace_id,让你从「p99 是 1.9 s」直接跳到「这是一条 1.9 s 的 trace」。OpenMetrics 的 exemplar 形如:
http_server_request_duration_seconds_bucket{le="2"} 17 # {trace_id="4bf92f3577b34da6a3ce929d0e0e4736"} 1.912 1710000000.123
exemplar 的采样率通常很低(例如每 100 个请求保留一个),否则它自己就会变成高基数数据。它的价值在于「桥接」:指标负责发现异常,exemplar 负责把异常落到一条具体 trace,trace 再落到日志。没有 exemplar 时,你只能按时间窗口去 trace 后端里碰运气。
10.3.5 基数:指标成本的第一性原理
一个指标的 series 数量是 label 取值的笛卡尔积。http_requests_total{service, route, method, status} 在 40 个服务、200 个路由、5 个方法、20 个状态码下是 80 万条 series;如果再加一个 tenant_id(1 万个租户),就是 80 亿条,任何 TSDB 都会崩。
控制基数的手段:
- 规范化路由:
/users/123/orders/456记为/users/{id}/orders/{id},不要把原始路径当 label; - 状态码分桶:
2xx/3xx/4xx/5xx,或者只保留业务关心的几个码; - 避免 ID 类 label:
user_id、request_id、trace_id、order_id一律不进 metrics; - 限制枚举:错误码、租户等级、部署版本用受控枚举,不要用自由文本;
- 定期审计:按指标名统计 series 数,找出异常增长的指标。
# 每个指标名的活跃 series 数,按从大到小排
topk(20, count by (__name__) ({__name__=~".+"}))
如果某个指标的 series 数在一天内从 1 万涨到 100 万,通常不是流量涨了 100 倍,而是某个 label 混进了高基数维度(例如把 error_message 当 label)。这类问题要在上线前用 cardinality 估算挡住,而不是等 Prometheus OOM 后再查。
10.3.6 抓取、rate 与重置
Prometheus 每 15 s 抓一次,rate(metric[5m]) 用窗口首尾样本估算速率。窗口内至少要有两个样本;counter 重置会被当作重置处理,但跨重置的 increase 会低估;rate 窗口至少是抓取间隔的 4 倍。告警用 5 m 平滑、排障用 1 m 看尖峰,两者可以共存,但要明确每个面板的窗口语义。
10.4 日志:结构化、关联、成本
日志是最古老的可观测性手段,也是最容易被写成「字符串拼接」的手段。一条好的日志不是「给人读的句子」,而是「带上下文的事件」:它有稳定的字段名、明确的级别、可关联的 trace/span,以及可计算的成本。
10.4.1 一条日志应该有哪些字段
| 字段 | 必选 | 示例 | 用途 |
|---|---|---|---|
ts | 是 | 2026-10-19T02:14:03.114Z | 排序、窗口聚合;统一 UTC |
level | 是 | INFO/WARN/ERROR | 过滤;但不要用它表达业务影响 |
event | 是 | charge.settled | 稳定的事件名,便于聚合 |
service | 是 | checkout | 多服务混合查询 |
trace_id / span_id | 是 | 4bf9... / 00f0... | 关联 trace |
request_id | 建议 | req-9f2a | 没有 trace 时的兜底 |
idempotency_key | 有副作用时 | pay-o-8812 | 归并重试、对账 |
attempt | 有重试时 | 2 | 区分物理尝试 |
duration_ms | 建议 | 183 | 单条日志的耗时 |
outcome | 建议 | ok/timeout/rejected | 受控枚举,便于统计 |
error.code | 出错时 | PSP_TIMEOUT | 机器可读的错误分类 |
peer | 建议 | 10.2.7.11:8443 | 判断是否总打到坏实例 |
字段名要稳定,不要在同一个服务里混用 traceId、trace_id、tid;否则查询时要写三个正则,告警也会漏。字段值要受控:outcome 用枚举,error.code 用错误码表,不要把异常堆栈当 error.code。
10.4.2 结构化日志的最小实现
Python 标准库的 logging 加上 contextvars 和 JSON formatter,就能得到可关联的结构化日志:
import contextvars
import json
import logging
import sys
import time
_trace_id = contextvars.ContextVar("trace_id", default=None)
_span_id = contextvars.ContextVar("span_id", default=None)
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created))
+ f".{int(record.msecs):03d}Z",
"level": record.levelname,
"event": getattr(record, "event", record.getMessage()),
"service": "checkout",
"trace_id": _trace_id.get(),
"span_id": _span_id.get(),
}
for key in ("request_id", "idempotency_key", "attempt",
"duration_ms", "outcome", "error_code", "peer"):
value = getattr(record, key, None)
if value is not None:
payload[key] = value
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
log = logging.getLogger("checkout")
log.addHandler(handler)
log.setLevel(logging.INFO)
def handle_charge(req):
token = _trace_id.set(req.trace_id)
try:
log.info("charge.started", extra={"event": "charge.started",
"idempotency_key": req.key,
"attempt": req.attempt})
result = psp.charge(req)
log.info("charge.settled", extra={"event": "charge.settled",
"idempotency_key": req.key,
"attempt": req.attempt,
"duration_ms": result.ms,
"outcome": "ok"})
return result
finally:
_trace_id.reset(token)
这段代码有三个关键点:
trace_id放在contextvars里,异步/线程池只要复制 context 就能自动带上;不要在每个函数参数里手动传。event是稳定的事件名,extra里的字段是结构化字段;不要把整个字典json.dumps塞进message,那样查询时还要二次解析。exception单独放,不要把堆栈拼进message;日志后端可以对exception做折叠和聚合。
10.4.3 关联:日志、trace、指标的三角
一条日志能回答「发生了什么」,但要和 trace 关联,必须满足两个条件:
- 日志里有
trace_id和span_id,且和 trace 后端的 ID 格式一致(都是 32/16 位小写 hex); - 记录日志的位置在对应的 span 作用域内,而不是在异步回调里丢了 context。
常见的关联查询:
# 从错误日志拿到 trace_id
jq -r 'select(.level=="ERROR" and .event=="charge.failed") | .trace_id' /var/log/checkout/app.log \
| tail -n 1
反过来,从指标跳到日志,靠的是 exemplar 或时间窗口 + 维度过滤;从 trace 跳到日志,靠的是 trace_id。三者之间的 ID 必须统一,否则「关联」只是口头上的。
10.4.4 成本:日志是按字节计费的
日志成本由三部分构成:摄入(ingest)、索引(index)、保留(retention)。摄入通常按 GB 计费,索引可能按字段数或查询量计费,保留按 GB·月计费。一个每天 432 GB 的服务,30 天保留就是 12.96 TB;即使压缩后打三折,仍然是数 TB。
控制成本的手段不是「少打日志」,而是「把日志分成不同的层」:
| 层 | 内容 | 保留 | 采样 | 例子 |
|---|---|---|---|---|
| 热 | 错误、审计、支付 | 30–90 天,可查 | 100% | charge.failed、refund.created |
| 温 | 请求摘要、状态迁移 | 7–30 天 | 10%–100% | http.request、call.state |
| 冷 | 调试、详细参数 | 1–7 天或关闭 | 1% 或按需开启 | cache.lookup、payload.debug |
采样要按「事件类型」而不是「随机行」:错误和审计永远 100%,调试日志可以按 trace 采样(保留整条 trace 的日志,而不是随机丢行)。随机丢行会让一条请求的日志残缺,反而更难查。
日志里最容易忽略的成本是重复:同一个异常在每一层都打一遍,一条请求产生 10 条几乎相同的 ERROR;或者把整个请求体打进日志,一条 100 KB。修复方式:
- 只在异常第一次被捕获的地方打 ERROR,上层打 WARN 或只记录「已处理」;
- 请求体只记录指纹、长度、关键字段,不记录全文;
- 用
error.code聚合,而不是让每条日志都带完整堆栈; - 对高频错误做限流(例如同一
error.code每分钟最多 N 条),但保留计数指标。
日志的定位是「解释细节」,不是「承载所有信号」。能用指标回答的「多少/趋势」,不要用日志;能用 trace 回答的「哪一段」,不要用日志;日志负责回答「具体是什么」。
下一节把这些信号接进 OpenTelemetry 的流水线:SDK 怎么产生、collector 怎么缓冲和转发、哪些环节会悄悄丢数据。
10.5 OpenTelemetry 与 collector
OpenTelemetry(OTel)不是某个后端,而是一套 API、SDK、协议和 collector 的规范。它的价值在于把「产生 telemetry」和「把 telemetry 送到哪」解耦:应用只依赖 API/SDK,后端可以换;collector 负责缓冲、批处理、脱敏、采样和路由。
10.5.1 三层结构:API、SDK、collector
| 层 | 作用 | 什么时候用 | 常见错误 |
|---|---|---|---|
| API | 定义 tracer/meter/logger 接口 | 库代码只依赖 API | 在库里直接初始化 SDK,导致版本冲突 |
| SDK | 实现采样、批处理、导出 | 应用启动时初始化一次 | 每个请求新建 provider;同步导出 |
| collector | 接收、处理、转发 OTLP | 多服务、多后端、需要尾采样 | 把 collector 当「可选组件」,应用直连后端 |
OTLP 是 OTel 的原生协议,gRPC 默认端口 4317,HTTP 默认端口 4318。应用可以把 span 直接发给后端,但在生产里通常至少经过一层 collector,原因不是「架构好看」,而是:
- 缓冲:后端短暂不可用时,collector 可以重试,应用不必阻塞;
- 批处理:把大量小 span 合并成批次,降低网络和后端压力;
- 脱敏/过滤:在离开信任域前删除敏感属性;
- 尾采样:只有 collector 能看到完整 trace;
- 多后端:同一份数据可以同时送监控、归档和审计,不必改应用。
10.5.2 一个可用的 collector 配置
下面是一个最小但完整的 collector 配置,包含接收、内存保护、批处理、脱敏和导出:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
attributes/scrub:
actions:
- key: http.request.header.authorization
action: delete
- key: db.statement.parameters
action: delete
batch:
timeout: 5s
send_batch_size: 1024
send_batch_max_size: 2048
exporters:
otlp:
endpoint: otel-gateway:4317
tls:
insecure: true
retry_on_failure:
enabled: true
initial_interval: 1s
max_interval: 30s
sending_queue:
enabled: true
queue_size: 5000
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, attributes/scrub, batch]
exporters: [otlp]
这份配置里有四个关键参数:
memory_limiter必须在batch之前,否则 batch 会先把内存吃满;limit_mib要小于容器的 memory limit,留出余量给运行时。batch.timeout是「最长等多久」,send_batch_size是「攒够多少就发」;两者共同决定延迟和吞吐。retry_on_failure的重试是有界的;如果后端长时间不可用,队列会满,新数据会被丢弃。必须监控otelcol_exporter_send_failed_spans和队列长度。sending_queue是内存队列,不是持久化队列;collector 重启会丢队列里的数据。对审计级数据,要么用持久化队列(如 Kafka),要么在应用侧落盘。
10.5.3 不要阻塞业务:批处理与丢弃策略
telemetry 的导出绝不能阻塞请求路径。一个常见的反模式是在 handler 里同步调用 exporter:
# 错误:每个请求都同步导出,后端慢时业务一起慢
with tracer.start_as_current_span("charge"):
result = psp.charge(req)
exporter.export(spans) # 网络 I/O 在请求路径上
正确做法是 BatchSpanProcessor:span 结束后进入有界队列,后台线程按批次导出。队列满时的行为必须是「丢弃并计数」,而不是「阻塞等待」:
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
provider = TracerProvider(
resource=Resource.create({"service.name": "checkout", "deployment.environment": "prod"})
)
provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://127.0.0.1:4317", insecure=True),
max_queue_size=2048,
schedule_delay_millis=5000,
max_export_batch_size=512,
export_timeout_millis=30000,
)
)
trace.set_tracer_provider(provider)
max_queue_size=2048 意味着最多缓存 2048 个 span;队列满后 SDK 会丢弃新 span 并增加 dropped_spans 计数。这个计数必须进 dashboard:丢弃 telemetry 是故障,不是静默优化。
10.5.4 尾采样与 collector 的路由
如果要用尾采样,collector 的部署方式会反过来影响架构:
- 所有 span 必须按
trace_id路由到同一个 collector 实例;否则每个实例只看到部分 trace,无法判断错误。 - 常见做法是「agent 层不做尾采样,只做批处理和脱敏;gateway 层用
loadbalancingexporter 按 trace-id 路由,再做尾采样」。 - gateway 需要足够的稳定内存;扩容/缩容会触发 trace 重新分布,正在决策窗口内的 trace 可能被拆到两个实例,导致决策不一致。
- 尾采样的策略要可观测:保留率、丢弃原因、各策略命中的 trace 数,都要有指标。
尾采样策略通常按「错误全留、慢 trace 全留、其余按比例」组合。decision_wait 决定等多久,num_traces 决定内存里最多保留多少条 trace;两者相乘就是内存上界,必须按 collector 的预算调。
10.5.5 collector 自身的可观测性
collector 是 telemetry 的「数据面」,它自己也会故障。必须监控:
| 指标 | 含义 | 异常时 |
|---|---|---|
otelcol_receiver_refused_spans | 接收端拒绝的 span 数 | 队列满/限流;扩容或降采样 |
otelcol_processor_dropped_spans | processor 丢弃的 span 数 | 内存保护/策略丢弃;查原因 |
otelcol_exporter_send_failed_spans | 导出失败数 | 后端不可用/认证失败;查重试与队列 |
otelcol_exporter_queue_size | 发送队列长度 | 持续接近上限;扩容或降载 |
otelcol_process_runtime_heap_alloc_bytes | collector 堆内存 | 接近 limit;查尾采样/大属性 |
一个常见事故是「collector 内存打满 → OOM → 所有服务看起来正常,但 trace 全没了」。如果没有 collector 的自监控,你会以为「没有慢请求」,实际上是「没有数据」。
自动埋点(agent/instrumentation library)能覆盖 HTTP、数据库、消息队列等标准库,但覆盖不了业务语义:哪个是「下单」、哪个是「扣款」、哪个失败应该算业务错误。自动埋点给你骨架,手动埋点给你关键分支;两者都要有,不能只靠 agent。
10.6 SLO、错误预算与告警
凌晨的 p99 告警有一个老问题:它告诉你「慢了」,但不告诉你「用户是否受影响、要不要现在起床」。SLO 的作用是把「健康」从一个形容词变成一个可计算的比率,再把比率变成可消耗的预算。
SLI(service level indicator)是一次可计算的测量:good events / valid events。SLO 是这个比率在某个窗口内要达到的目标,例如「过去 28 天,99.9% 的支付请求成功」。错误预算(error budget)是 ,即允许的失败比例。burn rate 是当前错误率相对于预算消耗速率的倍数:
burn rate = 1 表示按当前速度刚好在窗口结束时用完预算;burn rate = 14.4 表示 1 小时就用掉 2% 的月度预算。
SLI 的定义比阈值更重要。以 checkout 为例:
| SLI | good events | valid events | 说明 |
|---|---|---|---|
| 可用性 | 返回 2xx/3xx 且业务成功的请求 | 排除健康检查、用户主动取消 | 5xx 和业务失败都算坏 |
| 延迟 | duration <= 300 ms 的请求 | 所有成功受理的请求 | 阈值来自用户感知,不是 p99 现状 |
| 新鲜度 | now - data_ts <= 60 s 的查询 | 所有查询 | 适用于缓存/数仓/Feed |
30 天窗口下,不同 SLO 允许的不可用时间:
| SLO | 每月预算 | 直观感受 |
|---|---|---|
| 99.5% | 3 小时 36 分 | 允许一次小事故 |
| 99.9% | 43 分 12 秒 | 大多数团队的现实目标 |
| 99.95% | 21 分 36 秒 | 需要冗余和快速切换 |
| 99.99% | 4 分 19 秒 | 多副本 + 自动故障转移 + 演练 |
burn rate 告警要用多窗口:长窗口确认「真的在烧」,短窗口确认「现在还在烧」,两者同时满足才 page。否则一次 5 分钟的抖动会把已经恢复的系统再次叫醒。
| 长窗口 | 短窗口 | burn rate 阈值 | 消耗的月预算 | 动作 |
|---|---|---|---|---|
| 1 h | 5 m | 14.4 | 2% | page |
| 6 h | 30 m | 6 | 5% | page |
| 3 d | 6 h | 1 | 10% | ticket |
PromQL 写法(假设 recording rule 已经产出 sli:checkout_availability:ratio_rate5m 等比率):
# 可用性 SLI:5 分钟窗口
sum(rate(http_server_requests_total{service="checkout", status!~"5.."}[5m]))
/
sum(rate(http_server_requests_total{service="checkout"}[5m]))
# 1 小时 burn rate(SLO = 99.9%)
(1 - sli:checkout_availability:ratio_rate1h) / (1 - 0.999)
# 多窗口告警:短窗口和长窗口同时超阈值
(
(1 - sli:checkout_availability:ratio_rate5m) / (1 - 0.999) > 14.4
)
and
(
(1 - sli:checkout_availability:ratio_rate1h) / (1 - 0.999) > 14.4
)
一条正常的输出:
{service="checkout"} 18.2
18.2 意味着按当前错误率,大约 24 分钟就会烧掉 1% 的月度预算;如果持续 1 小时,就是 2.5%。这时 page 是合理的;如果 burn rate 只有 1.2,应该开 ticket,而不是半夜叫人。
告警要区分「症状」和「原因」。
page 只给症状(SLO burn、用户可见失败、大面积超时),每条都要有 runbook、dashboard 和负责团队;ticket 给原因(磁盘 80%、证书到期、副本 lag),这些问题有时间处理。不要给每个 5xx 都配告警,用 SLO burn 聚合决定「是否值得叫人」。
错误预算不是「用完了就惩罚团队」,而是一个预先约定的决策规则:预算充足时加快发布,预算耗尽时冻结非必要变更、优先修复可靠性。没有这条规则,SLO 就只是墙上的数字。
10.7 回放:确定性重放、影子流量、事件溯源
排障的最后一步是「能不能复现」。可观测性数据能告诉你「发生了什么」,但不一定能让你「再发生一次」;回放(replay)就是把记录下来的输入、依赖响应和环境,在一个受控的地方重新执行。回放不是「把流量再打一遍」这么简单:它要么追求确定性,要么追求真实感,两者对隔离的要求不同。
| 回放方式 | 输入 | 追求 | 主要风险 |
|---|---|---|---|
| 确定性重放 | 录制的请求 + 依赖响应 + 种子 | 同一输入产生同一输出 | 非确定性来源没录全;副作用打到真实依赖 |
| 影子流量 | 实时/近实时的生产请求副本 | 真实分布、真实时序 | 写请求打到生产;放大下游负载 |
| 事件溯源重放 | 事件日志(offset/时间范围) | 重建状态、回填投影 | 副作用重复(邮件、扣款);顺序/版本问题 |
10.7.1 确定性重放:先消灭非确定性
要让「同样的输入得到同样的输出」,必须记录所有非确定性来源:
- 时钟:用虚拟时钟替换
now(),回放时按录制的时间戳推进; - 随机数/UUID:记录种子,或直接录制生成的值;
- 外部依赖:把下游响应(包括错误和超时)录下来,回放时按请求匹配返回;
- 并发/调度:记录关键事件的顺序;完全确定的多线程回放很难,通常退而求其次,只保证「业务结果一致」;
- 配置/版本:记录代码版本、配置快照、feature flag 状态,否则回放的是另一套系统。
录制的粒度决定了回放的保真度。只录 HTTP 请求,回放时下游仍然真实调用,那只是「重发流量」,不是「确定性重放」;要复现一个超时引发的重试链,必须把那次超时也录下来。
10.7.2 影子流量:复制请求,不复制副作用
影子流量(shadow traffic / traffic mirroring)在入口代理处复制请求,发给新版本服务,响应被丢弃,用户不受影响。它适合验证「新版本能否处理真实流量」,但不适合验证「写路径」——除非写被隔离。
三个必须做的隔离:
- 写隔离:新版本连只读副本、独立的影子库,或把写操作改成 dry-run;绝不能连生产主库。
- 下游隔离:支付、邮件、短信、推送这些副作用,必须指向沙箱或 mock;否则「影子」只是「另一个生产」。
- 负载隔离:镜像流量按比例采样(例如 1%–5%),并限速;新版本故障时不能把下游拖垮。
一个常见的错误是「只丢弃响应,就算影子」。响应是丢弃了,但请求已经写进了数据库、已经调用了支付;用户看到的「没变化」,只是因为读的还是旧库。
10.7.3 事件溯源重放:重建状态,不是重发副作用
事件溯源系统把每次状态变更记成不可变事件;重放事件日志就能重建任意时刻的状态。它天然适合回放,但有两个陷阱:
- 副作用在投影之外:订单投影重放时,如果投影里调用了「发确认邮件」,重放 100 万条订单就会发 100 万封邮件。副作用必须从投影中剥离,或者由幂等的下游消费。
- 事件 schema 演进:三年前的事件可能缺少今天的必填字段;重放代码必须能处理旧版本,或者先做 upcast(把旧事件转换成新 schema)。
重放的顺序也要注意:同一分区内有序,跨分区不保证;如果投影依赖跨分区顺序,重放结果可能和线上不同。重放前先定义「一致」的判据:是最终状态一致,还是中间某个检查点一致?
10.7.4 隔离的代码边界
回放安全不能靠「操作的人小心」,要靠代码里的效果边界(effect boundary)。所有副作用——扣款、发券、发邮件、写外部系统——都必须经过一个网关,网关根据运行模式决定「执行、跳过还是打到沙箱」:
class EffectGateway:
def __init__(self, mode: str, run_id: str):
if mode not in {"live", "sandbox", "dry_run"}:
raise ValueError(mode)
self.mode = mode
self.run_id = run_id
def charge(self, key: str, amount_cents: int):
if self.mode == "dry_run":
return {"status": "skipped", "key": key}
if self.mode == "sandbox":
# 沙箱键加命名空间,避免与生产去重表碰撞
return psp_sandbox.charge(f"sandbox:{self.run_id}:{key}", amount_cents)
# live 只用于修复/回填:必须复用原键,让下游去重
return psp.charge(key, amount_cents)
三种模式对应三种目的:
| 模式 | 用途 | 下游 | 键 |
|---|---|---|---|
dry_run | 验证逻辑、压测 | 不调用 | 不生成 |
sandbox | 端到端联调 | 沙箱 | 加 run_id 命名空间 |
live | 修复/回填 | 生产 | 复用原键,绝不生成新键 |
live 模式是最危险的:它看起来像「正常执行」,但输入的是一段历史流量。唯一安全的做法是复用原始 idempotency_key,让下游把重复请求识别为同一逻辑请求,并确认键仍在去重窗口内;如果原始请求没有键,就不能用 live 回放,只能走 sandbox 或人工核对。
- 这次回放是为了「验证行为」还是「修复状态」?两者的隔离要求不同。
- 输入里的非确定性来源(时钟、随机、外部响应)录全了吗?
- 副作用边界在哪里?dry-run 开关是否能覆盖所有写路径?
- 回放会打到哪里:生产库、影子库、沙箱,还是 mock?
- 失败时的 kill switch 是什么?谁有权按下它?
回放的成本也常被低估:一次全量回放可能消耗数小时的 CPU、打满下游配额、产生大量临时数据。按「采样 1%、限速到生产峰值的 20%、先跑只读路径」的顺序推进,比一次性全量安全得多。
10.8 失败模式:重试导致的重复 span 与错误指标
现在把镜头对准一个具体的失败:一次用户操作,在 trace 里变成三棵树,在指标里变成三次请求,在 SLO 里变成一次故障。 这不是 instrumentation 的边角问题,而是重试与可观测性叠加后最常见的失真。
10.8.1 一条时间线
| 时间 | 客户端 | 服务端 | trace/指标 |
|---|---|---|---|
| t0 | 用户点击,创建 trace T1,发送 attempt 1 | 收到,开始处理 | T1 有一个 root span |
| t1 | 1 s 超时,SDK 重试;新起了一个 root trace T2 | 仍在处理 attempt 1 | T1 结束(timeout),T2 开始 |
| t2 | T2 也超时,再重试 T3 | attempt 1 成功但响应慢 | T2 timeout;T3 开始 |
| t3 | T3 成功,用户看到成功 | 返回结果 | 后端看到 3 个 root、3 次请求、2 次「失败」 |
如果 SDK 复用了 trace id,形状会变成「一个 root + 三个 CLIENT child」;如果每次重试都新起 trace,就是「三棵独立的树」。两种形状在不同后端里都常见,但都会导致同一个问题:指标把物理尝试当成了逻辑请求。
图里的 B rpc (attempt=1) 和 B rpc (attempt=2) 是同一个逻辑调用的两次尝试。如果它们没有 attempt 标记,读图的人会以为 B 被调用了两次;如果指标按 span 数或请求数计数,B 的 QPS 也会被算成两倍。
10.8.2 重复 span 的四种来源
| 来源 | 现象 | 怎么发现 | 修复 |
|---|---|---|---|
| SDK 重试新起 trace | 多个 root,时间上首尾相接 | 同一 idempotency_key 对应多个 trace_id | 复用 trace context;至少带 logical_request_id |
| 自动 + 手动埋点重复 | 同一操作两个重叠的 SERVER span | 同 span kind、同 operation、时间重叠 | 关闭重复的自动埋点,或给手动 span 改名为 INTERNAL |
| collector 至少一次导出 | 同一 (trace_id, span_id) 出现两次 | 后端按 span id 去重后数量下降 | 后端幂等写入;不要靠「不会重复」的假设 |
| 异步 context 丢失 | 孤儿 span 或挂错父节点 | parent 为空/指向无关请求 | 显式传递 context;消息头带 traceparent |
前两种是「产生了多余的 span」,第三种是「同一 span 被送了两次」,第四种是「span 的父子关系错了」。它们的修复方式不同,但都会让「span 数 = 请求数」这个假设失效。
10.8.3 指标双算:三个具体的数字
假设一次支付逻辑请求重试 3 次,每次都打到同一个服务:
- 服务端 RPS:
rate(http_server_requests_total[5m])会计 3 次,而不是 1 次;如果全站 dashboard 把所有服务的 RPS 相加,这个请求会被计 3 次以上。 - 错误率:3 次尝试中 2 次超时、1 次成功,按 attempt 算是 66.7% 错误;按用户结果算是 0% 失败。SLO 如果按 attempt 算,会误判为严重故障。
- 延迟直方图:3 次尝试都进了 histogram,
_count是 3;p99 可能被「重试等待」拉长,而不是被服务处理拉长。
更隐蔽的是跨服务双算:客户端记一次 CLIENT span,服务端记一次 SERVER span;如果 dashboard 用「所有 span 数」当请求量,一次调用就被算两次。正确的「请求量」只有一个来源:入口的 SERVER span,或者网关的访问日志;其他层的计数只能用于诊断,不能相加。
| 指标 | 错误算法 | 后果 | 正确算法 |
|---|---|---|---|
| 请求量 | 所有服务 RPS 相加 | 按 hop 数放大 | 入口 SERVER span / 网关日志 |
| 错误率 | 按 attempt 算 | 重试导致假故障 | 按逻辑请求的最终结果 |
| p99 | 对实例/时间平均 | 统计无意义 | 合并 bucket 后 histogram_quantile |
| SLO burn | 用 attempt 的 SLI | 预算被重试烧光 | 用逻辑请求的 SLI |
10.8.4 修复:让 trace 和指标都说「逻辑请求」的语言
三层修复,缺一不可:
- trace 层:重试复用
trace_id;每次尝试是一个带attempt的 child span(或 event/link);root span 只创建一次。SDK 不支持复用时,至少把logical_request_id放进 span 属性,后端按它聚合。 - 指标层:区分
requests_total{phase="logical"}和attempts_total;SLO 只消费 logical;attempt 指标用于诊断重试率。 - 存储层:后端按
(trace_id, span_id)幂等写入;采集端按idempotency_key去重;对账时用业务键而不是 trace id。
一个最小的 Python 重试埋点,重点是「同一个 trace,不同 attempt」:
from opentelemetry import trace
from opentelemetry.trace import SpanKind, Status, StatusCode
tracer = trace.get_tracer("checkout")
def charge_with_retry(client, req, ctx, attempts=3):
# root span 在更外层创建;这里只创建每次尝试的 child
for attempt in range(1, attempts + 1):
with tracer.start_as_current_span(
"rpc.charge",
context=ctx, # ctx: extract 得到的 opentelemetry.context.Context
kind=SpanKind.CLIENT,
attributes={
"rpc.attempt": attempt,
"rpc.logical_id": req.key,
"server.address": "psp.internal",
},
) as span:
try:
return client.charge(req)
except TimeoutError as exc:
span.set_status(Status(StatusCode.ERROR, "timeout"))
span.add_event("retry.scheduled", {"attempt": attempt})
if attempt == attempts:
raise
这段代码没有解决「SDK 自己新起 trace」的问题;如果 SDK 不暴露 context,你只能在入口生成 logical_request_id,让后端按它把多棵树拼起来。不要假设所有 SDK 都会正确传播 context,要在灰度环境里用一条真实重试验证。
某支付服务在大促期间 p99 从 300 ms 涨到 1.2 s,SLO burn rate 告警到 9。值班工程师看 trace,发现同一个 idempotency_key 对应 3 个 root trace:客户端 SDK 每次重试都新建 trace,服务端每次都记一条请求。指标上请求量是真实用户的 2.8 倍,错误率 31%,但用户侧成功率仍有 99.7%。
修复分两步:客户端 SDK 在重试时复用 trace context,服务端把 attempt 写进 span 属性;指标新增 phase="logical" 的计数器,SLO 改为按 logical 计算。修复后同样的流量下,burn rate 回落到 0.4,trace 数量减少 64%。
重复 span 和双算指标不是「数据多一点」,而是改变了你对系统的判断。当观测数据本身被重试放大,你会在错误的地方扩容、在错误的时间回滚、在错误的团队之间传锅。
10.9 观测:用 trace 找 p99 的那一段
这一节只做一件事:给定「checkout 的 p99 是 1.9 s」,用三步找到「这 1.9 s 花在哪」。顺序是「指标定位 span → 日志拿 trace id → trace 看瀑布」。
10.9.1 第一步:指标按 span 名拆 p99
histogram_quantile(0.99,
sum by (le, span_name) (
rate(span_duration_seconds_bucket{service_name="checkout"}[5m])
)
)
一次输出:
{span_name="POST /v1/pay"} 1.92
{span_name="psp.charge"} 1.74
{span_name="cart.get"} 0.05
{span_name="db.commit"} 0.02
root 的 p99 是 1.92 s,psp.charge 的 p99 是 1.74 s,已经占了 90%;cart.get 和 db.commit 都很快。注意这里用 sum by (le, span_name) 先合并所有实例的 bucket,再算分位数;不要对每个实例算完再平均。
10.9.2 第二步:从日志拿 trace id,再用 curl + jq 拉 trace
TRACE_ID=$(jq -r 'select(.event=="http.request" and .duration_ms > 1500) | .trace_id' \
/var/log/checkout/app.log | tail -n 1)
echo "$TRACE_ID"
curl -sS "http://127.0.0.1:16686/api/traces/${TRACE_ID}" \
| jq -r '
.data[0].spans
| sort_by(.startTime)
| .[]
| [.spanID, (.duration / 1000 | floor), .operationName,
([.tags[]? | select(.key == "span.kind") | .value][0] // "-")]
| @tsv
'
一次输出(单位 ms):
a1b2c3d4 1912 POST /v1/pay server
e5f6a7b8 21 auth.verify client
c9d0e1f2 38 cart.get client
93a4b5c6 1740 psp.charge client
7e8f9a0b 1705 POST /charge server
1c2d3e4f 16 db.commit client
把这张表按父子关系画出来:
POST /v1/pay server 1912 ms self 97 ms
├─ auth.verify client 21 ms
├─ cart.get client 38 ms
├─ psp.charge client 1740 ms
│ └─ POST /charge server 1705 ms (PSP 侧)
└─ db.commit client 16 ms
读法:
| 观察 | 含义 | 下一步 |
|---|---|---|
| root self time 大 | 本服务排队/序列化/GC,或子调用没埋点 | 看 CPU、GC、线程池、未埋点代码 |
| CLIENT 远大于 SERVER | 网络、重试等待、客户端排队 | 看 attempt、连接池、peer |
| 只有 CLIENT 没有 SERVER | 对方没埋点,trace 到边界断了 | 用对方日志/指标补,或推动对方接入 |
| 两个同名 span 重叠 | 自动+手动重复埋点 | 关掉一个,或改名区分 |
| 子 span 之和 > root | 并行 span 被重复相加,或时钟偏斜 | 看时间轴,不要直接求和 |
例子里 psp.charge 的 CLIENT 是 1740 ms,PSP 的 SERVER 是 1705 ms,差 35 ms 是网络和 PSP 入口排队;root 的 self time 只有 97 ms。结论是「PSP 处理慢」,不是「checkout 代码慢」。如果 PSP 没有 SERVER span,你只能看到 1740 ms 的 CLIENT,仍然能判断「时间在外部依赖」,但无法区分「PSP 慢」和「网络慢」;这时要用 PSP 的状态页或独立监控补盲。
10.9.3 第三步:回到指标确认不是个例
一条 trace 只能说明「这一次」。回到指标,按 peer 看 psp.charge 的分布:
histogram_quantile(0.99,
sum by (le, peer) (
rate(span_duration_seconds_bucket{service_name="checkout", span_name="psp.charge"}[5m])
)
)
读法:所有 peer 都慢是整体问题,单个 peer 慢是局部问题;p99 高而 p50 正常是长尾,p50 也高是系统性变慢。
10.10 真实案例:GitLab 2017-01-31 数据库误删与恢复
2017 年 1 月 31 日,GitLab.com 发生了一次数据库误删事故。公开复盘给出的核心事实是:一名工程师在处理复制延迟时,在错误的主库上执行了删除命令,约 300 GB 数据被删;恢复只能回到约 6 小时前的快照,这段时间内的数据丢失。事故没有停留在「一个人手滑」,而是暴露了五道备份/恢复防线同时失效。
| 时间(UTC) | 事件 |
|---|---|
| 2017-01-31 23:25 | 在错误的主库目录执行删除,约 300 GB 数据被移除 |
| 随后 | 停止相关进程,评估恢复路径;发现多套备份不可用 |
| 恢复期 | 从可用的快照恢复,快照约 6 小时前 |
| 之后 | 公开复盘,说明数据丢失窗口与恢复过程 |
公开复盘列出的五道防线——逻辑备份(pg_dump)、对象存储备份、LVM 快照、流复制、云盘快照——在需要时都没有提供可用的恢复点。复盘没有把原因归为单点,而是每一道都有自己的缺口;其中 LVM 快照最近一份约 6 小时前,恢复只能回到那个时间点。对本章而言,重点不是记住哪一道先坏,而是这些缺口本来都可以用「最近一次成功」和「恢复演练」暴露出来。
每一道防线都可以变成一个可观测性指标,而不是一句「以后注意」:
# 备份超过 1 小时没有成功,就告警(阈值按 RPO 调整)
time() - backup_last_success_timestamp_seconds{job="postgres-backup"} > 3600
{job="postgres-backup", instance="db1"} 26820
26820 秒约 7.45 小时,已经远超 1 小时的阈值。比「失败计数」更重要的是「最后一次成功的时间」:任务可能一直在跑、一直「成功」,但产出的是空文件或旧版本格式。备份的 SLI 不是「任务退出码为 0」,而是「最近一次成功恢复的时间」和「恢复出的数据时间点」。
还可以用 absent() 做「死人开关」:如果指标本身消失了(exporter 挂了、抓取配置错了),也要告警,而不是安静地认为「没有问题」:
absent(backup_last_success_timestamp_seconds{job="postgres-backup"})
这起事故对本章的四个教训:
- 备份是一个系统,不是一个任务。 它的 SLI 是 RPO/RTO 和恢复成功率,不是 cron 的退出码;每季度至少做一次真实恢复演练。
- 对「缺失」告警。 错误日志会吵,沉默不会;备份、复制、telemetry 管线都需要 dead man’s switch。
- 破坏性操作要有闸门。 生产删除前的二次确认、快照、双人复核、最小权限,比事后补救便宜。
- 可观测性本身依赖被观测的系统。 dashboard、trace、日志都可能和数据库一起挂;runbook 要能离线使用,关键告警要有独立通道。
本节只引用 GitLab 官方公开复盘中的结论:时间、约 300 GB 删除、约 6 小时数据丢失、五道备份/恢复机制失效。具体的命令、内部拓扑和恢复步骤不复述,也不把责任归因于单个人。官方复盘:Postmortem of database outage of January 31。
10.11 重试导致重复副作用:回放流量没有幂等
回放最危险的形态,是「以为自己在做只读验证,实际上打的是一个写路径」。下面这个案例里,没有人写错业务代码,也没有人故意绕过幂等;问题出在回放工具把「影子流量」理解成了「复制请求」。
10.11.1 一条时间线
某团队要把 checkout 迁移到新版本,决定用生产入口的访问日志做影子流量:复制 90 分钟的请求,发给新版本,丢弃响应,对比新旧版本的返回。新版本部署在生产 namespace,连同一个数据库,因为「这样最真实」。
| 时间 | 回放工具 | 新版本 checkout | 下游 PSP | 结果 |
|---|---|---|---|---|
| t0 | 读取访问日志,重放 POST /v1/pay | 收到请求,创建订单 | — | 开始产生真实订单 |
| t1 | 日志里没有 Idempotency-Key,工具为每条请求生成新 UUID | 按新键写入,调用 PSP | 扣款成功 | 第一笔重复扣款 |
| t2 | 90 分钟共重放 210 万条请求,其中 0.09% 是支付 | 创建 1,842 条支付 | 扣款 1,842 次 | 用户被重复扣款 |
| t3 | 对比脚本发现「响应不一致」,但为时已晚 | 订单表多出 1,842 条 | 退款流程启动 | 事故 |
根因有四层,每一层单独看都「有道理」:
- 影子流量被当成只读:复制请求不等于复制读请求;
POST /v1/pay本身就是写。 - 幂等键在回放时重新生成:访问日志没有记录
Idempotency-Key,工具为了「让请求合法」生成了新 UUID;服务端看到的是全新的逻辑请求。 - 环境没有隔离:新版本连的是生产库和 PSP 生产凭证;响应丢弃了,副作用没有丢弃。
- 没有 dry-run 闸门:回放工具没有「只读模式」,也没有按路由白名单限制;支付路径被包含在全量回放里。
10.11.2 三种模式与它们的边界
| 目的 | 模式 | 输入 | 副作用 | 键 |
|---|---|---|---|---|
| 验证新版本行为 | dry_run / 影子环境 | 请求副本 | 不执行,或只写影子库 | 不生成真实键 |
| 修复历史状态 | live 回填 | 原始事件/请求 | 执行,但必须幂等 | 复用原始键 |
| 联调 | sandbox | 采样请求 | 沙箱下游 | 加 run_id 命名空间 |
关键区别是:验证行为时,你不需要真实副作用;修复状态时,你需要真实副作用,但必须让下游能识别「这是同一个逻辑请求」。两者都不能「生成新键 + 打生产」。
某团队用生产访问日志做 90 分钟影子回放,验证 checkout 新版本。回放工具发现日志里没有幂等键,于是为每条请求生成新 UUID;新版本连生产库和 PSP,响应被丢弃。结果:1,842 笔重复扣款,退款和客服成本远超迁移收益。
修复分四步:
- 回放工具增加
--mode=dry-run,默认不调用任何写接口;写路径必须显式 allowlist。 - 影子环境使用独立数据库和 PSP 沙箱凭证;生产凭证在回放 namespace 里不可用。
- 如果必须打生产做回填,复用原始
Idempotency-Key,并在回放前用只读查询确认该键仍在去重窗口内。 - 回放前做 1% 采样 + 限速,先跑只读路径,确认对比指标正常后再扩大。
回放和重试共享同一个教训:「再执行一次」只有在能识别「这是同一个意图」时才安全。重试的键在客户端,回放的键在日志/事件里;两者都不能在「再执行」的那一刻重新生成。
10.12 可观测性设计清单
新增一个关键路径时,先把下面 12 个问题答完。答不上来的,不是「以后补」,而是「现在就没有可观测性」。
| # | 检查项 | 达标线 |
|---|---|---|
| 1 | 每个入口请求有 trace_id/span_id | 日志、响应头、下游调用都能关联 |
| 2 | 每个逻辑请求只有一个 root span | 重试复用 trace;attempt 有标记 |
| 3 | span kind 正确 | CLIENT/SERVER 分离,不混用 |
| 4 | 采样策略有文档 | 头/尾、比例、错误保留、成本估算 |
| 5 | RED/USE 指标覆盖路由与资源 | rate/errors/duration + 连接池/队列/CPU |
| 6 | histogram bucket 围绕 SLO | 阈值附近有 bucket;分位数从 bucket 合并 |
| 7 | label 基数受控 | 无 user_id/trace_id/原始 URL |
| 8 | 日志字段稳定且脱敏 | trace_id、event、outcome、error.code |
| 9 | collector 有内存/批处理/重试 | 丢弃量、队列长度有监控 |
| 10 | SLO 有 SLI、窗口、预算、burn 告警 | 按逻辑请求算,不按 attempt |
| 11 | 告警有 runbook 和负责人 | page 只给症状,ticket 给原因 |
| 12 | 回放有 dry-run/隔离/幂等 | 写路径默认关闭;live 复用原键 |
这张表的前 4 项决定「能不能查」,中间 5 项决定「查得准不准、贵不贵」,最后 3 项决定「出事时能不能行动」。很多团队只做 trace 和 RED,结果有数据但没有 SLO,有 dashboard 但没有 runbook;出事时仍然靠猜。
10.13 本章陷阱清单
- 把 trace_id 当请求 ID 或幂等键:trace 是一次调用链的标识,不是业务身份;重试可能换 trace,幂等键必须稳定。
- 重试时新起 root trace:同一逻辑请求变成多棵树,指标和 SLO 都被放大;复用 context,至少带
logical_request_id。 - 采样率固定 1%,不看错误:稀有错误和慢请求被丢掉;用尾采样或错误全留。
- 对分位数求平均:
avg(p99)没有统计意义;先合并 bucket 再histogram_quantile。 - 对 gauge 用
rate:队列深度、inflight 不是 counter;用avg_over_time或直接看瞬时值。 - 把 user_id/trace_id 放进 metrics label:基数爆炸,TSDB 内存和查询延迟失控;ID 属于 trace/日志。
- 日志只打字符串、不带 trace_id:无法与 trace/其他服务关联;结构化字段 + context 注入。
- 在请求路径同步导出 telemetry:后端慢会把业务拖慢;用 BatchSpanProcessor + 有界队列。
- 按 attempt 算 SLO:重试两次后成功,用户成功但 SLO 算失败;SLO 只看逻辑请求的最终结果。
- 备份任务成功就当可恢复:退出码为 0 不等于能恢复;看最近成功恢复时间,定期演练。
- 把回放当只读:复制 POST 就是写;没有 dry-run/隔离/原键,回放会重复扣款。
- 不监控 collector 自身:collector OOM 后 trace 全丢,dashboard 看起来「一切正常」;监控丢弃量、队列、堆内存。
10.14 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 解释三支柱的盲区 | 每种至少说出两个盲区和一个互补手段 | 10.1 |
| 读写 traceparent | 能生成/解析,指出全零、ff、大小写问题 | 10.2 |
| 画 span 树 | 能标出 root self time、CLIENT/SERVER、串行/并行 | 10.2 |
| 选择采样策略 | 能根据错误率/成本选头采样、尾采样或两级 | 10.2 |
| 写 RED/USE 查询 | 能给出 rate/errors/duration 与资源饱和度查询 | 10.3 |
| 正确算分位数 | 能解释为什么不能平均,并写出合并 bucket 的 PromQL | 10.3 |
| 写 SLO 与 burn 告警 | 能算预算、burn rate,并写出多窗口条件 | 10.6 |
| 设计安全回放 | 能区分 dry-run/sandbox/live,说明键的处理 | 10.7、10.11 |
| 诊断重复 span | 能说出四种来源和三层修复 | 10.8 |
| 用命令找慢 span | 能独立完成 PromQL → trace id → curl+jq → 读图 | 10.9 |
| 复述 GitLab 教训 | 能说出五道防线与「对缺失告警」 | 10.10 |
Q1 traceparent: 00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01 有什么问题?解析时应该接受还是拒绝?
Q2 实例 A 的 p99 是 100 ms(995 个请求),实例 B 的 p99 是 1 s(5 个请求)。平均 p99 是多少?合并后的 p99 大约是多少?应该怎么写查询?
Q3 一个系统 99.9% SLO,过去 1 小时的错误率是 1.44%。burn rate 是多少?按这个速度,多久烧掉 2% 的月度预算?
Q4 客户端 SDK 每次重试都新建 trace,服务端按请求计数。一次用户支付重试 3 次后成功,指标上的错误率和用户失败率分别是多少?怎么修?
Q5 回放生产访问日志验证新版本,为什么「丢弃响应」不等于「没有副作用」?安全回放有哪几种模式?分别怎么处理幂等键?
Q6 一条 trace 的 root 是 1912 ms,四个 child 分别是 21、38、1740、16 ms。root 的 self time 是多少?如果两个 child 在时间上重叠,直接相加会有什么问题?
答案
A1 trace-id 是大写,不符合「小写十六进制」的规范;严格实现应拒绝或先规范化为小写再接受。其他字段合法:版本 00、parent-id 非全零、flags 01 表示采样。生产上建议「解析时兼容大写、转发时统一小写」,避免跨厂商丢 context。
A2 平均 p99 是 550 ms;合并后的 p99 约 100 ms,因为第 990 个请求落在 A 的 995 个请求内,B 的 5 个慢请求只占全局 0.5%。正确写法是先 sum by (le) 合并 bucket,再 histogram_quantile(0.99, ...);不能对实例的 p99 求平均。
A3 burn rate 。1 小时消耗 的月度预算;正好对应「1 h/5 m 窗口、阈值 14.4」的 page 告警。
A4 按 attempt 算,3 次中 2 次超时、1 次成功,错误率 66.7%;按用户结果算,最终成功,失败率 0%。修复:重试复用 trace context,span 带 attempt;指标区分 logical 与 attempt;SLO 按 logical 的最终结果计算。
A5 响应被丢弃只影响调用方看到的结果,请求已经到达服务端并执行了写库、扣款等副作用。安全模式:dry_run 不执行副作用、不生成真实键;live 回填复用原始 idempotency_key,让下游去重;sandbox 打沙箱并给键加 run_id 命名空间。
A6 self time ms。如果 child 在时间上重叠,直接相加会重复计算重叠区间,得到大于 root 的和;应该看时间轴的并集,或只沿关键路径相加。
母本对应
| 母本 | 章节 | 本书落点 | 程度 | 说明 |
|---|---|---|---|---|
| 谢希仁《计算机网络》第 8 版 | 第 5/6 章(运输层、应用层) | 10.1、10.2 | 背景 | 只借用「请求跨层传输」的视角;不重写 TCP/HTTP |
| 张彦飞《深入理解 Linux 网络》 | 第 4–7 章(连接、协议栈) | 10.1、10.9 | 背景 | 网络耗时只作为 span 的一段,不展开内核路径 |
| 《Python 网络编程》 | HTTP 客户端、并发与日志 | 10.2、10.4 | 精选 | 借用工程风格;traceparent、结构化日志代码为本书重写 |
| 《Linux 系统与内核》第 4 篇 | 4.4–4.11(网络与协议栈) | 10.1、10.5 | 交叉引用 | 明确不重复 TCP 握手、HTTP 语义、epoll、DNS/TLS |
| W3C Trace Context、OpenTelemetry 规范 | traceparent、OTLP、collector | 10.2、10.5 | 引用 | 只引用格式与组件语义,不复制规范长段 |
| Google SRE Workbook | SLO、错误预算、burn rate | 10.6 | 引用 | 借用多窗口告警的参数,例子为本书自编 |
| 本书自编 + GitLab 公开复盘 | GitLab 2017-01-31 | 10.10 | 完整 | 只引用公开结论,不复制原文,不归因个人 |
下一站:知道了「怎么看见系统」,下一步要回答「看见快撑不住时怎么办」。进入 11 容量、限流、降级、故障演练,看 Little’s law、利用率曲线、令牌桶、熔断与爆炸半径。