系列文章《客户端可观测性进阶实战》第 2 篇
推荐阅读时间:10 分钟
前言
上一篇讲了 Trace,它能还原一次操作的时间线。但 Trace 有个问题:它太重了。你不可能对每个用户每次操作都跑 Perfetto,线上只能采样 0.1% 的设备。
Metric 体系解决的是另一面的问题:全局趋势。启动耗时分布是什么样的?某页面 P95 渲染时间涨了多少?某接口的错误率最近有没有抖动?这些需要大量数据汇总后才能看出来。
这篇聊四个话题:
端侧性能指标怎么定义和分类
采集框架怎么搭,采样和聚合策略怎么做
SLO 怎么定,告警怎么配
启动、页面、接口三维指标看板实战
一、指标分类与定义
1.1 四类核心指标
客户端可观测性的指标通常分四类:
性能型指标最关键也最容易做错。下面重点讲这类。
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% 来排查问题,平时降到 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 告警规则配置
告警最怕"狼来了"。配置时注意三条原则:
一条实际的告警规则:
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)
}
}
}
}
一个接口看板要展示的信息:
五、数据上报与隐私
端侧上报数据需要注意隐私合规。几个要点:
不上报用户敏感信息。接口 path 可能包含用户 ID,上报前要做脱敏
tags 里不放置设备序列号、IMEI 等唯一标识
上报频率控制,避免在用户睡眠时段频繁发网络请求
fun sanitizePath(path: String): String {
// 脱敏:把路径中的数字 ID 替换为占位符
return path.replace(Regex("/\\d+"), "/{id}")
.replace(Regex("/[a-f0-9]{16,}"), "/{token}")
}
小结
Metric 体系的核心是让性能从"感觉"变成"数字"。分位数比平均值更能反映尾部问题,聚合桶和采样策略让端侧采集不至于拖垮应用性能。三维看板(启动/页面/接口)是团队最常看的三个维度,建议优先建设。
最后一篇讲 Log 体系,以及怎么把 Trace、Metric、Log 三支柱打通,形成线上问题定位闭环。
参考阅读
本系列第 1 篇:Trace 体系深度实战
Google SRE 工作手册:SLO 与错误预算实践
OkHttp Interceptor 文档:https://square.github.io/okhttp/interceptors/