最近在项目中采用 Nextjs 14
的 App Router
模式 + formilyjs
为主要的技术栈,在压测发现了严重的内存泄漏
现象:
大概一个页面一个页面的GET请求进来可以增长3MB左右的可怕的内存占用,无法释放,随意压测100个请求可增长20M到30M
问题分析
工具:
- 分析工具:
Chrome Dev Tools
的Memory Tab
- vegeta 压测工具
原因分析过程:
- 在
chrome dev tools
中发现了很多大量的i18n
的字符串,怀疑是i18n
问题引起的,动手换了个i18n
的方案,显示结果依然是有很多i18n
在内存中,问题仍然存在,这个怀疑 pass
- 在
chrome dev tools
发现了一些样式,是antd
的所有样式合集,猜测是不是样式引起的问题,copy antd 的nextjs registry
源码去掉cache
后,node 端的样式没了,但是内存只是减少了一点点,不影响大局,这个也pass
- 怀疑是不是由于使用 provider 导致的内存泄漏,因为
rsc
可以通过 provider 把 server 的数据下发到组件里面,由于上一个项目采用了同样的技术栈,却没有内存泄漏,有理由怀疑是这个引起的,后来把服务端组件几乎全部注释掉了,仍然存在问题,这个怀疑 pass
- 会不会是 nodejs 引起的内存泄漏,升级到最新的lts版本,总体内存会下降 5MB 左右,猜测是 node 自身做了优化,但是增长势头明显,这个理由也 pass
- 猜测会不会是某个
nextjs
配置文件导致的问题,把next.config.mjs
里面的代码几乎删光了,仍然有问题存在,配置问题的想法 pass
- 这时候猜测是不是由于某些包没有升级到最新版本,怀疑是不是
react-query
这个库的缓存导致的问题,因为使用了react-query
在服务端会默认有5s的配置缓存,升级依赖到最新版本,参考官网最新的demo, 把缓存降低到0,继续测试,问题依然存在,甚至新建个nextjs
全新的空应用,使用react-query
库,不存在问题,猜测 pass
- 之前的一个项目也使用的同样的技术栈,稍微压测一下没有发生内存泄漏的问题,觉得肯定是两个项目有什么差异点导致的,把新项目的页面copy一个到老的项目里面,
layout
什么的也使用新项目的文件,问题依然存在,ps:这个复制的过程相当复杂,因为新项目是个复杂的大项目,所以复制一个页面的牵扯还是比较大的, 几乎复制了整个项目的依赖过去了,过程相当痛苦
-
快不行了,感觉完全没有思路了,当时的心情感到很绝望,苦笑了一下,突然想到三体里面的一句话:
物理学不存在了!
完全没什么想法了,使用控制变量法,终极删除源码大法
- 删除其他所有页面,只留下一个最简单的登录页面,问题依然存在
- 删除
layout
面的i18n provider
,问题依然存在 - 删除所有的
server组件
,只留下最后的client组件
,问题依然存在 - 删除没用的所有的配置文件,问题依然存在
- 删除
layout
里面所有不相干provider
,问题依然存在 - 删除
client组件
,问题没了,但是代码也没了,没意义
- 由于我们的组件主要是使用了formliyjs技术栈,这时候几乎删光了代码的情况下,猜测可能是formliyjs引起的,新建个空的nextjs应用,新增一个官网的demo,压测一下,没发现问题
- 把官网的demo,非常简单,就一个
form
, 动态创建2000个input
框,放到项目的根目录,OK,内存没有增长,接下来放到登录页同级别目录,依然存在问题,而且很严重,一个请求可以达到100MB
,特别夸张
-
现在发现不是项目业务代码的问题,留下一个官网的最简单的
form
,继续删除外部的无关代码,layout
里面只剩下使用了一个nextjs 官方的 headers()
的server代码,目的是拿到请求头的参数,删除这一行代码后问题消失了!!! ,猜测是使用了nextjs server端的动态方法之后,跟lformiyjs不兼容引起的问题,再次实验,使用一下nextjs提供的searchParams获取一个简单的searchParams,问题又出现了,猜测成立,果然是 1+1 导致的问题
分析结果
得到最终原因:formliyjs
加 nextjs
动态渲染,会导致 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/>
}
-
首先想到的是把form移到react组件之外,如下代码
import { createForm } from '@formily/core' import { Form } from '@formliy/antd' const form = createForm() const Cmp = () => { return <Form form={form}><Form/> }
但是这样会存在一个问题,原来在内部写的
effects
的一些生命周期函数比如onFormInit
会无法执行,这样我们不可避免的因为改这个问题要动刀到原来的业务代码,这是我们不想看到的现象,这样代价太大 -
既然这条路行不通,继续猜测,由于SSR不会执行
useEffect
里面的代码,那我们可以把form
放到useEffect
里面,结合高阶函数,后边简称hof,来把form
组件在hof
的useEffect
里面传递给组件,这样是不是可行呢,试了一下,果然可行,不过这样仍要把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
包裹,createForm
从props
里面引入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的难度,但是发现问题是个非常艰难的过程,也得到一些教训
-
下次如果碰到类似的困难问题,应该直接采用直接做减法进行测试,在尽量小的scope里面去找到问题的核心根源所在,猜测的话应该尽量少的进行,进行1-2次实验性后就不应该抱有侥幸能够猜中的心理进行试探
-
SSR
内存泄漏问题是个复杂的综合性问题,实在有些库不兼容可以使用useEffect
进行一些灵活操作 -
内存泄漏问题解决要秉承抓大放小的原则,着眼于最大的问题,往往最大的解决了,其他的小问题就迎刃而解