三、链路追踪原理归纳总结

链路追踪这件事,说白了就是把一次请求或一个操作,从起点到终点走过的所有路径串起来。我刚开始做 ANR 监控时,以为只要抓个堆栈就够了,结果查了好几个问题都查不到根因——后来才意识到,没有链路,你只看到症状,看不到传播路径。

3.1 从一次请求到一条链路

核心概念就三个:Trace、Span、SpanContext。

  • Trace:一次完整的事件流。比如一次用户点击 -> 主线程 dispatch -> 子线程 IO -> 加锁等待。整条线就是一个 Trace。
  • Span:Trace 里的一个独立工作单元。每个 Span 有开始时间、结束时间、操作名称、标签、日志。
  • SpanContext:身份令牌。里面装着 traceId、spanId、采样标记。靠它才能把上下游 Span 串起来。

我习惯把 Span 想象成乐高积木,Trace 就是搭好的模型。每一块积木(Span)都知道自己连接着谁(parentSpanId)。

提示:在 ANR 场景里,Trace 不一定只有一条链。多个线程并发执行时,可能形成 Span 森林。不过我们通常只关心主线程那个 Trace 上挂了哪些副 Span。

3.2 Span 的传播与上下文传递

链路信息要怎么传给下一个 Span?靠的是 传播机制。常见的有两种格式:

协议传播载体示例
W3C Trace ContextHTTP 头:traceparent / tracestate00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
B3HTTP 头:X-B3-TraceId / X-B3-SpanId / X-B3-SampledX-B3-TraceId: 0af7651916cd43dd8448eb211c80319c
Zipkin 多标头多个 HTTP 头与 B3 类似但分拆更细

在 Android 端,我们没有 HTTP 头,那就用 ThreadLocal 或者 协程上下文 传递。我曾经踩过一个坑:子线程里没法拿到主线程的 TraceId,因为 ThreadLocal 不跨线程传递。后来改用 TraceContext 容器,手动在创建线程时注入一次。

注意:不要在跨进程传递时用 B3 的四组头硬拼,容易丢。W3C 标准只有一个头,解析更方便。我建议新项目直接上 W3C。

3.3 采样策略与性能取舍

全量采集所有 Span?你觉得可能吗?一次 ANR 可能产生成百上千个 Span,全采集的话手机都得卡死。所以采样是必须的。

常用四种策略:

  1. 固定采样率:比如 10% 的请求录。简单粗暴,但低流量场景容易漏掉关键 ANR。
  2. 动态采样:根据错误率、延迟等条件动态调整。比如 CPU 飙高时提高采样率。我在项目中实现过,效果不错,但逻辑复杂。
  3. 头采样:在 Trace 开头就决定采样。简单,但万一后面出错了呢?
  4. 尾采样:Trace 全部跑完再决定是否保留。能精准保留错误 Trace,但需要缓冲存储。我比较推荐这个,配合一个滑动窗口的 10% 预算。

核心原则: 采样策略的触发点要放在 Span 创建前,不要在 Span 结束后再检查。否则浪费 CPU 不说,还可能因为来不及丢弃而导致内存泄漏。

3.4 关键埋点时机

链路数据要想有用,得埋在正确的点上。对于 ANR 追踪,我总结出四个必须埋的点:

  • 输入事件分发:dispatchInputEvent 开始和结束。差值就是响应延迟。
  • 广播接收:BroadcastReceiver.onReceive 入口和出口。注意要在主线程。
  • 服务生命周期:Service.onCreate / onStartCommand / onDestroy。特别是 Intent 处理时间。
  • 锁等待:synchronized 或 ReentrantLock 的 acquire 和 release。这个在 ANR 里特别关键。

代码示例(简化版):

void dispatchInputEvent(InputEvent event) {
    // 创建 Span
    Span span = tracer.buildSpan("inputEvent")
                       .withTag("eventType", event.getType())
                       .start();
    try (Scope scope = tracer.activateSpan(span)) {
        // 实际分发逻辑
        handleEvent(event);
    } finally {
        span.finish();  // 结束 Span
    }
}

3.5 链路还原与根因定位

所有 Span 上报到后端后,后端会按 traceId 和 parentSpanId 构建一棵 Span 树。你想想看,一棵树的根 Span 就是主线程的起始事件,下面挂着的子 Span 是每次调用、每次锁等待。哪个子 Span 花了很长时间,直接在树上标红就可以。

我遇到过一个问题:有一个 ANR 在树里显示成两棵独立的树,后来发现是因为我们内部把 traceId 重新生了,导致父子链断裂。所以一定要确保 traceId 在整个 Trace 生命周期内是同一个。

避坑指南:我曾经把 traceId 塞进 Intent 里跨进程传递,结果遇到 PendingIntent 时 traceId 被覆盖了。后来改成了用 ActivityManager 的额外 Bundle 来传。嗯,这地方容易忽略。

下面这张图概括了整个链路追踪的工作流程:

链路追踪原理流程图 用户操作 / 系统事件 Trace创建 + Root Span 初始化 SpanContext 通过 ThreadLocal/协程传递 Span: 输入事件处理 Span: 锁等待/IO 操作 Span: 广播/服务回调 Spans 上报 → 构建 Span 树 → 根因定位 关键点 • 每个 Span 记录 parentSpanId • 采样在创建 Span 前判断 • ANR 时关注耗时最长路径

嗯,以上就是链路追踪的核心原理。记住:Trace 定边界,Span 记录单元,上下文负责传递。有了这三样,再结合合适的采样策略,你就能在 ANR 爆发时迅速找到是哪条路径堵住了。我推荐团队在项目中先实现一个轻量级的手动 Span 植入,跑通后再上自动 AOP 埋点。