Administrator
发布于 2026-08-30 / 43 阅读
0
0

Trace 体系深度实战:从 Perfetto 采集到全链路追踪 ID 贯穿

系列文章《客户端可观测性进阶实战》第 1 篇
推荐阅读时间:10 分钟

前言

之前《客户端质量与稳定性实战》系列讲过可观测性的基础概念,但那篇更多是"是什么"层面的科普。实际线上排障时,团队里问得最多的还是这种问题:

  • 用户反馈"点了一下就卡了",你能看到什么?

  • 启动慢了 800 毫秒,到底慢在 Application 还是 ContentProvider?

  • 一个网络请求从发起到回调用了 3 秒,中间经过了几层拦截器?

这些问题靠传统的日志打点很难回答。你需要的是时间线级别的因果关系——每一步做了什么,花了多久,串起来看是什么样。这就是 Trace 体系要解决的事。

这个系列三篇分别讲 Trace、Metric、Log 三条线,最后打通三支柱。第一篇先聊 Trace:

  • Android Trace API 底层是怎么工作的

  • Perfetto 怎么采集和分析 trace 文件

  • 怎么写自定义 Trace 段,让业务链路在 trace 里可见

  • 全链路 Trace ID 怎么从网络层贯穿到 DB 层

一、Android Trace API 原理

1.1 Trace API 不等于 Log

很多开发者第一次接触 android.os.Trace 时会困惑:它和打日志有什么区别?

区别在数据结构。Log 是离散的事件点,没有时间跨度概念。Trace 的核心数据结构是 slice(切片):每一段都有 begin 和 end,自带耗时信息,可以被时间轴排列。

// 离散日志:只知道"发生了",不知道多久
Log.d("App", "loadConfig start")
loadConfig()
Log.d("App", "loadConfig end")

// Trace slice:自带耗时,可在时间轴上排列
Trace.beginSection("loadConfig")
loadConfig()
Trace.endSection()

Trace.beginSection 在底层调用了 atrace 机制,往内核的 trace pipe 写入一条 tracing_mark_write 记录。Perfetto 或 systrace 工具读取这些记录后,能在时间轴上还原出每个 slice 的起止时间和嵌套关系。

1.2 beginSection 的限制

Trace.beginSection 有几个容易踩的限制:

限制

说明

影响

必须同线程配对

begin 和 end 必须在同一线程调用

跨线程异步任务无法使用

只有一个 slot

同时只能有一个 active section

嵌套需手动管理栈结构

线程名固定

slice 归属调用线程

无法关联到业务名

API 29+ async

beginAsyncSection 支持跨回调

低版本不可用

单 slot 的限制意味着你不能这样写:

// 错误:第二个 begin 会覆盖第一个
Trace.beginSection("fetchToken")
fetchToken()
Trace.beginSection("parseResponse") // 第一个 slice 被截断
parseResponse()
Trace.endSection()
Trace.endSection()

正确做法是嵌套调用,保证 end 和 begin 严格配对:

Trace.beginSection("fetchToken")
try {
    fetchToken()
    Trace.beginSection("parseResponse")
    try {
        parseResponse()
    } finally {
        Trace.endSection()
    }
} finally {
    Trace.endSection()
}

1.3 API 29 引入的 Async Trace

Android 10(API 29)引入了 Trace.beginAsyncSection 和 Trace.endAsyncSection,解决了跨回调场景的 trace 问题:

private val traceCookie = Random.nextInt()

// 发起异步请求前打点
Trace.beginAsyncSection("networkRequest", traceCookie)

apiClient.request(url) { response ->
    // 回调线程结束打点
    Trace.endAsyncSection("networkRequest", traceCookie)
    handleResponse(response)
}

cookie 是一个整数标识,用于匹配 begin 和 end。同一个 cookie 的 begin/end 会被 Perfetto 合并为一个完整的 slice,即使它们发生在不同线程。

二、Perfetto 采集与分析

2.1 Perfetto 是什么

Perfetto 是 Google 推出的统一 trace 采集和分析工具,取代了老的 systrace.py。它的架构分三层:

┌─────────────────────────────────────────────┐
│              Perfetto UI                     │
│         (https://ui.perfetto.dev)             │
├─────────────────────────────────────────────┤
│           Trace Processor                    │
│        (SQL 查询引擎,WASM)                   │
├─────────────────────────────────────────────┤
│           Trace Config                       │
│    (数据源配置:ftrace、atrace、counter)       │
└─────────────────────────────────────────────┘

Perfetto 可以同时采集内核 ftrace、应用层 atrace、系统服务 trace 和性能计数器,全部合并到一个 trace 文件里。这比老 systrace 的覆盖面大很多。

2.2 命令行采集

在 Android 10+ 设备上,Perfetto 已经内置于系统。通过 perfetto 命令行可以采集:

# 定义采集配置
cat > /data/misc/perfetto-config/trace_config.txt << EOF
buffers {
    size_kb: 65536
    fill_policy: RING_BUFFER
}
data_sources {
    config {
        name: "linux.ftrace"
        ftrace_config {
            ftrace_events: "sched/sched_switch"
            ftrace_events: "sched/sched_waking"
            atrace_categories: "view"
            atrace_categories: "am"
            atrace_categories: "sched"
            atrace_apps: "*"
        }
    }
}
data_sources {
    config {
        name: "linux.process_stats"
    }
}
duration_ms: 10000
EOF

# 执行采集
adb shell perfetto -c /data/misc/perfetto-config/trace_config.txt \
    -o /data/misc/perfetto-traces/trace.perfetto-trace

# 拉取 trace 文件
adb pull /data/misc/perfetto-traces/trace.perfetto-trace .

关键配置项:

配置

作用

推荐值

atrace_categories

决定采集哪些系统模块的 trace

view, am, sched, wm

atrace_apps

采集哪些应用的 trace

星号表示全部,或指定包名

duration_ms

采集时长

10-30 秒,太长文件会很大

buffers.size_kb

环形缓冲区大小

65536 起步,复杂场景调到 262144

2.3 Perfetto SQL 分析

Perfetto UI 内置了一个 SQL 查询引擎,能直接对 trace 文件做结构化查询。这比在时间轴上手动找问题高效得多。

比如查找耗时超过 500 毫秒的 slice:

SELECT
    name,
    ts,
    dur / 1000000.0 AS dur_ms,
    thread_name,
    process_name
FROM slice
JOIN thread_track ON slice.track_id = thread_track.id
JOIN thread USING (utid)
JOIN process USING (upid)
WHERE dur > 500000000
ORDER BY dur DESC
LIMIT 20

一个更实用的查询:统计某方法被调用的次数和平均耗时:

SELECT
    name,
    COUNT(*) AS call_count,
    SUM(dur) / 1000000.0 AS total_ms,
    AVG(dur) / 1000000.0 AS avg_ms,
    MAX(dur) / 1000000.0 AS max_ms
FROM slice
WHERE name LIKE 'loadConfig%'
GROUP BY name
ORDER BY total_ms DESC

这种 SQL 查询可以在 CI 里自动化执行。把 trace 文件丢给 trace_processor_shell,跑一段 SQL,输出 JSON,就能在流水线上对性能做卡口检查。

三、自定义 Trace 段实战

3.1 业务链路可见性

系统自带的 atrace category 覆盖了 view 渲染、Activity 切换、GC 等系统行为,但业务代码在 trace 里是隐形的。如果不在关键路径上埋 Trace.beginSection,打开 Perfetto 只能看到一堆系统 slice,业务逻辑完全淹没在里面。

推荐在以下位置埋 Trace:

场景

推荐命名

说明

Application.onCreate

App.onCreate

含所有 SDK 初始化

ContentProvider.onCreate

CP:init:{providerName}

第三方 SDK 偷启动的定位

页面渲染首帧

Activity:firstFrame:{name}

onCreate 到第一帧 draw

网络请求

Net:{apiName}

含 DNS、连接、请求、解析

DB 操作

DB:{tableName}:{op}

读写定位

JSON 解析

Parse:{dataType}

大 JSON 解析容易是瓶颈

3.2 封装一个安全的 Trace 工具

直接用 Trace.beginSection 有两个问题:一是异常路径忘记 endSection 会导致 trace 污染,二是 API 29 以下不支持 async trace。封装一层:

object TraceUtil {

    fun trace(name: String, block: () -> Unit) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            val cookie = name.hashCode()
            Trace.beginAsyncSection(name, cookie)
            try {
                block()
            } finally {
                Trace.endAsyncSection(name, cookie)
            }
        } else {
            Trace.beginSection(name)
            try {
                block()
            } finally {
                Trace.endSection()
            }
        }
    }

    inline fun <T> traceSync(name: String, block: () -> T): T {
        Trace.beginSection(name)
        try {
            return block()
        } finally {
            Trace.endSection()
        }
    }
}

使用方式:

// 同步场景
val config = TraceUtil.traceSync("loadConfig") {
    repository.loadRemoteConfig()
}

// 异步场景(API 29+ 自动走 async)
TraceUtil.trace("fetchUserProfile") {
    api.getUserProfile(userId) { profile ->
        // 回调在 trace 内
    }
}

3.3 Trace 采样策略

线上不应该对所有用户开启 Trace。atrace 本身有性能开销,每个 begin/end 都会往内核 buffer 写数据。推荐两级策略:

object TraceSampler {
    // 采样比例,从远程配置下发
    @Volatile var sampleRate = 0.0f

    fun shouldTrace(tag: String): Boolean {
        if (sampleRate <= 0.0f) return false
        // 关键路径强制采样
        if (tag.startsWith("App.onCreate") || tag.startsWith("Activity:firstFrame")) {
            return true
        }
        return Random.nextFloat() < sampleRate
    }
}

线上默认 sampleRate = 0.0,只在需要排查问题时通过远程配置调到 0.01(1% 采样)或更高。关键路径(启动、首帧)强制采样,确保每次都能采到。

四、全链路 Trace ID 贯穿

4.1 为什么需要 Trace ID

单机 trace 能回答"这个方法耗时多久",但线上问题往往是跨进程、跨网络的。用户点了一个按钮,触发了网络请求,请求打到服务端,服务端查了缓存又查了 DB,最后返回数据渲染到页面上。这一整条链路如果只有一个端侧 trace,服务端的耗时就是黑洞。

全链路 Trace ID 的思路:在请求发起时生成一个唯一 ID,贯穿客户端到服务端的整条链路。所有日志、Metric、Trace 段都带上这个 ID,排查时用一个 ID 串联所有数据。

4.2 客户端生成 Trace ID

object TraceIdGenerator {
    private val counter = AtomicLong(0)

    fun generate(): String {
        val ts = System.currentTimeMillis()
        val seq = counter.incrementAndGet() and 0xFFFF
        val pid = Process.myPid() and 0xFFFF
        // 格式:时间戳-进程ID-序号,保证同设备内唯一
        return String.format("%x-%04x-%04x", ts, pid, seq)
    }
}

4.3 注入 HTTP 请求头

在 OkHttp Interceptor 里注入 Trace ID,同时把端侧的 trace 信息也带过去:

class TraceInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val traceId = TraceIdGenerator.generate()
        val parentSpanId = SpanContext.currentSpanId()

        val request = chain.request().newBuilder()
            .header("X-Trace-Id", traceId)
            .header("X-Trace-Parent", parentSpanId)
            .header("X-Trace-Source", "android")
            .build()

        Trace.beginSection("Net:${request.url.encodedPath}")
        try {
            val response = chain.proceed(request)
            // 记录响应码到 trace 参数
            Trace.setCounter("net_status", response.code.toFloat())
            return response
        } finally {
            Trace.endSection()
        }
    }
}

服务端收到请求后,从 header 里取出 X-Trace-Id,在自己的 trace 体系里复用这个 ID。这样端侧和服务端的 trace 就能通过同一个 ID 关联起来。

4.4 Trace ID 贯穿到 DB 层

如果客户端有本地数据库操作,Trace ID 同样要贯穿:

class TraceRoomCallback(
    private val traceIdProvider: () -> String
) : RoomDatabase.Callback() {

    override fun onOpen(db: SupportSQLiteDatabase) {
        val traceId = traceIdProvider()
        // 用 PRAGMA 设置自定义参数,部分 SQLite 版本支持
        db.execSQL("PRAGMA application_id = ${traceId.hashCode() and 0xFFFFFFFF}")
    }
}

更实际的做法是在每次 DB 事务开始时,把 Trace ID 写入一张专门的 trace 表,后续查询日志时通过 trace ID 关联。

4.5 多线程传递

Trace ID 在多线程场景下需要通过 ThreadLocal 或 Kotlin Coroutines 的 CoroutineContext 传递:

class TraceContextElement(
    val traceId: String
) : AbstractCoroutineContextElement(Key) {
    companion object Key : CoroutineContext.Key<TraceContextElement>
}

// 发起协程时注入
suspend fun fetchWithTrace(url: String) = withContext(
    TraceContextElement(TraceIdGenerator.generate()) + Dispatchers.IO
) {
    val traceId = coroutineContext[TraceContextElement]?.traceId
    Trace.beginSection("Net:${url.takeLast(30)}")
    try {
        val response = api.get(url, traceId)
        handleResponse(response)
    } finally {
        Trace.endSection()
    }
}

五、实战案例:启动慢 800 毫秒定位

这是一个真实场景。线上监控发现某机型冷启动 P95 从 2.1s 涨到 2.9s,但不知道慢在哪。团队用 Perfetto 采集了一次冷启动 trace:

App.onCreate (2100ms)
├── CP:init:bugly (45ms)
├── CP:init:tencent-push (180ms)     ← 可疑
├── CP:init:third-pay-sdk (320ms)   ← 可疑
├── initKoin (85ms)
├── initNetwork (120ms)
└── initDatabase (95ms)

在 Perfetto UI 里看到 CP:init:third-pay-sdk 占了 320ms,展开后发现其中 260ms 花在一个 init 方法内部的同步网络请求上。第三方 SDK 在 ContentProvider 里偷偷做了一次 HTTP 初始化。

定位后,把第三方 SDK 的初始化推迟到首页渲染完成后异步执行,冷启动回到 2.1s。

如果没有 Perfetto trace,靠日志打点很难发现 ContentProvider 里的隐藏网络请求——因为那不是你写的代码,你根本不知道该在哪打点。

小结

Trace 体系是可观测性三支柱里时间线最完整的一条。它和 Log、Metric 的区别在于:Log 回答"发生了什么",Metric 回答"多少次、多久",Trace 回答"什么导致了什么、串起来什么样子"。

这篇覆盖了 Trace API 原理、Perfetto 采集分析、自定义 trace 段和全链路 ID 贯穿。下一篇讲 Metric 体系——怎么把性能数据变成可量化的指标看板。

参考阅读


评论