AI时代中 一次node(SSR)内存泄漏实战解决

0 阅读3分钟

背景

前段时间运维报告过来说线上pod重启了,原因是OOM了,然后mentor把这个活交给我了,让我排查一下,当时心想坏了没做过,呜呜,最后我还是一个人解决了,过程中有一些坑,踩了一些狗屎文章的误导,最后还是突发奇想给解决了,下面说一下我解决这个问题的思路,以及过程中踩的一些坑。

狗屎方案:想办法打node快照,获取堆栈文件来分析

当时想法是我写一个接口,在内存达到50% 或者70%的时候运维写一个脚本访问我的接口,接口生成内存的快照

const v8 = require("v8");  
const fs = require("fs");  
  
function POST() {  
const fileName = `heap-${Date.now()}.heapsnapshot`;  
  
const stream = v8.getHeapSnapshot();  
const file = fs.createWriteStream(fileName);  
  
stream.pipe(file);  
  
file.on("finish", () => {  
console.log("Heap snapshot saved:", fileName);  
});  
}  
  

但是这个方案,会有几个现实的问题

  • 即使我生成了堆栈文件 甚至上g 我也是第一次,我也看不懂啊,市面上的ai 分析 他也不可能分析好几个g的文件啊,而且生成的堆栈文件肯定是两三个一起扔给ai呀,这也不行呀
  • 当我上到测试环境的时候,卧槽我访问这个接口 测试环境直接502了,当时我以为不是我的问题 ,我把接口下下来,测试环境又好了,卧槽勒。 后面我去看了一下node文档,这里附带原文: Generating a snapshot is a synchronous operation which blocks the event loop for a duration depending on the heap size.

image.png 这v8的这个getHeapSnapshot函数是同步的,并且会阻塞事件循环,并且获得内存快照是一个高密集cpu任务,当时想着天塌了,得亏没上线,这上线了不把线上给搞崩溃了啊。

狗屎方案二、三:Core Dump + llnode 或者开子进程

  • 方案二:Core Dump 这个方案我没实现,因为要依靠运维在崩溃的时候拿到dump文件,并且我用llnode给他转一下,运维没啥时间,搞鸡毛,并且就算拿到了我也未必能分析出来
  • 方案三:开子进程,这个方案不行的,因为你开了子进程,通过V8拿的内存快照是子进程的

最后我解决的方案:压测 + ai分析

我们既然是SSR页面,那我完全可以写一个脚本,去压测我们服务器,看是哪个页面内存泄漏了,找到了页面,最后我让ai给我分析这个页面为什么内存泄漏了,这里压测也有限制:我们的SSR 服务是node启动的,他接收不了很大的并发,因此我采用串行的压测方式,压测的方式我选择的autocannon比较友好 并且一个页面压测2000次,每个页面压测间隔5分钟。

const autocannon = require('autocannon')
const DOMAIN = '测试域名'
// const DOMAIN = 'http://localhost:3001'

const PATH = [
    // 根页面
    // '/',

    // ai-search-redirect
    // '/ai-search-redirect',

    // apps
    // '/apps',
    // '/apps/uni-fold',

    // chat
    // '/chat',
    // '/chat/[sessionId]',
    // '/chat/1b548632-6674-4248-ac2d-dafaece01cec',

    // journal-library
    // '/journal-library/[[...home]]',
    // '/journal-library/chinese/technology',
    // '/journal-library/area/Geoscience/地球科学/1?cover=https%3A%2F%2Ficon.bohrium.com%2Farea%2F1.png&tab=International',
    // '/journal-library/curated/Top%20Picks%20in%20Medicine/医学精选/46?tab=International',
    // '/journal-library/journal-home?journalId=5997&tab=International',
    // '/journal-library/mine/subject',
    // '/journal-library/search/journal?keyword=JCR&tab=International',
    // '/journal-library/subject/chemistry,%20physical/物理化学/119?tab=International',

    // paper-details
    // '/paper-details/changes-in-elbow-flexion-emg-morphology-during-adjustment-of-deep-brain-stimulator-in-advanced-parkinson-s-disease/811877395857408001-10889',

    // patent-details
    // 专利仅内部使用 不用测试

    // scholar
    // '/scholar',
    // '/scholar/874a90de/Claudia_Felser',
    // '/scholar/search?searchKey=xin%20wang',

    // sciencepedia
    '/sciencepedia',
    // '/sciencepedia/feynman/special_functions-gamma_function_definition_as_an_integral',
    // '/sciencepedia/feynman/keyword/ultraproducts',
    // '/sciencepedia/agent-tools',
    // '/sciencepedia/agent-tools/rxn4chemistry_rxn-reaction-preprocessing',
    // '/sciencepedia/agent-tools/c/subdomain-chemistry_%26_computational_chemistry-reaction_prediction_%26_retrosynthesis_ecosystem',
    // '/sciencepedia/field/feynman/special_functions',
]
// token 拿测试环境的
// 自己测试 替换/chat/[sessionId] sessionId
const token = 'eyJhbGciOiJSUzI1NiIsIn'

function runTest(url) {
  return new Promise((resolve, reject) => {
    console.log(`\n🚀 Testing: ${url}\n`)

    const instance = autocannon({
      url,
      connections: 1,
      pipelining: 1,
      amount: 2000,
      headers: {
        Authorization: `Bearer ${token}`
      }
    })

    autocannon.track(instance)

    instance.on('done', (result) => {
      console.log(`✅ Finished: ${url}\n`)
      resolve(result)
    })

    instance.on('error', reject)
  })
}

function wait(ms) {
  return new Promise(resolve => setTimeout(resolve, ms))
}

async function main() {
  for (const path of PATH) {
    const url = `${DOMAIN}${path}`

    // 打印 URL 和开始时间
    const now = new Date()
    console.log(`\n Starting test for: ${url}`)
    console.log(` Start time: ${now.toLocaleString()}\n`)

    // 执行压测
    await runTest(url)

    // 等待 5 分钟(300000 ms)再执行下一个 URL
    console.log(`⏳ Waiting 5 minutes before next URL...\n`)
    await wait(5 * 60 * 1000)
  }

  console.log('🎉 All tests done')
}

main()

当然这个DOMAIN替换成自己项目的域名,最后我用node跑这个脚本,然后我去美美的睡觉了

image.png 等第二天起早看一下这个图,一看就知道发生内存泄漏了,但是我跑去美美睡觉了,怎么知道是哪个页面呢?没事的运维有openresty ,我去openresty 去看我知道是哪个页面发生了内存泄漏了 最后知道了哪个页面,把页面甩给cursor了,最后cursor找到是BroadcastChannel没有关闭

后续措施

一说到前端内存泄漏,大家想到的就是网上说的 要么没close,要么闭包,要么监听器没有移除,这些乱七八糟的谁不知道啊,要么就吆喝着拿内存快照,拿来分析,简直狗屎没水平。
最后我怎么预防后面的小伙伴写出这样的代码呢?总不可能后面内存又炸锅了让我去分析吧 我在cursor里面加了skills,避免类似事件再发生了

最后

最后聊一下时间和心态线吧:这个是年前的事情,年前一周再探索方案一,觉得可行,年当周生病了就没去上班,年后请了5天假期,想着回来美美把方案一上线,结果出幺蛾子,后面回来的一周,夹杂着其他需求和这个并行着,差不多一个月时间了,网上给的方案是真的狗屎啊,搞得谁不知道内存泄漏咋发生的,问题是要怎么解决啊,后来我解决了,我倒着分析,假如我看node内存泄露是否能通过堆栈文件看出来?答案是否定的,我后面去在本地压测和添加--inspect参数,发现BroadcastChannel确实不断在创建和监听,没有回收,但是泄漏的很小,压根找不到是他,更别说什么函数了,崩溃。