本文首发于“Shopee技术团队”
摘要
MDAP(Multiple Dimension Analysis Platform)作为一个多维实时监控分析平台,能够支持业务应用侧自定义指标的监控与分析,并在自定义监控分析能力上,实现了对移动端应用性能数据的专项监控分析能力,以满足业务日益增长的数据分析需求。
这是 MDAP 系列的第四篇文章,前文回顾:
1. 背景
在软件调试及错误排查过程中,无论是客户端 App 还是后端服务,一个常见的手段是通过错误堆栈定位异常所在的源码位置,从而直接在源码层面剖析问题根因。MDAP 平台的移动端性能分析能力很大程度上也依赖于对堆栈数据的采集和分析,它的性能监控能力如下图所示。
其中,蓝色粗体部分的功能都需要 MDAP SDK 采集堆栈并上报到 MDAP 后台,帮助开发者定位异常问题,这意味着 MDAP 服务端需要支持 Android、iOS、Web H5、React Native 四类堆栈的高性能还原能力,以应对海量堆栈的上报。
1.1 堆栈还原的基本概念
所谓的堆栈,通常特指某一时刻的函数调用上下文关系。我们知道,函数的调用和返回会在内存的栈空间中新建和销毁栈帧,而栈帧就是一次函数调用的上下文关系。若能获取到某一时刻栈空间中的栈帧分布,即可知道该时刻的函数调用情况。
因此,堆栈通常可用于异常问题的排查。在这种场景下,我们通常只会关心函数名称(有时候还包含类名)、函数参数(若有)、文件名称、以及行号,而对诸如函数内部的局部变量等信息是不会太关心的。
堆栈还原的含义就是将栈空间中的原始栈帧信息转换为源码级别的函数调用关系,这个过程通常也称为堆栈符号化。
另外,对于一些高级语言(例如 Java),其运行时环境(JVM)会直接输出类似于源码形式的堆栈信息,但是由于打包过程中会对源码进行压缩和混淆,因此这类堆栈也需要一个转换过程,通常会称为反混淆。
也就是说,根据语言和运行环境的不同,堆栈还原其实包含了不同的含义,其对应的堆栈还原原理自然也不尽相同。
1.2 堆栈的分类
目前我们公司内常用的客户端平台可以分为四类:
- Android
- iOS
- Web H5
- React Native
其中,Android 平台比较特殊,它不仅支持多种开发语言:Java(Kotlin)和 C++,并且它们两者之间是两套相对比较独立的体系(通常称为 Android Java
和 Android Native
),因此其堆栈格式及堆栈还原原理完全不同。
而在其他平台上,即使使用不同的开发语言,其堆栈结构及堆栈还原原理都是基本统一的。
因此,基于客户端平台可以将堆栈类型分为以下几类:
上一小节也提到过,堆栈还原包含了两层不同的含义,这也对应着两类截然不同的堆栈还原基本原理。从这个角度,可以进一步把上述堆栈分为两大类:
- 基于地址关系映射
- Android Native
- iOS
- 基于符号关系映射
- Android Java
- Web H5
- React Native
基于地址关系映射,即客户端采集到的堆栈就是内存空间中 stack 区的栈帧排列,通常符合以下特点:
- 使用编译型语言编写,通常为 C 语言及其衍生语言;
- 编译后直接生成目标操作系统上的可执行文件;
- 客户端采集的原始堆栈实际是内存栈空间中每一层函数调用的返回地址,运行时无法获取源码级别的堆栈;
- 堆栈还原实际上就是将堆栈中的函数地址转换为源代码中函数名称及对应的行(列)号。
基于符号关系映射,客户端并不能直接从 stack 区中获取相关函数相关调用信息,它的基本特点如下:
- 通常使用解释型语言编写,例如 JavaScript;
- 不能直接运行在操作系统之上,其运行时通常是一个操作系统之上的中间层,例如 JVM、V8 引擎等;
- 通过这个中间层可以在运行时获取源码级别的堆栈,例如函数名和行列号;
- 在工程化实践中,通常基于安全或者性能方面考虑,代码构建时会将源代码进行压缩与混淆,因此运行时获取的堆栈中的函数名和行列号都是经过混淆转换的;
- 堆栈还原的本质,就是将堆栈中混淆过后的函数名称和行列号反混淆为真实源码中的函数名称及对应的行列号。
2. 方案调研
综合上述背景介绍,我们可以提炼出 MDAP 对于堆栈还原服务的两个基本要求:
1)高性能
因为需要实时还原海量的堆栈上报,尤其是在大促期间,MDAP 平台的数据上报 QPS 峰值会达到数万甚至数十万的级别,这对堆栈还原服务的性能要求非常高。
2)整体架构模型标准化、统一化
这是由于 MDAP 需要还原多种不同类型的堆栈,且堆栈格式与还原流程各异,统一的架构模型不仅可以降低开发和运维成本,还便于后期扩展新类型堆栈的还原能力。
事实上,我们最早期的规划中并没有 React Native
堆栈的还原功能,但正是得益于统一化的架构模型,我们在较短时间内快速开发并上线了该服务。
2.1 现有工具调研
上述几个平台都有成熟的堆栈还原工具,具体如下:
这些工具都有一个共同点:基本都是客户端开发套件中的配套工具,通常在本地开发环境中使用。那么,我们能否对这些工具进行简单的封装,作为 MDAP 平台的堆栈还原服务呢?
答案显然是否定的,具体原因有如下问题需要考虑:
- 性能问题
- i. 上述一些工具可能需要封装为命令行的形式调用,相当于每次还原都需要启动一个子进程,进程频繁创建和销毁带来的性能消耗导致这种方式无法满足我们性能方面的需求;
- ii. 同时,线上活跃的版本通常是比较集中的,一般就那么几个,并且符号表文件又比较大,几十 M 到几百 M 不等,这意味着同一时间段内会有大量相同版本的堆栈上报,而这些堆栈在还原时都需要重新读取符号表文件,也会造成大量的重复文件 IO 操作,产生性能瓶颈。
- 直接使用这些工具还会导致后续服务难以维护,这个问题体现在以下几个方面:
- i. 这些工具的运行时环境不一致。例如 Java 的堆栈还原工具依赖 JRE 环境,JavaScript 的还原工具依赖 JS 的运行时环境,而 iOS 的还原工具 atos 甚至只能运行在 macOS 环境中,多样化的运行时环境无疑会大大增加服务端的运维成本;
- ii. 开发语言不一致。上面提到过,复用这些工具的一个手段是通过命令行方式启动子进程;除此之外,还有一个手段就是在服务端集成这些工具的源码(前提是工具已开源)。但是这些工具使用的语言不一致,而我们的后台统一使用 Golang 作为开发语言,一方面从工程化的角度难以将多个语言的项目集成在一起,另一方面也不符合我们统一架构模型的理念;
- iii. 服务不可观测。无论是源码形式调用还是命令行形式调用,服务运行时的一些状态可能都是黑盒,即使有源码,一些工具的源码也是集成在一个大项目之中,我们很难在合适的地方加入日志,也很难集成监控指标或者链路追踪。
综合以上原因,我们最终没有选择直接使用这些现成的工具,而是决定自研堆栈还原服务。
2.2 业界方案调研及架构设计
既然决定了自研堆栈还原服务,在上面提出的问题中,除 2-ii
外其他都可以自然而然地得到解决。那么 2-ii
应该通过什么方式解决呢?
除了调研一些已有工具之外,我们还调研了一些其他性能分析平台如何实现堆栈还原能力。这些平台的实现存在以下两个共同点:
- 符号文件上传后预解析,避免重复文件 IO;
- 符号文件预解析为 KV 形式,并保存在 KV 缓存(Redis)或者 KV 数据库(HBase)中。
这实际上就是将堆栈还原所需的能力拆解为了两部分:符号表文件解析、堆栈还原。而通过符号表文件的预解析,我们也可以很好地解决问题 2-ii
。
遵循以上思路,我们基于 MDAP 的既有架构,可以初步设计出堆栈还原系统的相关架构,如下图所示:
与堆栈还原相关的主要有两个服务:
- SymbolManager:符号表管理服务,负责符号表的上传、下载、解析、缓存等流程的管理。符号表上传之后,该模块会实时解析文件,并将其解析为 KV 的形式写入 Redis 缓存之中。同时,还会负责符号表文件存储和缓存的管理。
- Stack Symbolicating:堆栈还原服务,负责堆栈还原,以 gRPC 的形式提供还原服务。当接收到还原请求时,会在 Redis 中查找相关的符号信息,并将这些信息拼接为完整的堆栈,返回给请求发送端。
3. 堆栈还原服务实现
上文给出了堆栈还原服务的相关架构,包含了符号表管理和堆栈还原两个服务。但是在具体的实现中需要支持多种类堆栈的还原,因此我们需要实现多个符号表解析实例,以及相应的堆栈还原实例。本章节将按照堆栈类型分别讨论符号表解析和堆栈还原的具体实现。
3.1 iOS 篇
前文在对堆栈进行分类时,将 iOS 和 Android Native 堆栈都分为了基于地址关系映射的类型。实际上,这两类堆栈还原原理可以说是完全一致的,但是由于 SDK 端采集堆栈时的处理方式有一些轻微差别,导致服务端的实现也会有相应的区别(后文会详细介绍这些区别),也正是这点微小的区别,导致 iOS 端堆栈还原的流程更具代表性,因此这里首先介绍 iOS 堆栈还原的实现。
3.1.1 iOS 堆栈格式
首先来看一下 iOS 平台还原前的原始堆栈格式。需要说明的是,这里的格式是 MDAP SDK 经过一定处理后上报上来的堆栈,其格式可能与其他方式获取到的堆栈格式有一些差别,但是其内容基本是一致的。为方便说明,后文对堆栈格式的介绍都以 MDAP SDK 上报的格式为准。
iOS 原始堆栈示例如下:
CoreFoundation 0x00007fff20422faa 0x7fff20311000 + 1122218 [8c17697f-2e84-39e5-b491-fcf5169106ff]
libobjc.A.dylib 0x00007fff20193ff5 0x7fff20174000 + 131061 [8c17697f-2e84-39e5-b491-fcf5169106ff]
CoreFoundation 0x00007fff204a1523 0x7fff20311000 + 1639715 [8c17697f-2e84-39e5-b491-fcf5169106ff]
CoreFoundation 0x00007fff203212d1 0x7fff20311000 + 66257 [8c17697f-2e84-39e5-b491-fcf5169106ff]
MDAP_testDemo9 0x00000001079b59d3 0x1079b3000 + 10707 [8c17697f-2e84-39e5-b491-fcf5169106ff]
MDAP_testDemo9 0x00000001079b5f49 0x1079b3000 + 12105 [8c17697f-2e84-39e5-b491-fcf5169106ff]
每一行包含以下信息:
- BinaryImage,例如第一行中的
CoreFoundation
,可理解为 iOS 中的可执行文件名称; - 栈帧的返回地址,例如第一行中的
0x00007fff20422faa
; - 该 BinaryImage 在内存中的加载地址,例如第一行中的
0x7fff20311000
; - 当前指令地址相对 BinaryImage 起始点的偏移,例如第一行中的
1122218
,为十进制数字; - BinaryImageUUID,例如第一行中的
8c17697f-2e84-39e5-b491-fcf5169106ff
,该栈帧所属可执行文件的唯一 ID。
3.1.2 iOS 符号表文件
在 iOS 平台中,符号表文件通常是指 dSYM 文件,即包含了调试信息的目标文件。在 Mac 上右键点击 dSYM 文件并选择“显示包内容”,即可看到其内部实际上是由一个包含了调试信息的 Mach-O 文件组成。Mach-O 是 Mach Object 文件格式的缩写,是 Mac 和 iOS 上的可执行文件。
Mach-O 文件可以通过 MachOView 应用进行可视化解析,如下图所示:
这个可执行文件内部包含了以 DWARF 格式保存的调试信息。
调试信息,顾名思义通常用于源码级调试。想象一下,我们调试源码时,通常会在代码的某一行打上断点,当程序执行到这一行时就会发生中断,从而暂停程序执行流程。也就是说,调试信息相当于源码和运行时之间的桥梁,其中描述了源代码与目标代码之间的关系,是在编译时生成的。因此,客户端在运行时采集到的堆栈也可以通过调试信息还原为源码级堆栈。
调试信息有多种格式,目前在 Unix & Linux 平台上,主流的调试信息格式即为 DWARF。
3.1.3 DWARF 调试信息
DWARF 是一种被