有个内部用的 Node 服务跑了大半年,一直很稳定,直到有一次流量涨了之后开始隔三差五重启,起初以为是压力太大扛不住,查下来是几个平时不太会注意的坑凑一块了。

第一个是内存涨得很慢但是一直不掉,典型的内存泄漏迹象。用 --inspect 接 Chrome DevTools 抓了两次堆快照对比,发现有个数组一直在变大。翻代码发现是个全局缓存,写的时候图省事直接用了一个普通对象当缓存,从来没写清理逻辑:

1
2
3
4
5
6
7
8
const cache = {}

function getUserInfo(userId) {
if (cache[userId]) return cache[userId]
const info = fetchFromDB(userId)
cache[userId] = info
return info
}

用户量一直涨,这个 cache 就跟着一直涨,从来没有淘汰机制,本质上就是拿内存当无限容量的存储用了。换成 lru-cache 设个容量上限和过期时间就解决了,这种”缓存”写法在小项目里可能一辈子都不会暴露问题,量上来了就现原形。

第二个坑是事件循环被堵住,这个更隐蔽,因为它不报错,就是响应变慢。排查过程中用 perf_hooks 里的 monitorEventLoopDelay measure 了一下,发现延迟经常飙到几百毫秒。最后揪出来是一个同步的 JSON 序列化操作,处理的对象特别大:

1
2
3
4
5
app.get("/export", (req, res) => {
const bigData = collectAllRecords() // 几十万条记录
const json = JSON.stringify(bigData) // 同步操作,卡住整个事件循环
res.send(json)
})

JSON.stringify 处理大对象是同步阻塞的,跑的时候整个进程没法处理任何其他请求,哪怕那些请求跟这个接口毫无关系。单线程模型的代价在这种场景暴露得很彻底——一个慢接口能把整个服务的其他接口都拖慢。这个问题没有特别优雅的解法,最后是把这类重活丢到 worker_threads 里跑,或者干脆分页导出别一次性吐几十万条,再配合流式响应(res.write 分批写)把内存占用和阻塞时间都摊薄。

还有个坑是 unhandledRejection 没处理,有个 promise 链里漏了 .catch,报错直接被吞掉,日志里连个痕迹都没有,排查的时候完全无从下手,只能靠猜。后来在入口文件加了个兜底:

1
2
3
process.on("unhandledRejection", (reason, promise) => {
logger.error("unhandled rejection", { reason, promise })
})

这不是解决问题的办法,是给自己一个能看见问题的机会。真正的解法还是代码里该 catch 的都得 catch,但在那之前先把这个兜底加上,起码下次线上出问题不会两眼一抹黑。

依赖包版本这块也吃过亏,有次线上服务突然抛一堆奇怪的类型错误,查了半天是自动升级的某个依赖在小版本号里改了默认行为,package.json 里写的是 ^1.2.0 这种松版本号,CI 重新装依赖的时候直接拉到了不兼容的新版本。后来把关键依赖锁死成精确版本号,配合 package-lock.json 一起提交,升级都走人工评审,不让它自己偷偷跳版本。

这几个坑归根结底都是”平时流量小根本看不出来”的类型,内存缓存没上限、同步操作堵事件循环、promise 漏 catch,量小的时候一个月都不一定复现一次,流量一上来全暴露。写 Node 服务这种单线程模型的东西,写之前多想一句”这段代码会不会在高并发下卡住整个进程”,能省不少事后排查的时间。