把综艺节目里的名场面翻译成微服务故障现场,大致是这样的:一次流量波动后,订单接口像荡秋千一样忽快忽慢;批量查询时,所有上游服务都拉一个公共基础服务来当垫脚石;为了定位问题,团队成员靠打电话、拉群、口头同步逐级确认,和“追踪局靠打电话作弊”没有本质区别;最后复盘时,往往是那个被拉了最多垫脚石的服务承担了所有。综艺有剧本,线上故障没有剧本。真正能让团队从这种状态里走出来的,不是某个人多打几通电话,而是一条贯穿所有调用环节、能被事后回放的全链路追踪系统。
1. 先从“荡秋千”看分布式排查为什么这么难
1.1 一次调用为什么会像荡秋千一样来回牵扯
一个典型的互联网请求,在微服务架构里很少只经过一个服务。用户点击下单,请求先到网关,再到订单服务;订单服务要查询会员信息、扣减库存、调用支付、发送消息,期间还会经过 Redis、数据库、消息队列和第三方接口。任何一个环节超时,都可能触发上游重试;重试又会让下游服务压力更大,于是出现“越修越慢、越慢越查不出来”的抖动。
这种现场让线性排查变得特别难。人脑习惯从单一服务日志出发,按时间顺序读日志,但分布式场景下,同一批请求在不同机器上的时钟、日志级别、打印位置都不一样。某个服务的日志显示“调用库存接口超时”,另一个服务的日志显示“库存接口本身正常”,两边都没说谎,但电话一打就变成了“到底谁有问题”的口水战。真正的事实,需要通过一条把请求串起来的追踪数据来还原。
1.2 打电话式排查的四个典型缺陷
靠打电话和人工查日志定位问题,在服务少、流量小的阶段勉强能跑通,但一旦服务数量超过十个,就会暴露出四个问题。
第一,信息失真。口头传递时,时间点、错误码、参数会被简化甚至带偏,常见结果是最后找到的“根因”根本不是最初的现象。第二,范围不完整。每个人都只看自己负责的服务,无法准确回答“这个请求到底经过了哪些服务”,自然也没法判断是不是某个公共组件把链路拖住。第三,无法回溯。故障发生时没有留下结构化的调用记录,事后想复盘只能靠聊天记录和个人记忆,很难形成能指导改进的证据。第四,归因模糊。没有数据支撑时,大家倾向于把问题算在“平时看起来最忙”或“被依赖最多”的服务头上,这正是“总被拉来当垫脚石的服务承担了所有”的由来。
与其靠人肉补全信息,不如在请求入口处注入一个全局标记,让每次调用自动记录“从哪里来、到那里去、花了多久、成功还是失败”。下面用一个最小 Demo 展示这套机制。
| 对比项 | 打电话式排查 | 全链路追踪 |
|---|---|---|
| 数据粒度 | 口头描述、片段日志 | 每次请求的完整调用树 |
| 覆盖范围 | 取决于参与者记忆 | 所有接入服务的 Span |
| 日志关联 | 人工对时间点 | trace_id 自动关联 |
| 回溯能力 | 聊天记录和个人记忆 | 按 Trace 回放,随时可查 |
| 结果归因 | 依赖讨论和投票 | 依赖耗时、状态和调用关系 |
2. 建立一条贯穿所有环节的追踪链路:核心概念先对齐
2.1 Trace、Span、Context 是什么
先用人话说。Trace 是一条完整请求的“总账单”,记录了这次请求从进入系统到返回给用户的全部过程。Span 是总账单上的每一笔明细,对应一次具体操作,比如调用一次支付接口、执行一条 SQL、消费一条消息。Context 则是贯穿所有明细的“订单号”,它让每一笔明细都知道自己属于哪一次请求。
在技术实现上,每个 Span 都带有 trace_id、span_id、parent_span_id、开始时间、结束时间、属性和状态。Tracer 会根据这些信息,把散落在不同进程里的 Span 拼成一棵调用树。最小结构大致是这样:
order-service /order └── pay-service /pay └── inventory-service /inventory根 Span 是 order-service 的 /order 请求,pay-service 的 /pay 是它的子 Span,inventory-service 的 /inventory 又挂到 /pay 下面。在 Jaeger 或链路追踪 UI 中展示出来就是瀑布图,每一行宽度代表耗时。把“荡秋千”里的每一个来回映射进去,就能看到每一脚踩在哪个服务上。
2.2 跨服务上下文传播怎么工作
光有 Span 还不够。微服务调用会跨越进程边界,必须让下游服务知道“我这次请求属于哪个 trace”,这个机制叫上下文传播。目前使用最多的标准是 W3C Trace Context。上游服务发起 HTTP 请求时,会在请求头里注入一个traceparent头,下游服务读取这个头,就能把新创建的 Span 挂到同一个 trace 下。
一个traceparent值看起来像这样:
00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01各段含义可以按下表理解:
| 字段段 | 示例值 | 含义 |
|---|---|---|
| version | 00 | 版本号,当前常见为 00 |
| trace-id | 4bf92f3577b34da6a3ce929d0e0e4736 | 全局唯一 trace ID |
| parent-id | 00f067aa0ba902b7 | 上游 Span ID |
| flags | 01 | 采样标记,01 表示希望被采样 |
使用 OpenTelemetry 的自动化 instrumentation 时,Flask 端会自动提取这个头,requests 端会自动注入这个头。也就是说,只要两边都接入同一套 SDK,上下文传播不需要手写 header 处理逻辑。这个细节非常关键,很多调用链断掉,都是因为某个服务用了普通 HTTP client 而没有接 instrumentation。
2.3 追踪系统选型思路
OpenTelemetry 负责采集、生成和导出 Trace 数据,但数据最终要落到一个能查询、展示的系统里。常见选择有 Jaeger、Zipkin 和 SkyWalking。选型没有绝对最优,关键看团队技术栈和现有基础设施。
| 方案 | 数据接入 | 适合场景 | 部署复杂度 |
|---|---|---|---|
| Jaeger | OTLP、Jaeger 原生协议 | 需要灵活查看 Trace、轻量自建 | 中,单机 all-in-one 即可演示 |
| Zipkin | Zipkin v2、OTLP | 简单 Trace 展示 | 低 |
| SkyWalking | SkyWalking 自有 Agent 协议 | Java 体系、需要 APM 指标和拓扑 | 高,通常需要 Agent 与后端集群 |
下面 Demo 选择 Jaeger,因为它支持 OTLP 标准协议,Docker 一条命令就能启动,UI 对瀑布图、服务名过滤和 trace 搜索都足够直观。
3. 最小可复现 Demo:三个服务模拟一次故障调用
3.1 环境准备和项目结构
这次 Demo 用三个 Flask 服务模拟一条真实的调用链:order-service 接收用户请求,调用 pay-service;pay-service 先做 1 秒的本地处理,再调用 inventory-service。为了复现“垫脚石被冤枉”的场景,真正的问题被故意放在 pay-service 的本地耗时里,而 inventory-service 本身很快。如果只盯着服务监控,很容易误以为库存服务是瓶颈;通过 Trace,可以看到耗时主要花在支付服务自身。
准备环境:
- Python 3.9 或更高版本
- Docker,用于启动 Jaeger 服务
- pip 安装依赖
目录结构:
trace-demo/ requirements.txt otel.py order_service.py pay_service.py inventory_service.pyrequirements.txt 内容如下。为了减少版本适配问题,这里不锁死版本,安装时以当前稳定版为准:
flask requests opentelemetry-api opentelemetry-sdk opentelemetry-instrumentation-flask opentelemetry-instrumentation-requests opentelemetry-exporter-otlp-proto-grpc安装命令:
pip install -r requirements.txt3.2 初始化 OpenTelemetry 并接入 Flask
创建otel.py,封装初始化逻辑。所有服务共用这个模块,保证service.name不同,但导出端一致:
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 from opentelemetry.instrumentation.flask import FlaskInstrumentor from opentelemetry.instrumentation.requests import RequestsInstrumentor def init_telemetry(app, service_name): resource = Resource.create({"service.name": service_name}) provider = TracerProvider(resource=resource) exporter = OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True) provider.add_span_processor(BatchSpanProcessor(exporter)) trace.set_tracer_provider(provider) FlaskInstrumentor().instrument_app(app) RequestsInstrumentor().instrument() return trace.get_tracer(service_name)这里要注意三件事。第一,Resource.create里的service.name是追踪系统识别服务身份的关键,也是之后在 Jaeger 里按服务过滤的依据。第二,OTLPSpanExporter的 endpoint 指向 Jaeger 的 OTLP gRPC 端口,默认是 4317;如果使用 HTTP 协议则是 4318。第三,BatchSpanProcessor会攒一批 Span 再异步导出,能有效减少 IO,但也意味着刚产生的 Span 不会立刻出现在 UI 里,查看时要稍等几秒。
3.3 三个服务代码
order_service.py:
from flask import Flask import requests from otel import init_telemetry app = Flask(__name__) tracer = init_telemetry(app, "order-service") @app.route("/order") def order(): resp = requests.get("http://localhost:5001/pay", timeout=5) return {"order": "ok", "pay_status": resp.status_code} if __name__ == "__main__": app.run(port=5000)pay_service.py:
import time from flask import Flask import requests from otel import init_telemetry app = Flask(__name__) tracer = init_telemetry(app, "pay-service") @app.route("/pay") def pay(): # 模拟支付服务本地处理耗时,比如加密计算、慢 SQL 或锁等待 time.sleep(1) resp = requests.get("http://localhost:5002/inventory", timeout=3) return {"pay": "ok", "inventory_status": resp.status_code} if __name__ == "__main__": app.run(port=5001)inventory_service.py:
from flask import Flask from otel import init_telemetry app = Flask(__name__) tracer = init_telemetry(app, "inventory-service") @app.route("/inventory") def inventory(): return {"inventory": "ok"} if __name__ == "__main__": app.run(port=5002)运行顺序上,先启动被依赖方,再启动上游会更容易观察。实际项目里,服务注册和发现不会这么简单,但这个最小模型已经能完整展现 Trace 的传播过程。
3.4 启动 Jaeger 和三个服务
先用 Docker 启动 Jaeger:
docker run -d --name jaeger \ -p 16686:16686 \ -p 4317:4317 \ -p 4318:4318 \ jaegertracing/jaeger:latest启动之后,用三个终端分别执行:
python order_service.pypython pay_service.pypython inventory_service.py然后调用接口:
time curl http://localhost:5000/order正常情况下响应会接近 1 秒或略多一点,因为 pay-service 里 sleep 了 1 秒。多次执行可以留下多条 trace:
for i in $(seq 1 10); do curl -s http://localhost:5000/order >/dev/null; done到这里,本地 Demo 的学习闭环已经能跑通。生产环境不能照搬这套单机方案:Jaeger 需要独立部署 collector 和存储,OTLP 上报需要考虑鉴权和 TLS,生产请求量下还要结合采样、多环境隔离和长期数据保留策略一起设计。
4. 用追踪数据回答“到底谁承担了所有”
4.1 打开 Jaeger 并定位一条 Trace
浏览器访问http://localhost:16686,在 Jaeger UI 左侧 Service 下拉框里选择order-service,Operation 可以留空,点击 Find Traces。刚才循环请求产生的 Trace 会按时间倒序出现。
如果没有看到数据,按下面顺序检查:三个服务是否都启动成功;Jaeger 的 4317 端口是否被占用;服务启动时有没有因为端口冲突立刻退出;请求是否真的发到了 order-service;从调用到打开 UI 之间有没有等几秒。
点击任意一条 Trace 后,左侧会展示调用瀑布图。预期看到三层结构:
order-service /order └── pay-service /pay └── inventory-service /inventory4.2 从瀑布图定位真正的耗时节点
在瀑布图中,order-service 的总耗时接近 1 秒,pay-service 的 Span 也接近 1 秒,而 inventory-service 的 Span 只有几十毫秒。如果此时把“订单服务总耗时高、请求慢”误判成“订单服务被库存服务拖垮”,就完全走偏了。正确读法是看每个 Span 的Duration和Self Duration。Jaeger 的 Span 详情里会显示这一项,它表示当前 Span 自身做了多久,不包括子 Span 的时间。
点击 pay-service 的 Span,能看到它自身耗时接近 1 秒,原因是代码里 sleep 的 1 秒被算进了 pay-service 的 Span。inventory-service 虽然“垫在”链路最后,但它只占了很小一段,根因并不是它。这个例子说明,追踪系统给出的不是一句“某个服务慢”,而是一条可展开的时间线。真正能回答“谁承担了所有”的,是时间线里耗时最大且先出现异常的那一段。
4.3 用 trace_id 把调用链和日志串起来
演示环境里可以直接看瀑布图,但生产环境通常还需要回到日志中看具体异常。养成好习惯,日志里一定要输出 trace_id。在 pay_service.py 里可以加一段获取当前 trace_id 的代码:
from opentelemetry import trace span = trace.get_current_span() ctx = span.get_span_context() if ctx.is_valid: print(f"trace_id={format(ctx.trace_id, '032x')} span_id={format(ctx.span_id, '016x')}")把输出格式统一成 JSON 后,日志系统可以按 trace_id 检索。出现告警时,研发拿到一个 trace_id,就能在日志平台和各服务日志里查出同一条请求的所有上下文。这个操作比一个个服务搜索日志、再打电话对时间点要准确得多。
5. 追踪接入后可复用的排查链路
5.1 一次告警后的标准排查顺序
接入链路追踪后,不要立刻回归旧的排查习惯。推荐按下面的顺序处理线上问题:
- 先拿到入口信息:告警里通常会包含接口、时间、业务侧 trace_id;如果没有 trace_id,按服务和最近时间段搜索。
- 在追踪系统找到对应 Trace,先看整体调用链是否完整。
- 从入口 Span 逐步向下看每个子 Span 的状态和耗时,优先找状态为 ERROR 的 Span。
- 对耗时异常的 Span,查看 Self Duration,确认耗时是发生在该服务自身还是等待子 Span。
- 打开 Span 的 Tags、Logs、Events,记录是否有错误堆栈、超时信息、依赖地址。
- 用 trace_id 回到日志平台,查该服务内部具体代码路径。
- 结合基础设施指标,判断是否伴随 CPU 飙高、连接池耗尽、磁盘延迟等资源问题。
- 修复后对照历史 Trace,确认同一条链路的耗时是否恢复。
这套顺序的核心是不猜。每一步都基于数据缩小范围,而不是先选一个“看起来像根因”的服务再去找证据。
5.2 排错清单:从现象到处理
实际排查时,可以直接对照下表:
| 问题现象 | 可能原因 | 看哪里 | 处理建议 |
|---|---|---|---|
| 接口整体变慢 | 上游服务本地处理慢 | 入口 Span 下每个子 Span 的耗时 | 定位 Self Duration 最大的 Span |
| 调用链断裂,只有入口一个 Span | 跨服务传播未生效或未接入 instrumentation | 请求头是否有 traceparent | 检查 HTTP client 是否接入 OTel,并配置 Propagator |
| Trace 中有 ERROR 但日志无异常 | HTTP 状态码非 2xx 被标记为错误 | Span Status 和 Events | 在业务边界明确哪些状态码该作为错误 |
| 服务在 UI 里找不到 | service.name 配置错误或 exporter 不通 | 服务启动日志是否报导出错误 | 检查 Resource 和 endpoint |
| 某些请求没有 Trace | 采样率过低或头部采样被拒绝 | 查看 Sampler 配置 | 错误全采、慢请求提高采样率 |
生产环境里,这条链路还需要和告警平台打通。理想状态下,告警消息里直接附带 trace_id,打开就能跳转到对应 Trace,能省掉大量人工找数据的时间。
6. 常见坑:为什么你的追踪图经常断在半路
6.1 跨服务传播失效,调用链断裂
现象是 Jaeger 里只出现单个服务的 Span,下游请求没有挂在同一个 Trace 下。常见原因是请求穿过了一个没有接入 instrumentation 的组件,比如自定义的 HTTP 客户端、RPC 框架、消息队列。另一个常见场景是网关层自行创建了新 Trace,没有透传上游traceparent。
检查时,在服务入口打印请求头,确认是否携带traceparent;如果携带了但还是新建 Trace,就要看服务端是否启用了 extract 逻辑。解决方式比较简单:统一使用官方提供的 instrumentation 库;如果用的是框架自带的 Client,需要手动调用trace.set_span_in_context并注入 Context。对于消息队列场景,需要把 Trace Context 放入消息 headers 中。
6.2 服务名不统一,Trace 全成了乱码
现象是同一个服务在 UI 中出现pay、pay-service、pay_service等多种名字,导致无法按服务聚合。服务名看似小事,实际会影响后续所有按服务维度做的统计和告警。建议团队维护一份服务名注册表,统一使用“业务名-服务名”的小写中划线格式,通过部署平台环境变量统一注入service.name,而不是每个开发自己在代码里写死。
6.3 采样率设置不合理,现场丢了
很多团队为了控制存储成本,把采样率设得很低,比如千分之一。这样平时没问题,但故障发生时的 Trace 可能因为没被采样而完全看不到。推荐做法是错误 Trace 全采,慢请求提高采样率,普通流量使用低比例采样。如果使用 OpenTelemetry 的尾部采样处理器,可以根据错误状态、耗时阈值、业务属性决定是否保留 Trace,把成本花在真正有排查价值的数据上。
6.4 只看 Span 总耗时,忽略 Self Duration
这个坑与本文开头的“垫脚石承担所有”高度相关。看到某个下游 Span 耗时很高,就直接判定是该服务的问题,是典型的误读。Span 的总耗时包含所有子 Span,真正要关注的是当前 Span 自身耗时。比如 pay-service 的 Span 耗时高,很可能是因为它在等待 inventory-service;但如果没有展开看,就会把库存服务当成根因。排查时一定要点开 Span,对比 Duration 和 Self Duration,并看子 Span 的分布。
7. 让数据说话:故障复盘里如何不冤枉“垫脚石”服务
7.1 复盘时用追踪证据回答四个问题
故障复盘最容易出现的不是“没有结论”,而是“结论靠投票”。引入链路追踪后,可以让复盘围绕四个问题展开。
第一个问题:哪一个请求失败了?不能只写“订单接口 14:30 开始失败”,要给出具体的 trace_id、入口服务、用户维度或商户维度。第二个问题:这个请求经过了哪些服务?用调用瀑布图回答,而不是靠每个人回忆。第三个问题:时间到底花在哪里?要用每个 Span 的 Duration 和 Self Duration 说话,区分“哪个服务最慢”和“哪个服务是根因”。第四个问题:哪里先出现错误?按 Span 执行顺序看第一个 ERROR 状态,而不是只看最后表现明显的服务。
这四个问题回答完毕,根因范围通常能收敛到某个具体代码路径或资源瓶颈,而不是“某个服务能力不足”。
7.2 从“谁承担所有”到“根因共担”的复盘模板
复盘文档可以按下面这张表组织:
| 复盘项 | 需要回答的问题 | 所需追踪证据 |
|---|---|---|
| 现象 | 用户或监控看到了什么 | 入口 Span 的状态、耗时、错误信息 |
| 影响范围 | 哪些服务和请求受影响 | 同 trace 下涉及的服务名、Span 数量 |
| 调用路径 | 请求实际经过哪些节点 | Trace 瀑布图 |
| 耗时分布 | 时间消耗在哪一段 | 每个 Span 的 Duration 与 Self Duration |
| 异常起点 | 第一个失败的节点 | Span 状态、Events、日志异常堆栈 |
| 根因判断 | 是逻辑、依赖、资源还是配置 | 结合 Trace、日志、指标交叉确认 |
| 改进项 | 如何避免再次发生 | 新增监控、调整超时重试、加缓存、优化代码 |
这个模板的价值在于,它把“责任”从个人或单个服务转移到了“根因”和“改进项”上。即使某个公共服务确实被大量依赖,也必须有 trace 证明它才是延迟瓶颈;反过来,如果 trace 显示上游重试风暴导致公共服务过载,那根因就在上游的调用策略。
7.3 团队接入全链路追踪时的最佳实践清单
最后给一份可以直接拿去落地的清单:
- 服务名统一:维护服务名注册表,禁止在代码里散落多种写法。
- 入口出口全覆盖:网关、HTTP、RPC、消息队列、数据库访问都要接入 instrumentation。
- 日志关联:所有服务日志输出统一格式,包含 trace_id 和 span_id。
- 错误状态明确:业务失败和框架异常都要显式标记 Span Status。
- 依赖信息补全:数据库 Span 记录表名、SQL 摘要、Redis 命令和 key 前缀。
- 采样策略分级:错误全采、慢请求高比例采样、普通请求低比例采样。
- 复盘模板固定:每次故障文档都带 trace_id 和瀑布图截图,结论必须引用证据。
这套清单不需要一次全部完成。可以先从核心链路开始,接入一个公共网关和两个核心服务,跑通 trace_id 与日志关联;再逐步扩展到消息队列、数据库和更多服务。链路追踪只是可观测体系的一部分,最终还要和指标、日志、告警联动,才能形成完整的故障定位闭环。
综艺里那个总是被拉来当垫脚石的角色,最后承担了所有,是因为剧本需要戏剧冲突。但真实的分布式系统里没有剧本。一次故障该由谁承担责任,不该取决于谁被依赖得多,也不该取决于谁打电话声音大,而应该由一条条真实的调用数据来决定。从最小 Demo 开始,把 trace_id 打印进日志,把调用链展示给团队,以后再遇到“荡秋千”式的线上抖动,就能少一点人肉八卦,多一点证据链。