西瓜视频稳定性治理体系建设一:Tailor 原理及实践

avatar
@字节跳动

摘要

Tailor 是西瓜视频 Android 团队开发的一款内存快照裁剪压缩工具,广泛用于字节跳动旗下各大 App 的 OOM 治理及异常排查,收益显著,在西瓜视频上更是取得 OOM 降低95%以上的好成绩。Tailor 工具现已开源,本文将通过原理、方案和实践来剖析 Tailor 的相关细节。

背景

稳定性治理一直是个老生常谈的话题,过去我们调查稳定性问题只能依靠堆栈和源码,但很多时候堆栈是远远不够的,对于严重依赖的数据只能临时增加埋点后再次上线搜集,这期间还会遇到能不能搜集到和怎么搜集的问题,使得我们治理稳定性问题时常常过于局限和被动。探寻通用、高效、便捷的异常数据搜集方案一直是我们在治理实践中努力的方向。

西瓜视频 Android 团队基于Java 堆内存快照,搭建了一套相对完整的通用异常数据搜集系统,能够在异常发生时,尝试 dump 出一个相对完整的内存快照文件,必要的时候借助云控系统实现快照回捞,最终通过内存快照辅助调查那些棘手的稳定性问题,以提升稳定性问题的治理效率。如何高效、安全、便捷的获取内存快照,是整个通用异常数据搜集系统里关键的一环。

内存快照的作用

OOM 治理

我们知道内存快照是治理 OOM 问题及其他类型的内存问题的重要数据源,其重要性可以简单理解为:内存快照是解决常规堆内存 OOM 问题的充分条件。同时,内存快照中保存的对象信息和依赖关系也是静态分析内存泄漏的关键,是所有内存泄漏检测工具的基石。

Crash 治理

内存快照中保存的数据,很多时候也是调查其他类型异常的重要参考,比如 Activity、Fragment、View 状态等、Framework 层及第三方对象的数据等,必要的时候都可以用来分析异常问题。作为通用数据大大减少了定向埋点的烦恼,同时也覆盖了很多无法渗透到的场景。

为什么要做裁剪

为了能在需要的时候为各类异常提供数据支持,必须要保证数据的稳定,这就需要解决快照在 dump、存储、传输等环节可能存在的问题,不仅包括存储空间和流量消耗问题,还包括隐私和安全性问题。

存储

以 LargeHeap 应用为例,其 OOM 时的内存快照大小通常在512M左右。不经过裁剪的话只能存储在App的外部存储空间或者 SDcard 上,这就会遇到空间不足或者 SDcard 的权限问题( Android 11对 App 的外部存储空间也做了权限限制)。没有足够稳定的存储空间,快照dump成功率将会大幅降低。

传输

传输过程对于数据的大小是非常敏感的,首当其冲的就是流量消耗问题,其次更小的快照传输耗时更少,回传的成功率也会大幅提升。

隐私

内存快照是虚拟机堆内存数据的完整 copy,这其中可能包含有账号、Token、联系人、密钥以及其他可能存在隐私的图片/字符串等,隐私数据是必须要裁剪掉的。

内存快照裁剪方案

目前已知的裁剪方案有种:一种是已开源的 Matrix 方案,另一种是本人在 2018 提出的 hprof 流裁剪方案。Matrix 方案分为两步:先通过 Debug.dumpHprofData 直接 dump 出一个完整的 hprof 文件;然后通过分析 hprof 文件分别裁剪掉数据相同的 Bitmap 对象和 String 对象。其裁剪方案存在以下问题:

  • 原生接口直接 dump 出的 hprof 文件过大,存储问题不好解决;
  • 裁剪过程中涉及到大文件 I/O 和 hprof 分析,对 App 性能的影响不好控制;
  • 裁剪不彻底,快照中仍然存在大量无用数据和可能存在的隐私问题。

hprof 流裁剪则是基于 hprof 文件格式,在 hprof 文件写入过程中进行裁剪压缩,存储空间问题大幅改善,也没有大文件 I/O 和 hprof 分析过程带来的性能问题。该方案源于实际的 OOM 治理需求,并参考了hprof 文件的格式定义,相关考虑如下:

治理需要

  • 对于 OOM 问题,只有对象大小和引用关系是必须的,其余信息都是次要的;
  • OOM 时占比最大的对象通常是 Bitmap/String,这些对象的数据主要消耗在 byte[] 、 char[];
  • Java 堆中的明文隐私信息通常以 Bitmap/String 的形式存在。

hprof格式

hprof [2]文件有明确定义,其数据组织形式比较简单,整体可分为 Header和 Record 数组两部分,相关数据组织定义如下:

  • Header: "JAVA PROFILE 1.0.2" + indetifiers + timestamp (13byte + 4byte + 8byte)

  • Record:tag + time + length + body(1byte + 4byte + 4byte + byte[$length])

Android 上 dump 出的 hprof 文件虽然也遵循 hprof格式,但也有所不同,典型的是其一级TAG只有:STRING、LOAD_CLASS、HPROF_TAG_STACK_TRACE、HEAP_DUMP_SEGMENT、HEAP_DUMP_END。HEAP_DUMP_SEGMENT 又分了很多二级 TAG ,这些二级 TAG 中既有标准 hprof 定义的,也有 Android 自定义的 TAG。跟裁剪关系比较紧密的二级 TAG 是 PRIMITIVE_ARRAY_DUMP,存放的是诸如 byte[] 、char[] 、int[]等类型的数据,其格式如图所示:

通过 hprof 格式定义可以发现,直接裁剪掉所有的 byte[]和 char[]就可以实现对 Bitmap/String 对象的裁剪。同时其数据格式定义中还存在大量的无用数据,比如 timestamp、class-serial-number、stack-serial-number、reserved 数据等等,4byte 的 length/number 等也可以压缩成 3byte 或者 2byte 等等。

Tailor 裁剪压缩实现

如果只为了治理 OOM,可以进行最大化裁剪(如byte[]、char[]、boolean[]、short[]、float[]、int[]、double[]、long[]、hprof格式裁剪),甚至可以只保留 app-heap。但作为通用异常数据,西瓜视频也会在必要的时候,通过回捞快照来分析非 OOM 类的异常,甚至是 native 异常。随着稳定性治理的深入,快照更多是用来分析非 OOM 异常。对于非 OOM 异常,快照的完整性尤为重要,同时非 OOM 的 crash 堆内存通常较小,最大化裁剪没有必要,综合考虑之后 Tailor 只保留了 byte[]、char[] 和 hprof 格式裁剪。

快照 dump 的过程大致可以分为 5 步,Tailor 只关注 open 和 write 环节。通过 xHook[3](针对 Android 平台 ELF 的 PLT hook库)在 native 层 hook dump 过程必然会调用到的 open/write 函数,以此实现对hprof 文件写入流的代理,进而进行 hprof 流裁剪。为了进一步降低写入后的文件体积,Tailor 会在裁剪之后直接进行 zlib 流压缩。流程大致如下:

  • 调用 Tailor.dumpHprofData() 时,会依次调用 nOpen()、Debug.dumpHprofData()、nClose();
  • nOpen 在 native 层开启对int open(const char* __path,int __flags, ...)和 ssize_t write_proxy(int fd,const char*buffer, size_t count) 的 hook 代理;
  • Debug.dumpHprofData 执行中会先调到 open 函数,hook 代理逻辑会过滤出目标文件的 fd;调到 write 函数时 hook 代理逻辑通过 fd 过滤出目标文件的写入数据进行裁剪压缩;
  • nClose 逻辑清除之前的 hook 代理。
public static synchronized void dumpHprofData(String fileName, boolean isGzip) throws IOException {  
       nOpen(fileName, isGzip);  
       Debug.dumpHprofData(fileName);  
       nClose();  
}  

Tailor 裁剪压缩效果

实际的裁剪效果取决于具体现场,OOM 现场的快照通常比较大(LargeHeap/非 LargeHeap 的差异也很大),非 OOM 的则要小很多,根据西瓜视频(LargeHeap)的实践经验可以得出以下数据:

  • 体积

    • OOM: 约 50% 可以裁剪压缩到 10M 以内。
    • 非 OOM: 约 60% 可以裁剪压缩到 5M 以内,约 90% 可以裁剪压缩到 10M 以内。
  • 耗时

    • 同原生 dump 耗时相差很小:dump 过程的耗时主要集中在两次 ProcessHeap 调用和文件写入上。
  • 稳定性

    • 基本没有稳定性问题:此开源版本已运行半年以上,未发现有 Tailor 相关的 crash。

西瓜视频治理实践

西瓜视频汇集了短、中、长各类视频资源,人均使用时长超过 100 分钟,同时启动次数又相对较少,导致内存问题会被放大,进而导致治理难度加大。以西瓜视频 Android v4.0.0 为例,这期间 Java crash 约为 6.5‱ 左右(影响用户的 DAU 占比),而其中 OOM 就高达 3.4‱,占比过半 。

OOM 问题常见的治理思路,基本都是通过内存泄漏检测工具实现的,这类工具的局限性在于其输出的是孤立的内存泄漏 case,缺少对整体堆内存影响的评估,无法从泄漏中看出 OOM 的直接原因,还存在比较严重的误报行为。虽然后续很多新的工具在性能上有所提升,但本质仍属于 LeakCanary 这一体系,并未从根本上解决工具在治理 OOM 时所面临的问题。

针对这种情况,西瓜视频 Android 完全抛弃了线上内存泄漏检测机制,开发完善了 Tailor 内存快照裁剪压缩工具,并以此为核心制定了线上线下同步治理的长效策略:

  • 线下开发、回归、Monkey、压测等环节自动集成 LeakCanary 检测内存泄漏;
  • 线上 OOM 时通过 Tailor 主动 dump 内存快照,通过回捞快照精准分析 OOM 问题。

该策略将治理防范的重点放到了线下,在建设完善内存问题前置发现能力的同时,也避免线上分析带来的性能影响和问题规模爆炸。同时,通过 Tailor 内存快照裁剪压缩工具和回捞机制,使得整个内存优化治理形成闭环,以线下防范为主,线上精准治理为辅,线上反哺线下,既可以精准高效地治理线上所有的堆内存 OOM 问题,又补充完善了线下监控体系。

经过一段时间的治理,西瓜视频 Android 新版本的 Java 堆内存 OOM 问题从 3.5‱ 降低到了 0.03‱,直接降低了两个数量级,并能长期以极低的人力投入保持下去。与此同时,我们也通过内存快照解决了大量迭代过程中遇到的其他类型的棘手的异常,不仅拓展了稳定性治理的思路,也沉淀出了新的稳定性治理的方法论。

在实际治理过程中,很多时候对于堆栈无法直接定位的问题,我们只能通过分析业务代码、分析增量代码、AB 实验等方法来定位。当第二次遇到时,即便知道原因,仍然需要重复之前繁琐的调查,治理经验太过主观,很难传承。而通过内存快照则不会有此类问题,快照的分析过程是客观可重复的,每解决一类问题,后续再遇到是完全可以复制之前的分析过程的。

堆内存 OOM 治理

事实上由于泄漏直接导致的 OOM 问题相对较少,能直接导致 OOM 或者内存水位比较高的,更多的是业务逻辑、缓存逻辑等,这些很多是现有检测工具覆盖不到的。事实上对于大多数 App 而言,实际能够导致 OOM 的原因十分有限,通过快照可以很直观的发现问题。

上图所示的是一个 OOM 现场,通过内存泄漏检测工具,的确可以找出多处泄漏,但都不是导致 OOM 的根本原因。即便修复了这些泄漏,显然也无法解决此类 OOM 问题(Android 硬件加速逻辑的漏洞,导致大量 byte[] 被 JNI Global 持有而泄漏)。

其他Crash治理

内存快照也是及其重要的数据现场,对于调查数据状态相关稳定性问题,是极为重要的数据补充。如果我们在非 OOM 类的 crash 时,也能获取内存快照,那么就获取了crash 时完整的内存状态。对于堆栈无法定位的问题,可以结合源码和快照数据来辅助调查问题,以下是三个典型的案例:

案例1

上图是一个比较常见的 Java crash 堆栈,堆栈中没有业务相关的信息,对于业务比较复杂的 App,传统手段很难快速定位。通过快照来调查此问题,就变得非常简单了:MAT 里先筛选出 mRecycled 为 true 的 Bitmap 对象,再通过「Path to GC Roots」即可定位。

案例2

上图同样是没有任何业务信息的 crash 堆栈,通过源码判断是在 mListener.onSurfaceTextureAvailable 回调里间接将 mLayer 置空导致的。由于置空代码位于 Framework 层,无法通过打点拿到相关 trace。

最后我们通过快照过滤出 crash时的 TextureLayer 实例,发现其 mAttachInfo 为 null,断定是在回调里执行 removeView 而最终导致 mLayer 被置空的,再通过这个 TextureLayer 实例逐层向上找到 mParent 为 null 的节点,最终找定位到被 remove 的上层节点,进而定位到了问题场景。

案例3

西瓜视频里经常遇到 OutOfMemoryError: pthread_create (1040KB stack) failed 类型的 native OOM,有一类明确因为播放器实例过多,导致 native 层缓存占用过大而 OOM 的。究竟是播放器自身的问题,还是业务层的问题很难判断。如果通过针对性的埋点来搜集数据太被动,而通过快照里 Java 层 player 对象的状态、引用关系来判断则非常简单,此类问题前后有三类:业务层未正确释放 player、player 的异步 release 被 block、standard 的 Activity 过多导致 player 实例过多等。

根据西瓜视频团队的实践,大量无法通过堆栈来定位的问题,通过快照则可以很轻松的定位到原因。那些即便不能直接定位到问题原因的 case,内存快照也可以提供必要的数据支持。以下是西瓜视频团队实践中总结出的典型的可以通过内存快照来辅助调查的问题分类:

  1. Framework:完整的 Activity、Fragment、View 状态,完整的 Framework 层数据&状态。
  2. 插件类问题:有完整的插件&状态信息、Class、Classloader 及 dex 信息等等。
  3. 业务层问题
  4. 第三方问题

内存快照裁剪后续

作为一个立足于提升稳定性治理效率的基础工具,能在必要的时候为任何可能的异常提供完整通用的数据现场,是其当仁不让的职责。能否提供完整的数据现场,核心集中在 dump、存储、传输三个环节,因而 dump 速度、体积、完整性也就成为了核心优化方向。基于目前的成果,对比 Android 原生的快照 dump 逻辑,内存快照裁剪压缩工具在以下方面还有进一步的优化提升空间:

裁剪压缩比

在保证快照数据尽可能完整的前提下,怎样进一步裁剪体积是个矛盾的问题,基于 hprof 格式裁剪仍有很大空间。同时,也可以探索其他高效的裁剪方案,以裁剪掉最终分析时用不到的数据。

裁剪压缩速度

目前 Tailor 的裁剪压缩耗时跟原生 dump 耗时比较接近,这是因为裁剪压缩的过程耗时有限,主要时间消耗在两次调用 ProcessHeap 和文件写入上,直接干掉第一次调用将会大幅减少整个 dump 耗时。

Dump内存消耗

Android 快照 dump 是在 native 层完成的,dump 过程中每个 Record 都是通过 std::vector<uint8_t> 先缓存之后,再写入到文件里的,实际 dump 过程中 Record 可能会非常大,这时就需要额外申请内存。而当我们是在 native 内存不足的 crash 现场,dump Java 堆内存快照时会大概率失败(大多数 native 内存不足都是由于 Java 层的业务逻辑导致的,必要的时候可以通过 Java 堆现场来定位问题)。如何保证在 native 内存不足时,也能成功 dump 内存快照,是值得思考的。

通过分析相关源码可以发现,实际只需要 hook 下列接口,就可以代理 Record 的缓存过程,直接对拦截到的数据进行裁剪压缩,就不会有 Record 缓存空间的问题,也可以提升快照 dump 的速度。

总结

Android 稳定性治理发展至今,相关的监控工具和方法论并不完善。基于内存快照的治理思路和分析方法,将会是传统稳定性治理体系的重要补充,其分析过程更客观、直接、高效,有效减少数据埋点的同时也净化了代码逻辑,将内存快照作为通用异常数据进行搜集可以一劳永逸。

内存快照裁剪压缩是通用异常数据搜集系统里至关重要的一环,是关系到整个技术路线是否通用的核心和关键。Tailor 只是迈开了其中的一小步,方案还有很大的优化空间。开源不是终点,我们希望集思广益、共同探索完善,在 Android 稳定性治理上走的更快更远。

接下来我们会逐步开源并详细介绍西瓜 Android 性能稳定性团队的其他核心监控体系建设,这其中主要有:Raphael(Native 内存泄漏监控工具)和 Sliver(高性能 Trace 工具)等,覆盖 Native 内存泄漏检测、ANR 治理、卡顿治理、基础性能优化等方向,敬请关注!

相关资料

  1. Tailor 开源地址

  2. HPROF 协议

  3. xHook 链接

  4. Android Camera内存问题剖析 (基于 Tailor 和内存快照的实战案例)

更多分享

基于有限状态机与消息队列的三方支付系统补单实践

UME - 丰富的Flutter调试工具

一例 Go 编译器代码优化 bug 定位和修复解析

字节跳动破局联邦学习:开源Fedlearner框架,广告投放增效209%


欢迎关注「 字节跳动技术团队

简历投递联系邮箱「 tech@bytedance.com