Theory · Microservice · Observability
几十个服务 × 几百个实例 —— 没有 Logging / Metrics / Tracing 三支柱,故障排查就是盲人摸象
通过系统的外部输出理解其内部状态:日志(离散事件细节)、指标(聚合趋势)、追踪(请求全旅程)
OpenTelemetry(统一采集 SDK+OTLP 协议)→ Prometheus(指标)· Loki/ELK(日志)· Tempo/Jaeger(追踪)
SLI/SLO 定义目标 → 告警分级 → 排障方法论(指标定位 → 链路收敛 → 日志定因)→ 复盘沉淀
Why Observability Matters
| 阶段 | 用户报“下单失败/很慢”之后发生了什么 |
|---|---|
| 单体时代 | 几台机器、一个日志文件、一个数据库。登上去 grep 订单号 → 看到异常栈 → 5 分钟定位到慢 SQL。 |
| 拆成微服务 | 下单请求穿过网关 → 订单 → 库存 → 支付 → 风控 → 通知,6 个服务 × 平均 8 个实例 = 48 个进程各自写日志。登哪台机器都只能看到自己那一小段。 |
| 结果 | 订单组说“我这边正常”,库存组说“我这边也正常”,支付组说“我返回值是对的”。最后靠逐个服务加日志 → 重新发版 → 等复现,3 小时起步。 |
① 认不出“同一次请求”:48 个实例的日志混在一起,哪些行属于用户这一次点击?没有统一 ID 就没法串。
② 不知道慢在“哪一跳”:每个服务只记自己的耗时,加起来对不上用户感受到的 12 秒——中间还有排队、重试、网络。
③ 不知道“是不是真坏了”:没有聚合数字,只能等用户投诉才发现。等发现时,已经坏了一小时。
它换掉的不是日志本身,而是那套“靠猜 → 加日志 → 重新发版 → 等复现”的排障方式。装上三支柱之后,同样的问题变成:指标先告诉你“订单服务 P99 从 200ms 涨到 3s”(30 秒发现),trace 再告诉你“慢在支付那一跳”(1 分钟收敛),最后用 trace_id 到那一个实例捞日志看到根因(5 分钟定因)。
总耗时:3 小时 → 约 7 分钟,而且不需要重发版。
先看三支柱各答什么问题(第 3 页)→ 逐一展开日志 / 指标 / 追踪 → 用 OpenTelemetry 把它们接起来 → 处理成本(采样)与目标(SLO)→ 最后落到 Go 工具箱与排障五步法。读完你应该能回答:“这条链路到底怎么查”。
Three Pillars
开场那三个问题,正好一人领走一个:认不出请求 → 追踪给 trace_id;不知慢在哪跳 → 追踪画调用树;不知是否真坏 → 指标做聚合告警。
Prerequisites & Glossary
| 术语 | 一句话理解(先记住这个,细节后面展开) |
|---|---|
| 可观测性 Observability | 只看系统的外部输出,就能推断它内部发生了什么——强在能回答事先没想到过的问题 |
| 监控 Monitoring | 针对已知故障模式设阈值并告警(CPU>80%)。功能是可观测性的子集:告警靠监控,定因靠可观测 |
| 日志 Log | 一条一条的离散事件记录:细节最全、成本最高,用来看"到底发生了什么" |
| 指标 Metric | 可聚合的数值时间序列(QPS、错误率、P99):便宜、能长期存,但没有个体细节 |
| 追踪 / 跨度 Trace / Span | 一次请求走过的全部步骤叫 trace;其中一个步骤(一次 RPC、一次 SQL)叫 span,带起止时间与父子关系 |
| trace_id 上下文传播 | 整条链路共用的同一个编号,靠 HTTP header / gRPC metadata / MQ 消息头逐跳往下传——打通三支柱的钥匙 |
| 标签基数 Cardinality | 标签取值能组合出多少种时间序列。user_id 做标签 = 几百万种 = 打爆存储,只能放服务/接口这类可枚举维度 |
| 分位数 P99 | 把请求按耗时从快到慢排,第 99% 位置那一笔的耗时。比"平均耗时"更能反映长尾用户的真实感受 |
| SLO / 错误预算 | SLO = 服务质量目标(如 30 天成功率 ≥99.9%);错误预算 = 1−SLO,即允许"欠"多少失败(99.9% ≈ 43 分钟/30 天) |
| 采样 Sampling | 只记录一部分链路以省成本。头部=入口随机掷骰子,尾部=链路跑完按结果决定(错误/慢的全留) |
微服务总览 → 为什么要把一个系统拆成一堆服务(本 deck 的一切痛点都源于这次拆分)
服务间通信与 RPC → "一跳"到底是什么、context 是怎么传过去的
GMP 调度 → 后面读 Go 的 goroutine / 调度延迟指标时需要
统一直觉:一次请求 = 一串 span。给这串 span 编一个号(trace_id),写进每一个服务打出的每一行日志;同时把所有同类 span 的数值聚合成指标。于是"全站趋势"和"某个用户这一次点击"共用同一个编号——你可以从一张趋势图一路钻到具体那一行日志。这就是三支柱唯一要做的事。
三支柱不是三套并列的系统,而是一个漏斗的三层:指标最便宜所以全量(发现问题)→ 追踪按 trace_id 收敛(缩小范围)→ 日志最贵所以只在确定的那一处捞(定因)。成本从低到高、细节从粗到细,排障时顺着这个漏斗往下走。
Pillar 1
// ✅ 结构化:字段可检索可聚合
{"ts":"2026-09-02T10:00:00Z",
"level":"error","msg":"pay failed",
"trace_id":"a1b2c3",
"order_id":"ORD-20260902",
"err":"timeout after 500ms"}
// ❌ 文本:grep 一把梭但无法聚合
"[ERROR] 2026-09-02 pay failed
for order ORD-2026... timeout"
结构化才能做到:按字段过滤(order_id)、跨实例聚合统计、与 trace 联动(trace_id 字段)。
应用 → stdout/stderr → sidecar/节点代理采集(Filebeat/Fluent Bit/Promtail)→ Kafka 削峰 → 存储(Loki:只索引标签省成本;ES:全文索引能力强)。选型口诀:标签检索选 Loki,全文检索选 ES,成本敏感 Loki + 对象存储。
Pillar 2
| 类型 | 语义 | 例 |
|---|---|---|
| Counter | 只增不减的累计值 | 请求总数、错误总数 |
| Gauge | 瞬时值可增可减 | goroutine 数、队列深度、CPU |
| Histogram | 分桶统计分布(可算分位数) | 请求延迟 P50/P95/P99 |
| Summary | 客户端直接算分位数 | 多实例无法聚合,慎用(histogram 替代) |
rate()/histogram_quantile() 是两个最常用的 PromQL 函数——延迟分位数必须用 histogram 分桶在服务端算。
RED(面向服务/请求):Rate(QPS)· Errors(错误率)· Duration(延迟分布)——每个对外接口都要有这三元组;USE(面向资源):Utilization(使用率)· Saturation(饱和度)· Errors(错误)——CPU/内存/连接池/队列。组合:RED 看服务健康,USE 找资源瓶颈。
Prometheus 主动拉取 /metrics(服务发现驱动)——故障时"拉不到"本身也是信号(对比推模型的失联沉默问题);标签基数纪律:user_id/order_id 级别不要做 label(组合爆炸打爆 TSDB)——高基数细节交给日志与 trace,指标只留可枚举维度(服务/实例/接口/状态码)。
指标模型示例:http_request_duration_seconds_bucket{service="order",method="POST",le="0.1"} —— label 定位维度、bucket 定位分位;一切告警 SLO 都建立在正确的指标设计上。Exemplar(OTel 扩展):直方图样本可携带 trace_id——指标图上直接跳转到对应 trace。
Pillar 3
OTel
应用 → OTel SDK(插桩)
→ OTLP → Collector(可选但推荐)
receivers # 接收
processors # batch/sampling
exporters # 多后端分发
# Collector 价值:
# - 卸载应用侧开销(批量/重试)
# - 统一采样/脱敏/富化
# - 后端解耦(换后端不改应用)
部署形态:Agent(每节点 DaemonSet)或 Gateway(中心集群)。
OTel in Practice
上一页回答"为什么用它",这一页回答"到底怎么接进去"。
// 初始化一次,全局生效
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res))
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(
propagation.TraceContext{})
// gRPC 拦截器自动注入/提取
grpc.Dial(addr,
grpc.WithUnaryInterceptor(
otelgrpc.UnaryClientInterceptor()))
grpc.NewServer(
grpc.UnaryInterceptor(
otelgrpc.UnaryServerInterceptor()))
① 零插桩/eBPF 自动埋点:Grafana Beyla/Otel-Auto-instr——无需改代码,适合存量;② 库级插桩:OTel 官方 Instrumentation(gin/grpc-go/database/sql 桥接 otelsql);③ 手动插桩:业务关键路径自建 span + 自定义属性(业务维度最值钱)。三层叠加,手动只做"值得记"的关键点。
手动 span 的纪律:只给下单/扣款/风控这类业务关键点建 span,其余交给库级自动埋点——span 数 × 属性大小直接决定 trace 存储成本,手动埋点埋的是"排障价值",不是"覆盖率"。
Sampling
trace 被"不采样"并不代表信息丢失:所有日志行都带 trace_id——出事后即使没有完整 span 树,也能按 trace_id 串起各服务的日志还原时间线。日志是低成本全量底座,trace 是高成本精读层——两者互补而不是替代。
trace 存储是三支柱最贵的(每链路几十个 span×属性)。控制手段:采样率、属性精简(不塞大 payload)、TTL 分层(热数据 SSD 7 天 → 冷数据对象存储)。可观测成本是"数据税"——设计时按"单位排障价值/字节"取舍。
SLO
成功请求率 = 非5xx / 总请求、延迟达标率 = P99<200ms 的请求占比下单成功率 ≥ 99.95%(30d)· 下单 P99 < 500ms ≥ 99% · 支付回调处理延迟 P99 < 3s ≥ 99.9%——每个 SLO 都能对应到具体 SLI 查询语句
告警 = 预算燃烧速率超阈值;看板 = 预算余量+燃烧趋势。错误预算告警替代"CPU>80% 告警"这类噪音源——用户没受影响就不该呼人
先给 1 条核心链路建 SLO(别贪多)→ 跑一个月校准阈值 → 再铺开。SLO 是治理工具不是 KPI——用 SLO 考核团队会催生"指标造假",Google SRE 明确警告过
Go Toolkit
import _ "net/http/pprof" go http.ListenAndServe( ":6060", nil) // 内网端口! // CPU 火焰图 (30s 采样) go tool pprof -http=:8080 \ http://pod:6060/debug/pprof/profile?seconds=30 // 内存 profile .../debug/pprof/heap // goroutine 泄漏定位 .../debug/pprof/goroutine?debug=2 // 阻塞/互斥锁分析 .../debug/pprof/block · mutex
事故现场三板斧:CPU 火焰图找热点、heap 看泄漏、goroutine dump 看谁阻塞——配合 gmp/gc deck 的原理理解采样数据。
必暴露的自定义+运行时指标:go_goroutines / go_gc_duration_seconds / process_resident_memory_bytes;业务侧:连接池活跃数(db/stats)、队列深度、缓存命中率。runtime/metrics 包提供全量运行时探针(新版优先于 expvar)。
runtime/trace(go tool trace):调度延迟、GC 停顿、网络/syscall 阻塞的时间线视图——pprof 是"谁占 CPU",trace 是"时间线上发生了什么"。GC 抖动排障组合:metrics 看到 GC 频率异常 → pprof heap 看分配热点 → go tool trace 看停顿影响(对照 Go GC deck 的三色标记与写屏障)。
上线检查清单:pprof 端口只绑内网(不是公网!信息泄漏风险)· /healthz 与 /readyz 分离 · 优雅关闭 flush 指标与 trace(before exit)· OTel exporter 失败不阻塞业务(异步批处理)。
Troubleshooting
| 症状 | 先查 |
|---|---|
| P99 涨但 QPS 稳定 | 慢查询/慢依赖(trace 定位)、GC(pprof heap) |
| 错误率突增 | 最近变更、依赖健康、限流熔断是否误触发 |
| 内存持续上涨 | goroutine 泄漏(dump)、heap 对比、缓存无淘汰 |
| 全链路超时 | 底座(网络/DNS)、注册中心、配置误发 |
无责复盘(blameless):时间线 → 根因(5 why)→ 改进项(可验证、有 deadline)→ 沉淀为监控/告警/runbook。衡量指标:MTTD(发现时长)与 MTTR(恢复时长)——可观测性建设的成果最终体现在这两个数字上。
Cheat Sheet
| 指标 Metrics | 答"哪里有问题":聚合数字、最便宜、可长期存、无个体细节 → 负责告警 |
| 追踪 Tracing | 答"经过了哪里":span 树还原因果,负责收敛到某一跳 |
| 日志 Logging | 答"到底发生了什么":细节最全、最贵,负责最终定因 |
| 打通的钥匙 | trace_id 同时写进 span、日志行、指标 exemplar —— 才能从趋势图一键钻到日志 |
| Counter / Gauge | 累计只增(请求数)/瞬时可增可减(队列深度、goroutine 数) |
| Histogram ★ | 服务端分桶聚合 → 多实例可算全局 P99;微服务默认选它 |
| Summary ✗ | 客户端算分位,多实例无法聚合——多副本场景别用 |
| 标签基数纪律 | 只放可枚举维度(服务/接口/状态码);user_id 级别交给日志与 trace |
| RED / USE | RED 看服务(QPS/错误率/延迟),USE 找资源瓶颈(使用率/饱和度/错误) |
| 头部采样 | 入口掷骰子决定整条链路留不留 —— 便宜,但会丢掉后来才出错的链路 |
| 尾部采样 ★ | 链路跑完按结果决定:错误 / 慢的全保,正常的再抽样;代价是 Collector 要缓存整链 |
| 组合(推荐) | 头部粗滤 10% + 尾部精筛(错误/慢全留,正常再抽 10%) |
| 最易错的一条 | 指标不受采样影响:RED 三元组必须全量,采样只针对 trace |
| 保底技巧 | trace 被丢也不怕:日志行全量带 trace_id,仍能按 ID 串起时间线 |
| 99.9% / 30 天 | 错误预算 ≈ 43 分钟;99.99% ≈ 4.3 分钟(每多一个 9,预算 ÷10) |
| 预算管理 | 充足→放心发布;<25%→提高审查;耗尽→冻结非稳定性变更 |
| 告警口径 | 按燃烧速率告警(快烧呼人、慢烧工单),不用"CPU>80%"这类噪音源 |
Interview QA
先自己答一遍再往下看——想不起来比看得顺眼记得牢;答不上就翻回上一页速查表。
监控面向已知故障模式:预设阈值、触发告警(CPU 高、错误率高);可观测性面向未知问题:通过丰富的外部输出(结构化日志/多维指标/分布式 trace)让工程师能提出任意新问题并得到答案。监控是可观测性的子集——告警靠监控,定因靠可观测。
拿到慢请求的 trace_id(从日志/网关响应头/采样系统查):打开 span 树看各 span 的时长与父子关系——最深的耗时叶子节点就是瓶颈(DB 查询/RPC/队列等待);span 属性带 SQL/目标地址等细节;若是概率性变慢,用尾部采样捞慢链路 + 多条 trace 对比找共性。整个流程秒级——这就是 trace 的价值。
优点:① 拉不到数据本身就是异常信号(实例挂了不需要心跳超时推断);② 服务端限流可控(拉取频率由 Prometheus 决定,防止被采集打爆);③ 目标发现天然对接服务发现。缺点:短生命周期任务(Job)可能在两次拉取间结束——Pushgateway 补丁;NAT 后的实例不可达需要网关。被问"为什么不用 Push"按这个答。
默认 Histogram:分桶在服务端聚合,多实例可汇总算全局 P99(histogram_quantile),代价是桶粒度是近似值;Summary 在客户端直接算分位(精确)但多实例无法聚合、且分位数集合固定。多副本微服务一律 Histogram——"单实例精确 vs 全局可聚合"是选择的核心权衡。
头部采样在入口随机——只采 1% 时,恰好"出错的链路"有 99% 概率被丢掉;而排障最需要的就是错误/慢链路。尾部采样在链路完整后按结果决策:错误/慢全保+正常抽样。代价:Collector 要缓存整条链路(跨服务聚合 span)才能决策——内存/延迟/复杂度都高于头部采样,所以是组合使用。
① 指标确认(go_goroutines 趋势)→ ② /debug/pprof/goroutine?debug=2 拿全量栈 dump,按阻塞点聚类——几千个 goroutine 卡在同一 ch <- / Lock / HTTP 无超时读,就是泄漏点;③ 常见模式:channel 无缓冲无人消费、锁未释放、HTTP/RPC 缺 context 超时、无限 for+time.Sleep;④ 修复后用 heap+goroutine 对比验证。原理支撑见 channel/sync deck。
Related & References