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

Metric 指标体系:端侧性能采集、采样策略与监控看板实战

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

前言

上一篇讲了 Trace,它能还原一次操作的时间线。但 Trace 有个问题:它太重了。你不可能对每个用户每次操作都跑 Perfetto,线上只能采样 0.1% 的设备。

Metric 体系解决的是另一面的问题:全局趋势。启动耗时分布是什么样的?某页面 P95 渲染时间涨了多少?某接口的错误率最近有没有抖动?这些需要大量数据汇总后才能看出来。

这篇聊四个话题:

  • 端侧性能指标怎么定义和分类

  • 采集框架怎么搭,采样和聚合策略怎么做

  • SLO 怎么定,告警怎么配

  • 启动、页面、接口三维指标看板实战

一、指标分类与定义

1.1 四类核心指标

客户端可观测性的指标通常分四类:

类型

例子

数据形态

告警方式

性能型

启动耗时、页面渲染、接口延迟

分布(P50/P95/P99)

阈值 + 趋势

错误型

崩溃率、ANR 率、接口错误率

比率

比率超阈

资源型

内存峰值、CPU 占用、流量消耗

分布 + 时序

峰值超阈

业务型

首页加载成功率、支付转化率

比率

同比环比

性能型指标最关键也最容易做错。下面重点讲这类。

1.2 为什么用分位数而不是平均值

平均值掩盖尾部问题。假设启动耗时 100 次中,95 次 1 秒,5 次 5 秒,平均值 1.2 秒看起来还行。但那 5% 的用户体验极差——他们等了 5 秒。

分位数指标才能暴露这种问题。客户端至少关注 P50(中位数)、P95、P99 三个值。线上监控看板应该同时画三条线:

启动耗时趋势(单位:ms)

P99  ─── 3200 ─── 3100 ─── 3400 ─── 4200 ─── 3900 ───  ← 突增!
P95  ─── 1800 ─── 1850 ─── 1900 ─── 2100 ─── 2050 ───
P50  ─── 1100 ─── 1150 ─── 1180 ─── 1200 ─── 1180 ───
      08/20    08/21    08/22    08/23    08/24

P99 突增但 P95 和 P50 稳定,说明是一小部分用户出了极端问题(可能是低端机型或弱网)。如果只看平均值,这个信号会被大量正常数据稀释掉。

1.3 指标命名规范

指标命名不一致是团队协作中最常见的坑。推荐 {域}.{子域}.{指标名} 三段式:

object Metrics {
    // 启动域
    const val APP_COLD_START = "app.startup.cold"
    const val APP_WARM_START = "app.startup.warm"

    // 页面域
    const val PAGE_RENDER = "page.render.duration"
    const val PAGE_FIRST_FRAME = "page.render.first_frame"

    // 网络域
    const val NET_REQUEST = "net.request.duration"
    const val NET_ERROR_RATE = "net.request.error_rate"
    const val NET_DNS = "net.dns.duration"

    // 业务域
    const val PAY_SUCCESS_RATE = "biz.pay.success_rate"
}

统一命名后,看板查询和告警配置就能复用,不用每次记 5 种写法。

二、采集框架设计

2.1 指标采集器封装

一个生产可用的指标采集器需要处理:计时、采样、聚合、上报。核心接口:

interface MetricCollector {
    // 记录一个耗时值
    fun recordTiming(name: String, durationMs: Long, tags: Map<String, String> = emptyMap())

    // 记录一个计数
    fun increment(name: String, value: Long = 1, tags: Map<String, String> = emptyMap())

    // 记录一个量规值(如内存占用)
    fun recordGauge(name: String, value: Double, tags: Map<String, String> = emptyMap())

    // 上报
    fun flush()
}

2.2 计时采集的实现

计时采集最常用。一个基于 SystemClock.elapsedRealtimeNanos 的实现:

class TimingScope(
    private val collector: MetricCollector,
    private val name: String,
    private val tags: Map<String, String>
) : Closeable {
    private val startNanos = SystemClock.elapsedRealtimeNanos()

    override fun close() {
        val durationMs = (SystemClock.elapsedRealtimeNanos() - startNanos) / 1_000_000
        collector.recordTiming(name, durationMs, tags)
    }
}

// 使用方式
MetricCollector.run {
    TimingScope(this, Metrics.APP_COLD_START, mapOf("channel" to "huawei")).use {
        // 启动流程
    }
}

SystemClock.elapsedRealtimeNanos 而不是 System.currentTimeMillis 的原因:前者基于单调时钟,不受系统时间调整影响。用户手动改系统时间或 NTP 同步不会导致计时长度的跳变。

2.3 内存聚合桶

线上不能每产生一个指标就上报一次网络请求。标准做法是在端侧维护一个聚合桶,按固定时间窗口或条数阈值批量上报:

class MetricBucket(
    private val flushIntervalMs: Long = 30_000L,
    private val maxBufferSize: Int = 500
) {
    private val buffer = ConcurrentHashMap<String, MutableList<MetricRecord>>()
    private val lastFlushTime = AtomicLong(SystemClock.elapsedRealtime())

    fun add(record: MetricRecord) {
        val list = buffer.getOrPut(record.name) { Collections.synchronizedList(mutableListOf()) }
        list.add(record)

        val shouldFlush = list.size >= maxBufferSize ||
            SystemClock.elapsedRealtime() - lastFlushTime.get() > flushIntervalMs

        if (shouldFlush) {
            flush()
        }
    }

    fun flush() {
        val snapshot = buffer.toMap()
        buffer.clear()
        lastFlushTime.set(SystemClock.elapsedRealtime())

        // 异步上报
        scope.launch {
            api.reportMetrics(snapshot)
        }
    }
}

两个触发条件:

  • 条数达到 maxBufferSize,避免内存暴涨

  • 超过 flushIntervalMs,避免后台时数据积压太久

2.4 采样策略

不是所有指标都需要全量采集。高频率指标(如滚动帧率)如果全量上报,数据量会淹没服务端。按场景配采样率:

class SamplingStrategy(
    private val configProvider: () -> Map<String, Float>
) {
    fun shouldRecord(metricName: String): Boolean {
        val rate = configProvider()[metricName] ?: 1.0f
        if (rate >= 1.0f) return true
        if (rate <= 0.0f) return false

        // 用 metricName 的 hash 做采样,保证同一指标的采样比例稳定
        val hash = metricName.hashCode().toLong() and 0xFFFFFFFF
        val threshold = (Long.MAX_VALUE * rate).toLong()
        return hash < threshold
    }
}

推荐的采样配置:

指标

采样率

理由

冷启动耗时

100%

频率低,每次都有价值

页面渲染耗时

100%

用户可感知,不能漏

接口耗时

10%

频率高,采样足够看趋势

滚动帧率

1%

极高频,全量上报会撑爆

内存峰值

10%

低频但数据量大,采样足够

采样率通过远程配置下发,可以根据线上数据量动态调整。大促前临时调高到 100% 来排查问题,平时降到 1% 控制成本。

三、SLO 与告警

3.1 SLO 不是 SLA

SLA 是对客户的承诺(违约要赔钱),SLO 是内部的工程目标。客户端 SLO 的常见格式:

冷启动 P95 < 2000ms
首页渲染 P95 < 800ms
核心接口错误率 < 0.5%
崩溃率 < 0.1%

SLO 的关键在于:它是可测量的,有明确的阈值,告警直接绑定到 SLO 违规上。

3.2 错误预算

SLO 配合错误预算使用。假设冷启动 SLO 定为 P95 < 2000ms,30 天计算窗口:

  • 如果某天 P95 达到 2100ms,消耗了当天的错误预算

  • 如果连续 3 天超 SLO,错误预算耗尽,触发告警

  • 错误预算耗尽后,团队应该停止新功能开发,优先修复性能问题

data class SLODefinition(
    val name: String,
    val target: Double,        // 目标值,如 2000.0
    val comparison: SLOComparison, // LESS_THAN, GREATER_THAN
    val windowDays: Int,       // 计算窗口
    val budgetBurnRate: Float  // 预算燃烧速率告警阈值,通常 2-14
)

enum class SLOComparison { LESS_THAN, GREATER_THAN }

3.3 告警规则配置

告警最怕"狼来了"。配置时注意三条原则:

原则

做法

目的

持续性

连续 N 个窗口超阈才告警

过滤抖动

分级

P99 超阈告 P1,P95 也超阈告 P0

优先级区分

归因

告警携带最近变更信息

缩短排查时间

一条实际的告警规则:

class AlertRule(
    val metricName: String,
    val condition: AlertCondition,
    val severity: AlertSeverity,
    val windowCount: Int,    // 连续几个窗口触发
    val cooldownMs: Long     // 冷却时间,避免告警风暴
)

enum class AlertCondition {
    P95_EXCEED,   // P95 超阈值
    P99_EXCEED,   // P99 超阈值
    ERROR_RATE_UP, // 错误率上升
    TREND_UP       // 趋势上涨超过斜率
}

四、三维指标看板实战

4.1 启动维度看板

启动看板需要回答三个问题:整体有多快?哪个机型最慢?哪个 SDK 初始化最耗时?

┌──────────────────────────────────────┐
│         启动耗时看板                   │
├──────────┬──────────┬─────────────────┤
│ P50 1.1s │ P95 1.9s │ P99 3.2s        │
├──────────┴──────────┴─────────────────┤
│ 按机型分布                             │
│ ┌──────────────┬──────┐               │
│ │ Xiaomi 12    │ 2.4s │  ← 红色       │
│ │ Huawei P50   │ 1.8s │               │
│ │ Oppo Find X5 │ 1.6s │               │
│ │ Pixel 7      │ 1.3s │  ← 绿色       │
│ └──────────────┴──────┘               │
│ 按 SDK 初始化耗时                       │
│ ┌──────────────┬──────┐               │
│ │ third-pay    │ 320ms│  ← 头号瓶颈    │
│ │ push-sdk     │ 180ms│               │
│ │ analytics    │  85ms│               │
│ │ bugly        │  45ms│               │
│ └──────────────┴──────┘               │
└──────────────────────────────────────┘

关键指标定义:

// 冷启动耗时:从 Application.attachBaseContext 到首帧 draw
fun recordColdStart() {
    val duration = firstFrameTime - appAttachTime
    val tags = mapOf(
        "device" to Build.MODEL,
        "os_version" to Build.VERSION.SDK_INT.toString(),
        "channel" to channel
    )
    collector.recordTiming(Metrics.APP_COLD_START, duration, tags)
}

4.2 页面维度看板

页面看板关注渲染性能。一个页面从 onCreate 到第一帧绘制完成的时间,比"Activity 启动"更能反映用户体感:

// 在 BaseActivity 中
override fun onCreate(savedInstanceState: Bundle?) {
    pageStartTime = SystemClock.elapsedRealtimeNanos()
    super.onCreate(savedInstanceState)
}

override fun onWindowFocusChanged(hasFocus: Boolean) {
    super.onWindowFocusChanged(hasFocus)
    if (hasFocus && !firstFrameRecorded) {
        firstFrameRecorded = true
        val duration = (SystemClock.elapsedRealtimeNanos() - pageStartTime) / 1_000_000
        collector.recordTiming(
            Metrics.PAGE_FIRST_FRAME,
            duration,
            mapOf("page" to javaClass.simpleName)
        )
    }
}

onWindowFocusChanged 作为首帧时间点是一个近似值。更精确的做法是使用 Window.OnFrameMetricsAvailableListener,它在 API 24+ 可以拿到每帧的渲染耗时。

4.3 接口维度看板

接口看板是最容易自动化的维度,因为所有请求都经过 OkHttp Interceptor:

class MetricInterceptor(
    private val collector: MetricCollector
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val startNanos = System.nanoTime()
        val request = chain.request()

        var response: Response? = null
        var error: Throwable? = null

        try {
            response = chain.proceed(request)
            return response
        } catch (e: Throwable) {
            error = e
            throw e
        } finally {
            val durationMs = (System.nanoTime() - startNanos) / 1_000_000
            val tags = mutableMapOf(
                "path" to request.url.encodedPath,
                "method" to request.method
            )
            response?.let { tags["status"] = it.code.toString() }
            error?.let { tags["error"] = it.javaClass.simpleName }

            collector.recordTiming(Metrics.NET_REQUEST, durationMs, tags)

            // 错误率独立计数
            if (response == null || response.code >= 500) {
                collector.increment("net.request.error", tags = tags)
            }
        }
    }
}

一个接口看板要展示的信息:

维度

展示内容

告警规则

耗时分布

P50/P95/P99 趋势线

P95 连续 3 个窗口超 SLO

错误率

状态码分桶 + 错误率趋势

错误率突增超 50%

量级

QPS 趋势 + 分机型占比

QPS 突降可能表示请求发不出去

DNS

DNS 解析耗时独立监控

DNS 耗时突增提示网络层问题

五、数据上报与隐私

端侧上报数据需要注意隐私合规。几个要点:

  1. 不上报用户敏感信息。接口 path 可能包含用户 ID,上报前要做脱敏

  2. tags 里不放置设备序列号、IMEI 等唯一标识

  3. 上报频率控制,避免在用户睡眠时段频繁发网络请求

fun sanitizePath(path: String): String {
    // 脱敏:把路径中的数字 ID 替换为占位符
    return path.replace(Regex("/\\d+"), "/{id}")
        .replace(Regex("/[a-f0-9]{16,}"), "/{token}")
}

小结

Metric 体系的核心是让性能从"感觉"变成"数字"。分位数比平均值更能反映尾部问题,聚合桶和采样策略让端侧采集不至于拖垮应用性能。三维看板(启动/页面/接口)是团队最常看的三个维度,建议优先建设。

最后一篇讲 Log 体系,以及怎么把 Trace、Metric、Log 三支柱打通,形成线上问题定位闭环。

参考阅读


评论