第26章:线上监控:帧率采集、卡顿堆栈聚合与实时告警体系

线上监控这件事,说实话,是很多团队容易忽视的环节。

我见过不少项目,开发阶段跑得挺顺,一上线就各种卡顿。为什么?因为线下测试环境太干净了。用户手机上有几百个App在后台跑,系统版本五花八门,硬件配置参差不齐。你想想看,这种环境下,你的App能不能稳住60帧?

所以这一章,我们来聊聊线上怎么监控帧率、怎么抓卡顿堆栈、怎么把告警体系搭起来。我把自己踩过的坑和沉淀下来的方案,都摊开来讲。

帧率采集:不止是FPS那么简单

很多人一提到帧率监控,第一反应就是拿Choreographer.FrameCallback算FPS。嗯,这个思路没错,但线上环境光算FPS是不够的。

为什么?因为FPS是一个平均值。假设你1秒内前500ms卡了3帧,后500ms流畅跑了57帧,平均下来还是60帧。但用户感受到的,是那半秒钟的明显卡顿。

我个人习惯的做法是:同时采集FPS和掉帧数。掉帧数比FPS更能反映真实体验。

核心指标定义

  • FPS:每秒实际渲染帧数,理想值60
  • 掉帧数:单帧绘制耗时超过16.67ms的次数
  • 卡顿率:掉帧帧数 / 总帧数 × 100%
  • 严重卡顿:单帧耗时超过100ms

我在项目中遇到过一个问题:线上采集到的FPS看起来还行,但用户反馈说「滑动不跟手」。后来一查,发现是掉帧分布不均匀——大部分掉帧集中在列表快速滑动时。所以我又加了一个指标:连续掉帧次数。连续掉帧超过3帧,基本就是一次明显的卡顿事件。

采集方案上,我推荐用Matrix的帧率采集模块。它底层也是基于Choreographer,但做了很多优化:

// Matrix 帧率采集核心逻辑(简化版)
public class FrameTracer implements Choreographer.FrameCallback {
    private long mLastFrameTimeNanos;
    private int mDroppedFrames;
    private static final long FRAME_INTERVAL_NANOS = 16666666; // 16.67ms

    @Override
    public void doFrame(long frameTimeNanos) {
        if (mLastFrameTimeNanos == 0) {
            mLastFrameTimeNanos = frameTimeNanos;
            Choreographer.getInstance().postFrameCallback(this);
            return;
        }

        long diff = frameTimeNanos - mLastFrameTimeNanos;
        int dropped = (int) (diff / FRAME_INTERVAL_NANOS) - 1;

        if (dropped > 0) {
            mDroppedFrames += dropped;
            // 上报掉帧事件
            reportDropFrame(dropped, frameTimeNanos);
        }

        mLastFrameTimeNanos = frameTimeNanos;
        Choreographer.getInstance().postFrameCallback(this);
    }
}

小技巧:线上采集时,不要把每一帧的数据都上报。那样流量和性能开销都扛不住。我一般设置一个阈值——只有掉帧数超过3帧才触发上报。这样数据量能减少90%以上,而且抓到的都是真正有问题的帧。

卡顿堆栈聚合:从海量数据里捞金子

帧率数据只能告诉你「卡了」,但没法告诉你「为什么卡」。要定位根因,必须拿到卡顿时的堆栈信息。

这里有个难点:线上环境,你不能每帧都dump堆栈。那样App直接卡死。所以我们需要一种轻量级的采样策略

我推荐的做法是:主线程Looper监控 + 堆栈采样。说白了,就是监听主线程的Looper,当一条消息执行时间超过阈值(比如200ms),就认为发生了卡顿,然后dump当前主线程的堆栈。

BlockCanary就是基于这个思路做的。但线上用BlockCanary有个问题——它每次卡顿都dump堆栈,如果卡顿频繁,堆栈数据量会爆炸。而且堆栈信息是文本,上传和存储成本都很高。

所以我们需要做堆栈聚合。把相似的堆栈合并成一条记录,只记录出现次数和首次/最近出现时间。

堆栈聚合的核心逻辑

  1. 对堆栈字符串做MD5或SHA1哈希,作为唯一标识
  2. 相同哈希的堆栈,合并计数+1
  3. 记录首次出现时间和最近出现时间
  4. 定期(比如每5分钟)将聚合后的数据上报

我曾经踩过一个坑:堆栈哈希碰撞。两个不同的堆栈,哈希值居然一样。后来我改用「堆栈前10层的方法名+行号」作为聚合key,碰撞概率就低多了。

// 堆栈聚合示例
public class StackAggregator {
    private ConcurrentHashMap<String, StackTraceInfo> mStackMap;

    public void onStackCaptured(StackTraceElement[] stackTrace) {
        String key = buildAggregateKey(stackTrace);
        StackTraceInfo info = mStackMap.get(key);
        if (info == null) {
            info = new StackTraceInfo(key, stackTrace);
            mStackMap.put(key, info);
        }
        info.incrementCount();
    }

    private String buildAggregateKey(StackTraceElement[] stack) {
        StringBuilder sb = new StringBuilder();
        // 取前10层作为聚合key
        int depth = Math.min(10, stack.length);
        for (int i = 0; i < depth; i++) {
            sb.append(stack[i].getClassName())
              .append(".")
              .append(stack[i].getMethodName())
              .append(":")
              .append(stack[i].getLineNumber())
              .append("\n");
        }
        return sb.toString();
    }
}

注意:堆栈采样本身也有性能开销。我建议采样频率不要太高——卡顿阈值设置在200ms以上,采样间隔至少1秒。另外,堆栈数据上报前一定要压缩,gzip压缩后体积能减少70%以上。

实时告警体系:别等用户骂了才知道

数据采集了,堆栈聚合了,然后呢?如果没人看,等于白做。

实时告警体系的作用,就是在问题刚冒头的时候,第一时间通知到对应的人。我见过太多团队,线上监控数据跑了大半年,一次告警都没配过。直到用户投诉到应用商店,才发现问题。

一个完整的告警体系,我觉得至少包含三层:

告警级别 触发条件 通知方式 响应时间
P0(严重) 卡顿率 > 5% 持续5分钟 电话 + 短信 + 群@所有人 15分钟内
P1(重要) 卡顿率 > 2% 持续10分钟 群消息 + 邮件 1小时内
P2(一般) 新版本卡顿率比上一版本上升50% 日报邮件 24小时内

告警阈值怎么定?我的经验是:不要拍脑袋。先跑两周数据,看看正常波动范围是多少。比如你的App平时卡顿率在0.5%~1%之间波动,那阈值设在2%就比较合理。设得太低,告警轰炸;设得太高,漏报。

另外,告警一定要带上上下文信息。光说「卡顿率超标了」没用,得告诉开发人员:哪个版本?哪个页面?哪个机型?哪个网络环境?我习惯在告警消息里附上Top5的卡顿堆栈,这样值班的人一眼就能看出问题在哪。

避坑指南:我曾经把告警阈值设得太敏感,结果半夜三点被电话吵醒,一看是某个低端机型的卡顿率飙到了8%。后来一查,是那个机型系统版本有bug,跟我们的代码没关系。从那以后,我加了一个「机型过滤」——如果卡顿集中在某个特定机型,先标记为「已知问题」,不触发P0告警。

整体架构:一张图说清楚

上面讲了帧率采集、堆栈聚合、告警体系三个模块。它们不是孤立的,需要串联成一个完整的链路。我画了一张架构图,你看完应该就清楚了:

线上卡顿监控架构图 数据采集层 Choreographer帧回调 Looper消息监控 数据处理层 掉帧计算 堆栈哈希聚合 上报存储层 gzip压缩 批量上报 服务端处理 堆栈去重聚合 → 卡顿率计算 → 版本/机型维度分析 趋势对比 → 异常检测 P0 严重告警 电话+短信+群@ P1 重要告警 群消息+邮件 P2 一般告警 日报邮件 反馈闭环:修复后验证

这张图里,我特别想强调反馈闭环这个环节。很多团队只做到了「发现问题→告警」,但没做到「修复后验证」。我建议每次修复卡顿问题后,都要在线上观察至少3天,确认卡顿率确实降下来了,才能关闭告警。

落地建议:从0到1怎么搭

如果你团队现在还没有线上卡顿监控,我建议分三步走:

  • 第一步:先接入帧率采集,只采集FPS和掉帧数,不上报堆栈。跑一周看看数据量,确认服务端能扛住。
  • 第二步:加上堆栈采样,但只对掉帧超过5帧的卡顿事件采样。堆栈做聚合后再上报。
  • 第三步:配置告警规则,从P2开始配,逐步收紧到P0。别一上来就搞电话告警,先让团队适应一下。

最后提醒一句:线上监控是一把双刃剑。采集太细,App性能反而会下降;采集太粗,又抓不到问题。我个人的经验是:线上采集的性能开销,控制在App总CPU占用的1%以内。超过这个数,用户就能感知到了。

好了,这一章的内容就这些。帧率采集、堆栈聚合、告警体系,这三块搭好了,你的App线上卡顿问题基本就能做到「发现即定位,定位即修复」。下一章我们聊聊更进阶的话题——如何用工具链自动化分析卡顿根因。


公众号:蓝海资料掘金营,微信deep3321