滴滴国际化外卖 Android 商户端正常迭代版本过程中,新版本发布并且线上稳定一段时间后,突然触发线上 Crash 报警。
第一次排查发现是在依赖的底层平台 so 库中崩溃,经过沟通了解到其之前也存在过崩溃问题,所以升级相关底层 so 版本。重新发版后短期没有出现 Crash 大面积上报情况,只有零星上报,但不久后又发生了第二次大面积 Crash 上报。具体信息如下图所示:
在定位分析问题的过程中收获很多,通过这篇文章分享该 Crash 的排查过程、问题根因以及一些经验总结,希望能为读者在遇到同类型问题时提供一些参考。
排查流程
Crash 描述
Crash 的量级,从两次高峰期的峰值来看,集中爆发的峰值为每日50次左右,第二次爆发的峰值最高为173次。同时,Crash 和影响用户数量是一比一。
两次大面积爆发的问题设备集中在华为的三款机型,运行时间都在14天左右,内存情况正常,线程情况正常,此时还没有相关线索指向句柄,所以这个时候没有关注句柄,如下图:
定位分析
从整体的 Crash 描述以及 Crash 统计平台的相关数据来分析,每隔14天就大面积爆发一次,可以确定是周期性问题,这种问题的排查难度较高。
根据上报的错误日志,明确其崩溃位置是在底层的 libpush.so 库,同时和其维护的同学沟通后发现依赖的 so 版本出现过问题,所以我们在第一次大面积上报之后,升级了底层 so 库版本。虽然当时增发版本后没有明显的 Crash 上报,但还是存在零星的 Crash 上报,这让我们放松了警惕,未对问题的根因进行定位,才导致了后面更严重的第二次爆发。
第二次爆发后,通过分析 Crash 统计平台上的173次 Crash 日志信息发现,Crash 代码地址都是“000000000007ce08”,如下图,由于静态库中的代码地址都是固定的,所以对底层 so 库进行了代码定位。
我分析底层 so 库代码习惯是使用 IDA,通过 IDA 定位到的问题代码如下图:
找到对应问题代码块,定位到直接原因是 fopen 文件返回空,fwrite 写入数据之前未做空判断。由于 fopen 是调用系统 API,系统 API 出现问题概率极小,所以一定是业务某些异常场景导致打开文件失败。
业务进行了哪种非法调用引发的异常场景?我们需要定位到问题场景的代码执行环境和具体的用户操作路径。此时只有完整复现这个问题,才能找到导致 fopen 失败的根因。
上面我们推测是周期性问题,与业务运营侧同学确认,没有周期性的活动发布,排除客观因素。
从 Crash 相关数据分析,除了定位到 libpush.so 的直接代码位置,没有太好的进展,所以根据对应上报高峰的时间段排查 top5 中的其它新增 Crash,发现其中一个 Crash 从上报时间、运行时间、机型几个纬度与直接 Crash 信息高度一致,大概率是同一个问题导致,查看对应的堆栈信息。
综合定位到的底层 so 库的问题代码,分析原因是句柄超限后 fopen 打开失败导致为空,综合 App 长时间运行分析,句柄泄漏问题有14天(左右)的周期性共性条件,同时输出了占比top3问题机型的句柄上限都是1024。我们知道目前国内大多机型的句柄上限是10000+,不过由于我们自己的业务形态是基于定制设备的,定制设备更新换代较慢,机型较老,所以句柄上限较低,最终导致了问题主要集中在业务采购的定制系统设备,非定制的用户设备句柄虽然也会异常增加,但是在一个版本周期内是远远达不到句柄上限的,也就不会出现崩溃问题。
从以上信息推测崩溃问题是句柄泄漏导致超过系统上限,剩下的就是如何复现用户的操作路径和正向代码根因定位了。
问题复现
由于是句柄泄漏问题,输出打开的本地 fd 后,无法直接定位到具体 so。又因为是新版本新增问题,所以通过反向排除法,进行版本 diff,排查更新的代码。而业务代码未涉及句柄操作,故逐个进行依赖 SDK 还原,设备定时输出 fd 数量进行分析。
测试版本为线上有问题版本:
第一次测试记录:测试耗时:12h+,fd: 227 → 272
第二次测试记录:测试耗时:15h+,fd: 227 → 296
第三次测试记录:测试耗时:24h,fd: 227 → 313
句柄数量明显增加。
测试版本为还原更新的SDK版本:
第一次测试记录: 测试耗时:15h,fd: 193 → 198
第二次测试记录:测试耗时:21h,fd: 193 → 204,切换过账号一次
第三次测试记录:测试耗时:39h,fd: 193 → 210
句柄数量无明显增加。
对比 SDK 还原前后两个版本运行的数据得出结论,句柄异常增加的根因在线上版本所依赖的3个 SDK。这时候只需再依次对比3个依赖 SDK 的数据,定位到具体的问题 SDK 是时间的问题。同时我们也将排查定位进展同步给各自相关基础 so 库维护同学,发现之前其中一个 so 库的历史版本存在过句柄泄漏问题,和相应同学沟通了解相关信息。通过梳理底层 so 的句柄泄漏调用逻辑,增加调用日志,并对设备句柄数量持续观察,最终发现 so 侧存在一个逻辑:6小时轮询打开19个句柄,但是打开后没有正常关闭释放。
此时问题的根因已大概定位,为了加快复现,我们把6小时轮询时间缩短为2分钟,运行一段时间后程序句柄数量达到1024上限发生崩溃,崩溃日志与线上崩溃日志完全相同,正向从用户角度复现了该问题。同时通过有无问题的两个版本 so 跑数据,对比后也证明问题的产生是由当前 so 库导致。至此,问题直接原因以及根本原因都已定位,问题修复后发版上线,线上验证通过。
为什么问题会集中爆发在 fwrite 方法的调用上?原因是业务其中一个场景需要30s轮询调用某个操作句柄的 API,高频调用 fwrite。它不能定位根本原因,不过暴露了直接原因,这也说明内存泄漏和句柄泄漏可能会报在任何代码位置。这里也让我们初期排查问题时,偏离了方向。不过这个就是这篇文章最想强调的内容,也是最想解决的问题,当出现这类问题,作为RD,我们要重点关注些什么?来快速纠正方向,快速定位问题。
下面我们简单介绍一下句柄泄漏是什么?如何处理?如何预防?Android中都有哪些常见的句柄?有助于我们后期快速排查定位句柄相关问题。
什么是句柄泄漏
句柄泄漏,就是当打开的资源未被正常释放,导致资源不能关闭回收。因为系统会为每个进程规定最大文件描述符上限数,一般 Linux 系统的进程最大句柄上限为1024,不过现在比较新的 Android 系统,上限升到32768,我们可以通过 adb shell ulimit -n 来查看:
截图为vivo findx2的设备文件描述符上限数
程序存在句柄泄漏问题时,对应的资源句柄不会被释放,当达到上限时,程序崩溃,报出异常信息。一般的异常信息有
Could not allocate dup blob fd
java.lang.RuntimeException: Could not read input channel file descriptors from parcel.
abort message 'could not create instance too many filesœ
java.io.IOException: Cannot run program "logcat": error=24, Too many open files
"Could not allocate JNI Env: %s", error_msg.c_str()
"Could not open input channel pair"
如果上报的日志信息包含这种,那大概就是句柄泄漏导致了。
注意:句柄泄漏和内存泄漏这类问题不属于业务逻辑问题,是进程分配的资源耗尽,导致再次分配时无足够的资源进行分配,所以当程序运行时,达到对应的资源上限后,就算普通的代码依然会直接报错,这个时候,上报的日志就是对应的代码位置,这点容易误导我们排查线上问题。
如何解决句柄泄漏
上面也提过,句柄泄漏和内存泄漏是一类问题,这类问题崩溃后,Crash 的位置可能不会明确的标出是哪里出现问题,最终的 Crash 日志也可能是普通代码。
问题用户操作路径存在共性的情况(复现难度较低)
确定问题用户操作路径的共性
根据用户操作路径,反复操作进行句柄数量监控并本地保存
输出句柄异常增长情况,查看异常增长的句柄
排查业务上,涉及句柄分配的代码
问题用户操作路径无共性的情况(复现难度较高)
通过diff问题版本前后的代码,确定涉及句柄分配的代码改动
通过排除法进行逐一回退对比,进行句柄数量监控和分析
通过工具(so 库可以借助 IDA)进行问题代码定位,辅助分析问题
确定问题代码或者问题依赖,再深入定位具体代码
以上的结论是建立在没有其它辅助手段基础上,实际排查过程中,我们还可以通过 Crash 平台上的辅助信息、梳理底层 so 库代码逻辑、积极与 so 侧同学沟通等角度进行辅助定位,也可以加速定位到问题代码。
定位句柄泄露相关命令和代码
1.查看设备句柄上限:adb shell ulimit -n
2.输出进程句柄:
private void listFd() {
String tag = "FD_TAG";
File fdFile = new File("/proc/" + android.os.Process.myPid() + "/fd");
File[] files = fdFile.listFiles();
int length = files.length;
MerchantLogUtils.logFd(tag, "fd length: " + length);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
File f = files[i];
String strFile = Os.readlink(f.getAbsolutePath());
sb.append(strFile + "\n");
}
}
句柄泄漏常见问题以及分类
1.Andorid 常见的句柄泄漏问题
HandlerThread 的使用,要记得 release
IO 流操作打开后要在 finally 中 close
SQLite 数据库操作要把 cursor 实例 close
InputChannel相关,即 WindowManager.addView反复调用时,记得 removeView
Bitmap 进行 IPC
这是常见的 Android 句柄泄漏的点,具体的原理分析资料很多。
2.部分 Android 的文件描述符的具体分类,可以缩小排查范围
如何预防和监控句柄泄漏
上面都是如何解决,其实更希望问题在线下或者灰度期间就暴露并且解决掉。
预防
代码开发过程中,涉及句柄操作需“慎之又慎”,要经过充分的自测
代码 CR 的 CheckList 增加“句柄相关代码的重点CR”,如上面介绍的 Android 常见的句柄泄露的场景
版本需求改动涉及句柄创建时,QA 需重复多次操作对应路径,查看句柄情况
测试粒度覆盖句柄,测试包定时输出句柄指标,达到对应的阈值后报警,端上同学介入排查句柄增长原因
自动化测试中增加长时间运行时句柄数量的观测
观测
线上定时获取设备句柄数量并且上报,建立句柄均值观测
根据线上稳定期间的句柄数量均值,设置合理的报警阈值,及时感知到该类线上问题
总结
从 Crash 统计平台的相关数据可以看到,所有的 Crash 都在底层 so 库里,这类问题我们除了分析 Crash 统计平台提供的相关信息,是否还有其它可以辅助定位问题的手段呢?
在全面定位过程中,也需要关注相同时间段的其他新增 Crash 信息,确认是否有相关性,如同为新增、机型、时间、地区、用户操作场景等等之间的相关性。
通过对应的工具进行三方 so 的代码逻辑梳理,综合已有数据进行分析,如本次排查过程中使用 IDA 分析底层 so 库代码,定位到直接原因为 fwrite 之前未做空判断。由于系统 API 出现问题概率极小,如果之前处理过此类问题,综合 Crash 统计平台上的机型、运行时间等,从这就可以初步定位是句柄泄漏问题。
积极同步进展以及所有可能的分析到底层 so 侧同学,我们定位到句柄泄漏,反向排除法定位问题过程中也同步到底层so同学,发现之前另一个底层so 解决过句柄泄漏问题,提前定位到问题 so,减少了问题定位的人力浪费。
在与相关同学沟通时,要带有自己的分析和判断,有着重点的讨论,这样会极大地提升排查效率。
综上,当问题发生在平台底层库时,同时又无明显的用户操作路径,通过代码定位工具先尝试定位直接原因,再通过 Crash 平台上各方面的信息对比分析其相关性,配合测试数据,可以加快定位这类周期性问题。