2018-07-04 14:23:45

解决 Grafana 的一处前端内存泄漏问题

一直没有遇到过需要用 Chrome devtools 来解决的前端问题,这次正好业务方反馈说 Grafana 上的面板看一个小时就卡得不行,排查了一下确实有内存泄漏,解决的过程正好用到了 Chrome devtools 。又正好 Grafana 有一个公开的体验站可以拿来做示范,读者也可以自己检验一下,本文就记录一下整个解决过程。

1 确认现象

登录 Grafana 体验站的 其中一个页面,打开 Chrome 设置 -> 更多设置 -> 任务管理器,可以看到此页面的内存占用,初始情况下是 83.9 MB。

然后,以五秒为间隔点击页面右上角刷新按钮十次,可以看到内存瞬间涨到 100 MB,并且等待一段时间后内存没有回落,说明确实有内存泄漏

2 定位问题

开始定位,首先刷新该页面,打开 Chrome Devtools 选择 Memory 面板,点击 take snapshot 先把初始状态记录下来。记录完成后,继续点击右上角刷新按钮十次,接着 take heap snapshot 记录。然后进行两个快照的对比,对比完成后点击两次 Delta 排下序,立马就看到一排 Detached XXX Element 。

接着展开选中一个 Detached HTMLDivElement,映入眼帘的是诸如 w 的变量,这个变量名很明显是被编译压缩混淆过看不出来什么,不过 w 上面有一个比较可疑的 Array 数组,鼠标点一下这个数组悬停一会,一个明显泄漏的地方就出来了,该数组高达 130 个成员,每个成员中都有一个 clipper 变量保持了对一段 DOM 的引用,那么泄漏很有可能就是因为这个数组没有正确的被释放掉。

3 排查代码

上图中可以随便捞一个变量出来搜索一下 Grafana 代码仓库,比如出现了三次很显眼的 scroll ,搜索出来的 commit 中又多次出现了 baron 这个关键词,一搜马上就搜到了一个 baron 相关的 PR 。观察代码发下前端这块是用 React,而我们是为了排查内存泄漏的问题,因此可以猜测是 componentDidUnmount 或者 componentWillUnmount 方法,熟手可以立即知道 React 其实没有前面那个方法,因此搜索 componentWillUnmount,立马就出来一处:

  componentWillUnmount() {
+  this.scrollbar.dispose();
  }

把代码 clone 下来,一路靠 vscode 追下去,最后追到 dispose 方法内部,代码如下

    dispose: function() {
        if (process.env.NODE_ENV !== 'production') {
            if (this._disposed) {
                log('error', 'Already disposed:', this)
            }

            liveBarons--
        }

        manageEvents(this, this.event, 'off')
        manageAttr(this.root, this.params.direction, 'off')
        if (this.params.direction == 'v') {
            this._setCrossSizes(this.scroller, '')
        } else {
            this._setCrossSizes(this.clipper, '')
        }
        this._dumbCss(false)
        this.barOn(true)
        fire.call(this, 'dispose')
        instances[this.params.index] = null
        this.params = null
        this._disposed = true
    },

仔细一看这个库是对使用者没释放内存是有意识的,可惜不知道为啥生产环境使用时把提示给去除了,这不就非常搞笑了嘛?观察代码发现 instances[this.params.index] = null 这一句比较可疑,因为 instances 数组并没有在这个函数中定义,一搜果然就是我们想找的那个泄漏的数组了:

// https://github.com/Diokuz/baron/blob/master/src/core.js#L19
// Global store for all baron instances (to be able to dispose them on html-nodes)
var instances = []

4 解决泄漏

用 Chrome devtools 打个断点试试,如下图,明显看到 this.params.index 的值为 0,这造成了每次销毁时销毁的位置出错了导致泄漏。

最后查看 params.index 被赋值的地方,发现它的值为函数的调用结果,该函数长成这样:

var attr = manageAttr(rootNode, params.direction)
var id = +attr // Could be NaN

// set, remove or read baron-specific id-attribute
// @returns {String|null} - id node value, or null, if there is no attr
function manageAttr(node, direction, mode, id) {
    var attrName = 'data-baron-' + direction + '-id'

    if (mode == 'on') {
        node.setAttribute(attrName, id)
    } else if (mode == 'off') {
        node.removeAttribute(attrName)
    }

    return node.getAttribute(attrName)
}

Grafana 前端代码中每次初始化 baron 这个东西时因为 DOM 上没有这个属性,因此 this.params.index 永远为 0,而 instances 的缓存逻辑则是非常简单的 push :

instances.push(out)

所以销毁时的 index 当然就不对啦。最后就用 serverless + 网关层代理出来,往静态资源里注入了一句清理 instances 数组的代码,这个内存泄漏就这样解决了。

结尾

上面其实没有给出根本解决办法,根本原因是 baron 没有考虑到它的这个组件可能在一个页面内被同时初始化多次,这会导致初始化没 index 时 this.params.index 被 + 操作符转成了 0,如果只同时初始化一次,这个 0 正好可以把缓存清理掉,但是一旦有一个没被清理到导致数组大于 0 ,后续的初始化的就永远清理不掉了。真正的修复方法应该是在初始化组件后 且缓存进 instance 前 将 this.params.index 设置成 instance. length,这样才算真正解决了这个问题。

不过我以前给老外提的 Issue 和 PR 一直是被晾个几周然后被关掉,我是已经懒得主动搞这种事情了。

解决这个内存泄漏前,一个包含九个面板的 Grafana 页面以每十秒刷新一次的频率在一小时后占用了我们 2G 的内存,解决之后一直稳定在小于 100MB,成果还是不错的。

本文链接:https://smallpath.me/post/grafana-fe-memory-leak

-- EOF --