一次 Next.js App Router 的内存泄漏问题的排查过程

0 阅读7分钟

最近在项目中采用 Nextjs 14App Router 模式 + formilyjs 为主要的技术栈,在压测发现了严重的内存泄漏

现象:

大概一个页面一个页面的GET请求进来可以增长3MB左右的可怕的内存占用,无法释放,随意压测100个请求可增长20M到30M

问题分析

工具:

  1. 分析工具:Chrome Dev ToolsMemory Tab
  2. vegeta 压测工具

原因分析过程:

  1. chrome dev tools 中发现了很多大量的 i18n 的字符串,怀疑是 i18n 问题引起的,动手换了个i18n的方案,显示结果依然是有很多i18n在内存中,问题仍然存在,这个怀疑 pass

  1. chrome dev tools 发现了一些样式,是 antd 的所有样式合集,猜测是不是样式引起的问题,copy antd 的 nextjs registry 源码去掉 cache 后,node 端的样式没了,但是内存只是减少了一点点,不影响大局,这个也pass

  1. 怀疑是不是由于使用 provider 导致的内存泄漏,因为 rsc 可以通过 provider 把 server 的数据下发到组件里面,由于上一个项目采用了同样的技术栈,却没有内存泄漏,有理由怀疑是这个引起的,后来把服务端组件几乎全部注释掉了,仍然存在问题,这个怀疑 pass

  1. 会不会是 nodejs 引起的内存泄漏,升级到最新的lts版本,总体内存会下降 5MB 左右,猜测是 node 自身做了优化,但是增长势头明显,这个理由也 pass

  1. 猜测会不会是某个 nextjs 配置文件导致的问题,把 next.config.mjs 里面的代码几乎删光了,仍然有问题存在,配置问题的想法 pass

  1. 这时候猜测是不是由于某些包没有升级到最新版本,怀疑是不是 react-query 这个库的缓存导致的问题,因为使用了 react-query 在服务端会默认有5s的配置缓存,升级依赖到最新版本,参考官网最新的demo, 把缓存降低到0,继续测试,问题依然存在,甚至新建个 nextjs 全新的空应用,使用 react-query 库,不存在问题,猜测 pass

  1. 之前的一个项目也使用的同样的技术栈,稍微压测一下没有发生内存泄漏的问题,觉得肯定是两个项目有什么差异点导致的,把新项目的页面copy一个到老的项目里面,layout 什么的也使用新项目的文件,问题依然存在,ps:这个复制的过程相当复杂,因为新项目是个复杂的大项目,所以复制一个页面的牵扯还是比较大的, 几乎复制了整个项目的依赖过去了,过程相当痛苦

  1. 快不行了,感觉完全没有思路了,当时的心情感到很绝望,苦笑了一下,突然想到三体里面的一句话:

    物理学不存在了!

    完全没什么想法了,使用控制变量法,终极删除源码大法

    • 删除其他所有页面,只留下一个最简单的登录页面,问题依然存在
    • 删除 layout面的 i18n provider,问题依然存在
    • 删除所有的 server组件,只留下最后的 client组件,问题依然存在
    • 删除没用的所有的配置文件,问题依然存在
    • 删除 layout 里面所有不相干 provider ,问题依然存在
    • 删除 client组件,问题没了,但是代码也没了,没意义

  1. 由于我们的组件主要是使用了formliyjs技术栈,这时候几乎删光了代码的情况下,猜测可能是formliyjs引起的,新建个空的nextjs应用,新增一个官网的demo,压测一下,没发现问题

  1. 把官网的demo,非常简单,就一个 form, 动态创建2000个 input,放到项目的根目录,OK,内存没有增长,接下来放到登录页同级别目录,依然存在问题,而且很严重,一个请求可以达到 100MB,特别夸张

  1. 现在发现不是项目业务代码的问题,留下一个官网的最简单的 form,继续删除外部的无关代码,layout 里面只剩下使用了一个 nextjs 官方的 headers() 的server代码,目的是拿到请求头的参数,删除这一行代码后

    问题消失了!!! ,猜测是使用了nextjs server端的动态方法之后,跟lformiyjs不兼容引起的问题,再次实验,使用一下nextjs提供的searchParams获取一个简单的searchParams,问题又出现了,猜测成立,果然是 1+1 导致的问题

分析结果

得到最终原因:formliyjsnextjs 动态渲染,会导致 nextjs 在node端缓存form的实例,导致内存泄漏

解决方案

已经找到了问题根源所在,开始着手寻找解决方案

原来的代码:

import { createForm } from '@formily/core'
 import { Form } from '@formliy/antd'
 import { useMemo } from 'react'

 const Cmp = () => {
   const form = useMemo(() => createForm(),[])
   return <Form form={form}><Form/>
 }
  1. 首先想到的是把form移到react组件之外,如下代码

    import { createForm } from '@formily/core'
    import { Form } from '@formliy/antd'
    
    const form = createForm()
    
    const Cmp = () => {
      return <Form form={form}><Form/>
    }
    

    但是这样会存在一个问题,原来在内部写的 effects 的一些生命周期函数比如 onFormInit 会无法执行,这样我们不可避免的因为改这个问题要动刀到原来的业务代码,这是我们不想看到的现象,这样代价太大

  2. 既然这条路行不通,继续猜测,由于SSR不会执行 useEffect 里面的代码,那我们可以把 form 放到 useEffect 里面,结合高阶函数,后边简称hof,来把 form 组件在 hofuseEffect 里面传递给组件,这样是不是可行呢,试了一下,果然可行,不过这样仍要把effects 里面的逻辑放到 hof 里面,改动还是一样大,但是方案是OK的,那么这时候继续猜测,是不是把 createForm 这个方法放到 hof 里面就OK了呢, 下边我们给出代码

    import { createForm as formilyCreateForm } from '@formily/core'
    import React, { useEffect, useRef, useState } from 'react'
    
    export type PropsWithCreateForm<P = {}> = P & { createForm: typeof formilyCreateForm }
    
    const isForwardRef = (component: any): component is React.ForwardRefExoticComponent<any> => {
      return component?.$$typeof?.toString() === 'Symbol(react.forward_ref)'
    }
    
    export const withForm = <P extends object>(
      Child: React.ComponentType<PropsWithCreateForm<P>> | React.ForwardRefExoticComponent<PropsWithCreateForm<P>>
    ) => {
      const WrappedComponent = (props: Omit<P, keyof PropsWithCreateForm>, ref?: React.Ref<any>) => {
        const createFormRef = useRef<typeof formilyCreateForm | null>(null)
        const [showChild, setShowChild] = useState(false)
    
        useEffect(() => {
          createFormRef.current = formilyCreateForm
          setShowChild(true)
        }, [])
    
        if (!showChild) {
          return null
        }
    
        if (isForwardRef(Child)) {
          return <Child {...(props as P)} createForm={createFormRef.current!} ref={ref} />
        }
    
        return <Child {...(props as P)} createForm={createFormRef.current!} />
      }
    
      WrappedComponent.displayName = `withForm(${Child.displayName || Child.name || 'Component'})`
    
      return isForwardRef(Child) ? React.forwardRef(WrappedComponent) : WrappedComponent
    }
    
    

    然后我们对原来的页面做如下修改, 把原来组件用 withForm 包裹,createFormprops 里面引入

    import { Form } from '@formliy/antd'
    import { useMemo } from 'react'
    
    const Cmp = withForm(({ createForm }: PropsWithCreateForm) => {
      const form = useMemo(() => createForm(),[])
      return <Form form={form}><Form/>
    })
    

    马不停蹄的测试了一个页面, 这样也是OK的!内存已经完全下来了,压测了两个小时内存几乎非常稳定,不再一直增长,至此这个内存泄漏问题最终得到了解决

总结

此次问题最终的解决起来还算OK的难度,但是发现问题是个非常艰难的过程,也得到一些教训

  1. 下次如果碰到类似的困难问题,应该直接采用直接做减法进行测试,在尽量小的scope里面去找到问题的核心根源所在,猜测的话应该尽量少的进行,进行1-2次实验性后就不应该抱有侥幸能够猜中的心理进行试探

  2. SSR 内存泄漏问题是个复杂的综合性问题,实在有些库不兼容可以使用 useEffect 进行一些灵活操作

  3. 内存泄漏问题解决要秉承抓大放小的原则,着眼于最大的问题,往往最大的解决了,其他的小问题就迎刃而解