教训:Next.js .next 缓存反复损坏

记录日期:2026-02-12   PM2 重启 22 次 / 页面 404

1. 问题现象

症状:反复出现页面 404(页面文件明明存在),需要手动 rm -rf .next 才能恢复。过几天又坏。

典型错误日志:

# 编译产物丢失
Error: ENOENT: no such file or directory, open '.next/server/app-paths-manifest.json'
Error: ENOENT: no such file or directory, open '.next/server/app/_not-found/page.js'
Error: ENOENT: no such file or directory, open '.next/server/pages/_document.js'
Error: ENOENT: no such file or directory, open '.next/server/app/favicon.ico/route.js'

# Webpack 缓存文件损坏
[webpack.cache.PackFileCacheStrategy] Caching failed for pack:
  Error: ENOENT: ...stat '.next/cache/webpack/client-development/3.pack.gz'
  Error: ENOENT: ...stat '.next/cache/webpack/client-development/5.pack.gz'

# 幽灵配置重启
Found a change in next.config.js. Restarting the server to apply the changes...
Found a change in next.config.js. Restarting the server to apply the changes...  (一小时内 3 次)

2. 根本原因:三因叠加

不是单一问题,而是三个独立问题形成的连锁反应:

原因 1 PM2 信号传不到 Next.js(核心原因)

问题配置:

// ecosystem.config.js
{
  script: 'npm',          // PM2 管理的是 npm
  args: 'run dev',        // npm 再启动 next dev
  kill_timeout: 8000,     // 8 秒后强制 SIGKILL
}

实际进程树:

PM2 (守护进程)
  └── npm (PID 8499)          ← PM2 追踪这个 PID
        └── next dev (PID 8548)   ← 实际的 Next.js 进程

信号传递失败机制:

sequenceDiagram
    participant PM2 as PM2
    participant NPM as npm (PID 8499)
    participant NEXT as next dev (PID 8548)
    participant CACHE as .next/cache

    Note over NEXT,CACHE: Webpack 正在写 3.pack.gz ...

    PM2->>NPM: SIGTERM (优雅退出信号)
    Note over NPM: npm 收到 SIGTERM
    Note over NPM: 但 npm 不可靠地转发信号
    NPM--xNEXT: SIGTERM 可能丢失

    Note over PM2: 等待 kill_timeout (8s)...
    Note over NEXT,CACHE: Webpack 继续写缓存...

    PM2->>NPM: SIGKILL (暴力杀死)
    PM2->>NEXT: SIGKILL (杀死整个进程组)

    Note over CACHE: 3.pack.gz 写到一半被截断
    Note over CACHE: app-paths-manifest.json 丢失
    Note over CACHE: _document.js 被删除但未重建完
核心问题:npm 作为中间进程不会可靠地将 SIGTERM 转发给子进程。 这是一个 已知的 npm 行为。 PM2 只追踪 npm 的 PID,不知道真正的 next dev 进程在哪。 等 8 秒后 PM2 发 SIGKILL,所有进程被瞬间杀死——Webpack 正在写入的缓存文件就被截断损坏了。

原因 2 内存限制矛盾,触发频繁强杀

// ecosystem.config.js - 矛盾的配置
max_memory_restart: '1G',                 // PM2: RSS 到 1GB 就杀进程
node_args: ['--max-old-space-size=2048'], // Node: V8 堆可以用到 2GB
配置项含义
max_memory_restart1GBPM2 在 RSS 到 1GB 时触发重启(SIGTERM → SIGKILL 链)
--max-old-space-size2048MBV8 引擎认为自己可以使用 2GB 内存

Node 认为自己有 2GB 可用,但 PM2 在 1GB 就会强杀。一个大型 Next.js 项目(596 个测试文件、大量页面和组件),dev server 编译时内存很容易超 1GB。

证据:PM2 显示进程已重启 22 次,很多次是内存触发的暴力重启。

原因 3 next.config.js 导入外部文件触发幽灵重启

// next.config.js
import { getCurrentEnvironment } from '../../config/environments.js';  // 引用项目根目录的文件

Next.js 会监听 next.config.js 及其依赖的变化。../../config/environments.js 位于项目根目录,任何触及该文件的操作都会让 Next.js 误判"配置变了",触发服务器自行重启。

日志中一小时内触发了 3 次:

23:11:09  Found a change in next.config.js. Restarting the server...
23:14:43  Found a change in next.config.js. Restarting the server...
23:29:24  Found a change in next.config.js. Restarting the server...

3. 连锁反应的完整时间线

flowchart TD
    A["config/environments.js 被触及\n(编辑器保存 / git 操作 / 脚本运行)"] --> B["Next.js 检测到 config 变化\n自行重启"]
    B --> C["清空 .next/server/\n开始重新编译"]
    C --> D["编译中大量页面\n内存快速增长"]
    D --> E{"RSS > 1GB?"}
    E -->|"是"| F["PM2 触发 max_memory_restart\n发送 SIGTERM → npm"]
    E -->|"否"| G["编译完成\n暂时正常"]
    F --> H["npm 不转发 SIGTERM\n8 秒后 SIGKILL"]
    H --> I[".pack.gz 写入一半被截断\nmanifest.json 丢失"]
    I --> J["PM2 自动重启\n读损坏的缓存"]
    J --> K["更多编译错误\n更多 Full Reload\n更多内存消耗"]
    K --> E

    style A fill:#fff3cd
    style F fill:#f8d7da
    style H fill:#f8d7da
    style I fill:#f8d7da
    style K fill:#f8d7da
    style G fill:#d4edda

4. 修复方案

修复 1 PM2 直接启动 next,跳过 npm 中间层

原理:让 PM2 直接管理 next dev 进程,SIGTERM 能直接送达 Next.js,Next.js 有内置的优雅退出逻辑(完成当前编译、刷新缓存、关闭 WebSocket 连接等)。

// 修复前
{
  script: 'npm',
  args: 'run dev',
  interpreter: 'none',
}

// 修复后
{
  script: './node_modules/.bin/next',
  args: 'dev --port 3001',
  interpreter: 'none',
}
flowchart LR
    subgraph "修复前 (信号断裂)"
        PM2_OLD["PM2"] -->|"SIGTERM"| NPM["npm"]
        NPM -.->|"不转发"| NEXT_OLD["next dev"]
        PM2_OLD -->|"8s 后 SIGKILL"| NPM
    end

    subgraph "修复后 (信号直达)"
        PM2_NEW["PM2"] -->|"SIGTERM"| NEXT_NEW["next dev"]
        NEXT_NEW -->|"刷新缓存\n关闭连接"| EXIT["优雅退出"]
    end

    style NPM fill:#f8d7da
    style NEXT_OLD fill:#f8d7da
    style NEXT_NEW fill:#d4edda
    style EXIT fill:#d4edda

修复 2 内存限制与 Node 堆大小对齐

原理:PM2 的 max_memory_restart 应该 >= Node 的 --max-old-space-size,否则 Node 还没认为需要 GC,PM2 就已经把它杀了。开发环境中 Next.js 内存高是正常的(热编译、模块缓存),应允许更大的内存使用。

// 修复前 (矛盾)
max_memory_restart: '1G',                 // PM2: 1GB 就杀
node_args: ['--max-old-space-size=2048'], // Node: 我能用 2GB

// 修复后 (对齐)
max_memory_restart: '2G',                 // PM2: 2GB 才杀(对齐 Node 设置)
node_args: ['--max-old-space-size=2048'], // Node: 可以用 2GB

修复 3 稳定 next.config.js 的依赖

原理:将外部配置在 import 时一次性读取并缓存,避免 Next.js 的文件监听器误判配置变更。同时对配置值做环境变量优先,减少对外部文件的运行时依赖。

// 修复前 — 直接 import 外部文件(会被 Next.js 文件监听器追踪)
import { getCurrentEnvironment } from '../../config/environments.js';
let envConfig;
try {
  envConfig = getCurrentEnvironment();
} catch (error) {
  envConfig = { api: { baseUrl: 'http://localhost:3000' }, web: { port: 3001 } };
}

// 修复后 — 环境变量优先,外部文件仅作 fallback
const API_BASE = process.env.NEXT_PUBLIC_API_URL || process.env.API_BASE_URL || 'http://localhost:3000';
const WEB_PORT = process.env.WEB_PORT || process.env.FRONTEND_PORT || '3001';
为什么有效:

5. 验证方法

验证项命令预期结果
PM2 直接管理 next ps -o pid,ppid,command $(pgrep -f "next dev") next dev 的 PPID 直接是 PM2 守护进程,不再经过 npm
信号传递正常 pm2 restart celoria-frontend && pm2 logs celoria-frontend --lines 5 无 ENOENT 或 Caching failed 错误
内存不再频繁触发重启 pm2 describe celoria-frontend | grep restarts 重启次数不再快速增长
无幽灵配置重启 pm2 logs celoria-frontend --lines 100 | grep "change in next.config" 无匹配结果

6. 经验教训

总结:
  1. 永远不要用 npm run 作为 PM2 的 scriptnpm 不转发信号,用 node_modules/.bin/xxx 直接启动目标进程
  2. max_memory_restart 必须 >= --max-old-space-size — 否则 Node 还在合理使用内存,PM2 就把它杀了
  3. next.config.js 尽量不 import 外部文件 — 因为 Next.js 的文件监听器会追踪所有依赖,任何变更都触发服务重启
  4. 缓存损坏从来不是根因 — 它是"进程被暴力杀死"的症状。修缓存(rm -rf .next)治标不治本,要找到是谁在杀进程、为什么杀
适用范围:此问题模式不仅限于 Next.js。任何通过 PM2 管理的 npm run xxx 脚本都有信号丢失风险。包括 Storybook (npx storybook dev)、Vite (npm run dev) 等。Landing Page 的 PM2 配置也应检查。