一次Android线上OOM问题,如何定位和解决?

摘要:oom (out of memory) 问题非常不好排查,特别是线上代码出问题的时候,真是一个头两个大,然而楼主就遇到了这样的倒霉事,看看楼主是怎么硬着头皮解决的吧,顺便搭一波联合征文的东风…
背景 公司的主打产品是一款跨平台的 App,我的部门负责为它提供底层的 sdk 用于数据传输,我负责的是 Adnroid 端的 sdk 开发。 sdk 并不直接加载在 App 主进程,而是隔离在一个单独进程中,然后两个进程通过 tcp 连接进行通信的,这样做的目的是减少因 sdk 的崩溃带来的主进程 crash,为用户带来更好的体验。 如上图所示,sdk 主要实现于 service.so 中被 Work 进程加载,kernel.so 通过 jni 嵌入在 App 主进程,前者作为侦听端,后者是连接端。 不过这样做有一个问题,当侦听端口被占用时,两个进程就无法建立通信了,导致数据无法传输。为了解决这个问题,打算用本地 socket (unix domain socket) 代替 tcp socket,因为前者不依赖端口号,只依赖文件路径,而 Android 中的私有存储可以有效的防止文件冲突。 这个替换过程也不能一蹴而就,因为 App 进程加载的 so 与 Work 进程加载的可能并不是一个版本,考虑到向后兼容,新的 service 版本需要同时侦听 tcp 与 local 两个通道,新的 kernel 版本也需要同时连接两个通道,哪个先连接上就用哪个。 开发完成的自测阶段一切正常,验证了以下组合: 连接端 侦听端 结果 tcp local, tcp tcp 成功 local local, tcp local 成功 local, tcp tcp tcp 成功 local, tcp local, tcp local, tcp 均成功,一般 local 抢先 结果符合预期,提测阶段也顺利通过,于是通过版本灰度,逐渐替换线上的旧版本,各个灰度阶段观察正常,最后正式全量发布。 问题发生 全量两天后,正式将特性分支合入 master,结果合入没 30 分钟,QA 反馈主端 oom (out of memory) 崩溃异常升高,需要回滚版本验证。 了解了一下情况,发现主端的全部版本崩溃率确实从 0.01% 升高到了 0.05%~0.07% 的水平,且大量新增的崩溃类型堆栈显示 oom 信息,最关键的是崩溃升高的趋势和 sdk 灰度的节奏完全吻合,而这期间主端也没有发布新的版本,于是只能回滚 sdk 版本尝试。 糟糕的是刚刚合入代码,使用 revert 回滚提交的几个 commit,又出现了一大堆冲突提示。正在解决冲突的过程中,QA 等不急了,建议从之前合入的位置直接拉分支打版本,一顿操作猛于虎,很快就打好了回滚版本,当天就通过测试小流量了。 第二天来了一看,崩溃率果然应声下降,于是 QA 开启全量修复。同时研究了一个短平快的 master 回滚方案:新建一个目录,clone 并 checkout 到合入前的代码,将 .git 目录删除后用这个目录覆盖旧的工作目录,最后将所有 modified 的文件作为新版本直接提交。这样做的好处是可以得到与合入前完全一样的代码,防止手工处理冲突引入新的变更。 问题分析 随着回滚版本的放量,主端 oom 崩溃逐渐回归正常,进一步坐实了新版本存在问题。oom 问题非常不好排查,原因是崩溃时的堆栈与引入 bug 的地方已经相差了十万八千里,不能直接定位问题点。 好在这个版本之前做过一次小流量,看当时的崩溃率没有明显升高,在准备全量前,合入了 master 上的最新修改、ios 平台的一些代码等,因此重点排查两个版本的差异部分,应该就可以定位引入问题的点了。 走查了一遍,没有发现明显的内存泄漏代码: master 是稳定版本,不存在内存泄漏; ios 平台代码通过宏定义作了隔离,对 android 没有影响; 只有一个地方非常可疑——这是一个日志上报操作,只在特定场景下发生,日志上报时并不是直接上报到服务器,而是放入一个队列,再由专门的线程负责上传。一次上报并不会占用太多内存,但关键是一旦进入这个特定场景,日志就会一直产生,而主端会在传输数据的过程中频繁调用这个接口,导致大量的日志进入队列,特别是当用户处于非 WIFI 环境下,日志上报会被关闭来节省流量,进一步加剧了队列积压,最终导致队列疯狂增长耗尽内存…… 知道了原因,改起来就简单了,加一个 bool 标记,上报过后设置这个标记下次就不再上报了,因为这类日志有一条用来排查问题就足够了。 问题定位 修复版都打好准备送测了,老大的一句话提醒了我——最好能在本地复现一下。
阅读全文