草庐IT

记一次 android 线上 oom 问题

goodcitizen 2023-03-28 原文

背景

公司的主打产品是一款跨平台的 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. ruby - 在 64 位 Snow Leopard 上使用 rvm、postgres 9.0、ruby 1.9.2-p136 安装 pg gem 时出现问题 - 2

    我想为Heroku构建一个Rails3应用程序。他们使用Postgres作为他们的数据库,所以我通过MacPorts安装了postgres9.0。现在我需要一个postgresgem并且共识是出于性能原因你想要pggem。但是我对我得到的错误感到非常困惑当我尝试在rvm下通过geminstall安装pg时。我已经非常明确地指定了所有postgres目录的位置可以找到但仍然无法完成安装:$envARCHFLAGS='-archx86_64'geminstallpg--\--with-pg-config=/opt/local/var/db/postgresql90/defaultdb/po

  2. ruby - 通过 rvm 升级 ruby​​gems 的问题 - 2

    尝试通过RVM将RubyGems升级到版本1.8.10并出现此错误:$rvmrubygemslatestRemovingoldRubygemsfiles...Installingrubygems-1.8.10forruby-1.9.2-p180...ERROR:Errorrunning'GEM_PATH="/Users/foo/.rvm/gems/ruby-1.9.2-p180:/Users/foo/.rvm/gems/ruby-1.9.2-p180@global:/Users/foo/.rvm/gems/ruby-1.9.2-p180:/Users/foo/.rvm/gems/rub

  3. ruby - 使用 Vim Rails,您可以创建一个新的迁移文件并一次性打开它吗? - 2

    使用带有Rails插件的vim,您可以创建一个迁移文件,然后一次性打开该文件吗?textmate也可以这样吗? 最佳答案 你可以使用rails.vim然后做类似的事情::Rgeneratemigratonadd_foo_to_bar插件将打开迁移生成的文件,这正是您想要的。我不能代表textmate。 关于ruby-使用VimRails,您可以创建一个新的迁移文件并一次性打开它吗?,我们在StackOverflow上找到一个类似的问题: https://sta

  4. ruby - 如何每月在 Heroku 运行一次 Scheduler 插件? - 2

    在选择我想要运行操作的频率时,唯一的选项是“每天”、“每小时”和“每10分钟”。谢谢!我想为我的Rails3.1应用程序运行调度程序。 最佳答案 这不是一个优雅的解决方案,但您可以安排它每天运行,并在实际开始工作之前检查日期是否为当月的第一天。 关于ruby-如何每月在Heroku运行一次Scheduler插件?,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.com/questions/8692687/

  5. ruby - 通过 RVM (OSX Mountain Lion) 安装 Ruby 2.0.0-p247 时遇到问题 - 2

    我的最终目标是安装当前版本的RubyonRails。我在OSXMountainLion上运行。到目前为止,这是我的过程:已安装的RVM$\curl-Lhttps://get.rvm.io|bash-sstable检查已知(我假设已批准)安装$rvmlistknown我看到当前的稳定版本可用[ruby-]2.0.0[-p247]输入命令安装$rvminstall2.0.0-p247注意:我也试过这些安装命令$rvminstallruby-2.0.0-p247$rvminstallruby=2.0.0-p247我很快就无处可去了。结果:$rvminstall2.0.0-p247Search

  6. ruby - Fast-stemmer 安装问题 - 2

    由于fast-stemmer的问题,我很难安装我想要的任何ruby​​gem。我把我得到的错误放在下面。Buildingnativeextensions.Thiscouldtakeawhile...ERROR:Errorinstallingfast-stemmer:ERROR:Failedtobuildgemnativeextension./System/Library/Frameworks/Ruby.framework/Versions/2.0/usr/bin/rubyextconf.rbcreatingMakefilemake"DESTDIR="cleanmake"DESTDIR=

  7. ruby - 安装 Ruby 时遇到问题(无法下载资源 "readline--patch") - 2

    当我尝试安装Ruby时遇到此错误。我试过查看this和this但无济于事➜~brewinstallrubyWarning:YouareusingOSX10.12.Wedonotprovidesupportforthispre-releaseversion.Youmayencounterbuildfailuresorotherbreakages.Pleasecreatepull-requestsinsteadoffilingissues.==>Installingdependenciesforruby:readline,libyaml,makedepend==>Installingrub

  8. java - 从 JRuby 调用 Java 类的问题 - 2

    我正在尝试使用boilerpipe来自JRuby。我看过guide从JRuby调用Java,并成功地将它与另一个Java包一起使用,但无法弄清楚为什么同样的东西不能用于boilerpipe。我正在尝试基本上从JRuby中执行与此Java等效的操作:URLurl=newURL("http://www.example.com/some-location/index.html");Stringtext=ArticleExtractor.INSTANCE.getText(url);在JRuby中试过这个:require'java'url=java.net.URL.new("http://www

  9. ruby-on-rails - 简单的 Ruby on Rails 问题——如何将评论附加到用户和文章? - 2

    我意识到这可能是一个非常基本的问题,但我现在已经花了几天时间回过头来解决这个问题,但出于某种原因,Google就是没有帮助我。(我认为部分问题在于我是一个初学者,我不知道该问什么......)我也看过O'Reilly的RubyCookbook和RailsAPI,但我仍然停留在这个问题上.我找到了一些关于多态关系的信息,但它似乎不是我需要的(尽管如果我错了请告诉我)。我正在尝试调整MichaelHartl'stutorial创建一个包含用户、文章和评论的博客应用程序(不使用脚手架)。我希望评论既属于用户又属于文章。我的主要问题是:我不知道如何将当前文章的ID放入评论Controller。

  10. 【高数】用拉格朗日中值定理解决极限问题 - 2

    首先回顾一下拉格朗日定理的内容:函数f(x)是在闭区间[a,b]上连续、开区间(a,b)上可导的函数,那么至少存在一个,使得:通过这个表达式我们可以知道,f(x)是函数的主体,a和b可以看作是主体函数f(x)中所取的两个值。那么可以有,  也就意味着我们可以用来替换 这种替换可以用在求某些多项式差的极限中。方法: 外层函数f(x)是一致的,并且h(x)和g(x)是等价无穷小。此时,利用拉格朗日定理,将原式替换为 ,再进行求解,往往会省去复合函数求极限的很多麻烦。使用要注意:1.要先找到主体函数f(x),即外层函数必须相同。2.f(x)找到后,复合部分是等价无穷小。3.要满足作差的形式。如果是加

随机推荐