3.4 链路追踪:一次请求慢在哪一环

场景:用户说下单慢,全员都说不是我

网关上线后的第二周,客服接到反馈:下单偶尔要等七八秒。我们五个人的群开始互相甩锅——前端说接口返回慢,订单服务说库存查询不归我,商品服务说数据库看着没压力,运维说 CPU 内存都正常。吵了一圈,谁也拿不出证据。

这就是没有链路追踪的典型场面:一次请求经过网关、订单、商品、用户四个节点,日志分散在四台 CVM 上,用 grep 串时间线基本不可能。这件事的背景在 可观测性建设 里有展开,本节讲我们怎么把 trace 真正落到 gRPC 链路里。

从日志到 trace:先看清调用拓扑

引入 OpenTelemetry 之前,我们靠 request_id 在每条日志里打 grep,能拼出大致顺序,但耗时分布完全靠猜。上了追踪系统后,第一次看到完整的火焰图,全组沉默了三秒:原来 60% 的耗时花在订单服务里一段多余的库存双写校验上。这段代码是当初为了稳加的双保险,拆分后两个调用落在不同服务,重复校验每次都要跨网络查一遍,纯属浪费。

先列一张观测手段对照表,方便确定各自的位置:

手段 回答的问题 成本 局限
结构化日志 这里发生了什么 低 需要人工串时间线
指标监控 整体是否健康 低 看不到单次请求细节
链路追踪 一次请求慢在哪一环 中 依赖采样和埋点质量
告警 现在该不该处理人 低 依赖前两者

落地选型我们用了开源的 OpenTelemetry SDK 加自建 Jaeger,日志再统一投递到 CLS,和 统一日志收集 的方案打通,一条 trace_id 能在两个系统里互相跳转。收集器那边单独占一台 CVM,成本可控,类型选择和扩容思路可以参考 CVM 实例选型清单。

还有一个决策点是采样策略。常见有三种做法,我们最终的组合方案如下:

采样策略 实现 优点 缺点
固定比例采样 配置百分比 实现简单 低流量时丢关键样本
慢调用强制采样 超过阈值的全采 直击问题 存储压力大
尾部采样 等请求结束再决定 精准保留错误链路 需要额外组件

我们的组合是:正常链路按比例采样,错误和慢链路强制全采。这样存储成本可控,排查时又不缺现场。

上线第一周我们就靠这套东西定位了一个经典问题:接口偶发慢三秒,看监控曲线毫无规律,看 trace 才发现是某次 redis 调用超时重试,重试机制本身放大了耗时。没有追踪之前,这类问题只能靠玄学复现。

落地:context 串起整条链路

Go 服务接入 OpenTelemetry 只需三步,整体不算复杂,一个实习生两天就搞定了三个服务的接入。第一步初始化 tracer provider,这一步决定 trace 数据往哪发、服务名是什么:

func setupTracing(ctx context.Context) error {
    exp, err := otlptracegrpc.New(ctx,
        otlptracegrpc.WithEndpoint("otel-collector:4317"),
        otlptracegrpc.WithInsecure(),
    )
    if err != nil {
        return err
    }
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exp),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceNameKey.String("order-svc"),
        )),
    )
    otel.SetTracerProvider(tp)
    return nil
}

第二步,gRPC 服务端和客户端各加一个拦截器。这一步是关键:trace context 必须自动跨服务传播,不然每段 trace 都是孤岛:

srv := grpc.NewServer(
    grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
conn, err := grpc.Dial(
    target,
    grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)

第三步,业务代码里打 span。我们只给关键环节加,埋点过多反而干扰判断:

ctx, span := otel.Tracer("order").Start(ctx, "checkStock")
defer span.End()
stock, err := productClient.GetProduct(ctx, productID)

HTTP 网关那一层也不能漏。客户端 HTTP 进来时,otelmux 中间件会从 traceparent header 里提取上游上下文,生成新的根 span;调用方没有传 header 就新建一条,保证从入口起每一段都在同一棵树下:

r.Use(otelmux.Middleware("api-gateway"))

同时把 trace_id 写进结构化日志,这样 CLS 里的每条日志都带着同一 id,排查时先看 trace 定位环节,再回 CLS 捞明细,这套流程和 监控告警体系 配合使用。

踩坑记录

第一个坑就是断链。上线后 trace 只剩零星几段,原因是有个接口绕过了 gRPC 拦截器直接走了 http client,context 里的 trace 信息没传过去。后来定了死规矩:所有出站调用必须用同一个 ctx,code review 重点查这个。

第二个坑是采样率。默认按比例采样,大促时 1% 也能看到大量慢 trace,反而找到了几个偶发问题;但平时低流量时一些只出现几次的报错被采样掉了,排查时正好缺那一条。最后的做法是慢调用和错误链路强制全采,正常链路按比例。

第三个坑是 span 太多。有个同事把每个数据库查询都包一层 span,一个请求展开 300 多个节点,Jaeger 页面直接卡死。后来约定数据库只在事务级别打 span, span 总数控制在 20 个以内。

第四个坑是时钟。有次 trace 时间线显示子 span 比父 span 还早,吓出一身冷汗,其实是某台 CVM 的 NTP 没同步。所有机器统一装 chrony 之后正常了,机器初始化检查清单也跟着更新了一条。

第五个坑是异步任务断链。下单后发短信走的是 RocketMQ 异步消费,trace context 跨消息队列默认不传递,消费侧一看是全新的根 span,和下单链路完全对不上。解决办法是发送消息时把 context 序列化进消息属性字段,消费端反序列化后接上 context,消息队列的更多玩法见 RocketMQ 削峰实战。

第六个坑是 trace 存储膨胀。全量采集两周后 Jaeger 后端的存储占用翻了三倍,空间告急。除了调低正常链路采样率,还加了存储保留期策略:原始明细保留 7 天,聚合统计保留 90 天,既能满足日常排查,也满足长期趋势分析的需求,数据管理思路和 成本治理 一脉相承。

小结

追踪系统上线一个月后,我们做了一次统计:p99 接口耗时从 800 毫秒压到 260 毫秒,定位一次慢调用的平均时间从半天缩到十分钟。更重要的是会议风气变了——以前甩锅会,现在直接把 trace 图甩在群里,谁的问题谁领走。

回过头看,可观测性建设的顺序应该排在拆分之后、业务复杂化之前:拆分初期服务少、链路短,正是埋点成本最低的时候。等到五六个服务再补,历史代码每一处都要回填埋点,痛苦得多。另外一个建议是接入时先抓准服务名与版本号两个资源属性,标错会导致同一服务的 trace 被拆成两份,图上频繁断链,查起来误导性极强。网关、注册中心、gRPC 加上 trace,服务间通信这条线算是闭环了,下一篇要面对的是拆库后的数据一致性难题。Collector 所在的机器同样可以走 腾讯云 CVM 新用户特惠。

延伸阅读