背景

公司的主打产品是一款跨平台的 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 标记,上报过后设置这个标记下次就不再上报了,因为这类日志有一条用来排查问题就足够了。

问题定位

修复版都打好准备送测了,老大的一句话提醒了我——最好能在本地复现一下。于是基于有问题的版本,稍加修改让它一启动就不停上报日志,关闭 WIFI 打开 4G,用这个版本在测试机上跑了一整天,进程居然没崩溃!

于是不得不评估一下日志上报的泄漏规模,按一条日志最大 300 字节、主端 2 次/秒的调用频率计算,一天占用内存为 300 * 2 * 3600 * 24 = 51840000 B < 50 MB,虽然也不小了,但是对于动辙 4~8 GB 的智能手机而言,实在不算什么,要想泄漏 4 GB 得不关机运行将近 82 天,不要说现在没有这样一直不关机的用户,即使有,Android 的后台进程优化功能也早就将 App 杀了上万遍了,哪容你泄漏这么久~ 别说你不是微信,就是微信系统该杀也就杀了~

与同事一起研究这个问题后,自己又提出来一个疑点:假如是因为日志泄漏导致的 oom,那应该是 Work 进程崩溃,而不是出现大量的 App 进程崩溃。如果是因为内存耗尽导致系统上所有进程崩溃,那也至少是崩溃率一起升高,而不像现在只有 App 进程崩溃率升高,所以越看越不像是这个原因导致的。

问题根因

正当排查方向一片迷茫的时候,同事的一句话提醒了我——如果能抓到崩溃现场的日志就好办了。可是怎么抓呢?崩溃平台记录的是崩溃时间和 CUID,后者用于标识一次唯一的崩溃事件;日志抓取需要时间范围和用户 UID,而崩溃平台并不提供 UID。

这时同事神秘兮兮的祭出了一条链接,点开一看:ID-Mapping,可以将各种系统的 ID 进行批量转换,其中就包括 CUID 向 UID 的转换,好家伙,这不就是我想要的?老同事真的浑身都是宝,摸着他们过河错不了~

大部分 UID 没有捞取到日志,只有两个用户有日志。内容非常多但都是重复的,看起来 Work 进程没有启动,导致连接端一直在进行重连。在连接后期都发现了这样的日志:

2021-10-30T20:55:19.84255454 [b61e7920] {netio} LocalHandler::post_connect: local endpoint failed with system:24, fatal error
2021-10-30T20:55:19.84408116 [b61e7920] {netio} kernel_message_transmit:handle_io: pipeerror|system:24 type=1|channel=1
2021-10-30T20:55:19.84480116 [b61e7920] {netio} kernel_message_transmit:handle_io: pipeerror|system:24 type=1|channel=2
2021-10-30T20:55:31.05991064 [b61e7920] {netio} kernel_service_interface:on_ready_timeout: restart! running=1, channel=0

查了下系统错误码:

#define EMFILE      24  /* Too many open files */

这种错误一般是打开的句柄超过 linux 进程的最大打开文件句柄数 (一般是 1024),这个值对于服务器程序来说一般是不够用的,需要通过系统设置来拉高上限。但对于 App 进程是足够了,怎么会超限呢?难道是出现了句柄泄漏。于是马上去走查了连接关闭的代码:

if channel='local' then
close local_channel
else if channel='tcp' then
close tcp_channel
else
nothing channel = 'none'

这里使用了伪代码来说明大意,其中 channel 标记当前使用的连接方式,初始时设置为 none,连接时两种方式同时发送异步连接请求,先收到应答的连接将设置对应的 channel 值并关闭另一种连接通道,连接建立成功后 channel 必为两种方式之一 (local | tcp)。

上面推演的是正常的场景,当 Work 进程没有启动而导致两个通道都无法完成连接时,channel 将一直保持 none 值直到超时,再连接重启前,会尝试使用上面这段代码清理资源,此时就会命中最后的 else 逻辑——什么也不做——从而导致连接句柄被泄漏。以 10 秒重连、6 秒超时一次计算,每 16 秒就泄漏 2 个句柄,1024 个句柄泄漏光只需要不到 2 小时!

为了验证,专门修改了一版代码,人为制造 Work 进程不启动的场景,果然跑了没多久 App 进程就崩溃重启了。确定了问题根因,再回顾一下现象,之前那几个疑问就能得到解释了:

  • 问题表现为打开文件、创建线程均失败的 oom 问题,实际是 oof (out of fd),句柄泄漏的表现和内存泄漏有相似的地方
  • 问题存在于 kernel,当 kernel 耗光句柄后对应的 App 进程会因 EMFILE 错误崩溃,Work 进程反而是没什么事,所以表现为 App 进程崩溃率单独升高
  • 只影响一部分 Work 进程长时间不启动的用户,这部分用户占比较少,所以崩溃率升高有限
  • 之前小流量的那版也有问题,只是放量较少所以崩溃率升高不明显而已

问题的修复非常简单,就是在关闭清理资源时,不再根据 channel 判断,直接 close 所有句柄。打好的修复版本在 Work 进程不启动的场景下运行了一天也没有出现崩溃,对外灰度后,观察 App 崩溃率正常,逐步全量覆盖线上版本,最后合入 master。

结语

复盘整个 oom 问题产生的过程,为何在灰度阶段没有发现 App 进程崩溃率异常升高呢?原来在看崩溃数据时是过滤了 sdk 版本号的,而实际发生异常升高的版本号却是奇特的 0.0.0.1 版本,因而没有观察到。

为何 oom 问题会集中在 0.0.0.1 版本中?进一步排查发现并非只有 oom 崩溃是这样,90% 的崩溃都归类在了这个版本下面,原因竟然是 App 在初始化时没有处理好先后关系,从 sdk 拿版本号时 sdk 还未初始化,所以得到了一个无效的版本值。更严重的是,该问题几乎一直存在,而我们之前过滤版本号的做法几乎可以肯定是不正确的,想到这里不由得背上直冒冷汗!幸好有这次问题的复盘,不然这个问题要继续存在多久还是个未知数~

最后总结一下 oom 问题的处理方法,首先不要心慌,特别是在不经求证的情况下靠猜测来定位问题、靠不断发小版本在线上验证问题,这样做一来不严谨,二来效率比较低,最终很可能还会定位不到问题。最好的办法是通过现场日志来定位出错的场景,可以极大的缩小排查范围;另外 oom 与 oof 在 Java 崩溃堆栈中有相似的表现,因此遇到这类问题可以多考虑下句柄泄漏的可能性,而不是一味观察内存的分配与释放。

另外可能还有人对 Work 进程为何没有启动感兴趣,但这就属于另外一个问题了,可以单独写篇文章了。目前仍在排查中,真的是应了那句:生命不息,debug 不止~~

参考

[1]. Git 如何优雅地回退代码,用 reset 还是 revert?

记一次 android 线上 oom 问题的更多相关文章

  1. 火山引擎MARS-APM Plus x 飞书 |降低线上OOM,提高App性能稳定性

    通过使用火山引擎MARS-APM Plus的memory graph功能,飞书研发团队有效分析定位问题线上case多达30例,线上OOM率降低到了0.8‰,降幅达到60%.大幅提升了用户体验,为飞书的 ...

  2. 一次线上OOM故障排查经过

    转贴:http://my.oschina.net/flashsword/blog/205266 本文是一次线上OOM故障排查的经过,内容比较基础但是真实,主要是记录一下,没有OOM排查经验的同学也可以 ...

  3. 【转】又一次线上 OOM 排查经过

    又一次线上OOM排查经过 最近线上一个服务又出现了频繁Full GC的情况,导致提供的业务经常超时.问题出现非常不稳定,经过两周的时候,终于又捕捉到了一次Full GC,于是联系运维做Heap Dum ...

  4. 记一次log4j日志导致线上OOM问题案例

    最近一个服务突然出现 OutOfMemoryError,两台服务因为这个原因挂掉了,一直在full gc.还因为这个问题我们小组吃了一个线上故障.很是纳闷,一直运行的好好的,怎么突然就不行了呢... ...

  5. 记一次ArrayList产生的线上OOM问题

    前言:本以为(OutOfMemoryError)OOM问题会离我们很远,但在一次生产上线灰度的过程中就出现了Java.Lang.OutOfMemoryError:Java heap space异常,通 ...

  6. 记一次线上 OOM 和性能优化

    大家好,我是鸭血粉丝(大家会亲切的喊我 「阿粉」),是一位喜欢吃鸭血粉丝的程序员,回想起之前线上出现 OOM 的场景,毕竟当时是第一次遇到这么 紧脏 的大事,要好好记录下来. 1 事情回顾 在某次周五 ...

  7. 记一次线上OOM问题分析与解决

    一.问题情况 最近用户反映系统响应越来越慢,而且不是偶发性的慢.根据后台日志,可以看到系统已经有oom现象. 根据jdk自带的jconsole工具,可以监视到系统处于堵塞时期.cup占满,活动线程数持 ...

  8. Android线上Bug热修复分析

    针对app线上修复技术,目前有好几种解决方案,开源界往往一个方案会有好几种实现.重复的实现会有造轮子之嫌,但分析解决方案在技术上的探索和衍变,这轮子还是值得去推动的 关于Hot Fix技术 Hot F ...

  9. 记一次排查线上MySQL死锁过程,不能只会curd,还要知道加锁原理

    昨晚我正在床上睡得着着的,突然来了一条短信. 啥,线上MySQL死锁了,我赶紧登录线上系统,查看业务日志. 能清楚看到是这条insert语句发生了死锁. MySQL如果检测到两个事务发生了死锁,会回滚 ...

随机推荐

  1. Python&Selenium 数据驱动测试【unittest+ddt+xml】

    一.摘要 本博文将介绍Python和Selenium做自动化测试时,基于unittest框架,借助ddt模块,使用xml文件作为测试输入. 二.xml文件 保存路径:D:\\Programs\\Pyt ...

  2. 攻防世界XCTF-WEB入门全通关

    为了更好的体验,请见我的---->个人博客 XCTF的web块入门区非常简单,适合一些刚接触安全或者对网络安全常识比较了解的同学在安全搞累之余娱乐娱乐. 其主要考察下面几点: 基本的PHP.Py ...

  3. NX开发 刀路生成

    此段是可以生成程序的完整代码,只有从坐标(10,10,10)到(500,500,500)一根刀轨.motion_ptr->feed_value 的值为0时生成G00,非0时生成G01.此代码只有 ...

  4. [软工顶级理解组] 团队规划和任务拆解(Beta)

    目录 需求再分析 功能增减 管理改进 任务分解 人员管理 需求再分析 在Alpha阶段,我们的产品得到了用户的广泛好评,但是还是存在一些问题. 登录不稳定,登录速度慢等问题:这是北航VPN本身的不稳定 ...

  5. [no code][scrum meeting] Alpha 2

    项目 内容 会议时间 2020-04-07 会议主题 功能规格说明书review 会议时长 30min 参会人员 OCR组(肖思炀,赵涛)和产品经理 $( "#cnblogs_post_bo ...

  6. 【二食堂】Alpha - Scrum Meeting 10

    Scrum Meeting 10 例会时间:4.20 18:00~18:20 进度情况 组员 昨日进度 今日任务 李健 1. 与柴博合作完成登录注册页面issue 继续完成登录注册页面issue 柴博 ...

  7. Asp.net Core C#进行筛选、过滤、使用PredicateBuilder进行动态拼接lamdba表达式树并用作条件精准查询,模糊查询

    在asp.net core.asp.net 中做where条件过滤筛选的时候写的长而繁琐不利于维护,用PredicateBuilder进行筛选.过滤.LInq配合Ef.core进行动态拼接lamdba ...

  8. dice_game攻防世界进阶区

    dice_game XCTF 4th-QCTF-2018 前言,不得不说,虽然是个简单题但是还是要记录一下,来让自己记住这些东西. 考察的知识点是: 1.cdll_loadlibrary加载对应库使得 ...

  9. 零基础入门之Linux进程基础

    计算机实际上可以做的事情实质上非常简单,比如计算两个数的和,再比如在内存中寻找到某个地址等等.这些最基础的计算机动作被称为指令(instruction).所谓的程序(program),就是这样一系列指 ...

  10. C语言基础资料,可以看看哦

    C语言程序的结构认识 用一个简单的c程序例子,介绍c语言的基本构成.格式.以及良好的书写风格,使小伙伴对c语言有个初步认识. 例1:计算两个整数之和的c程序: #include main() { in ...