EZTor 线上故障排查实录
一个 Next.js 16 全栈项目在部署运维中踩过的坑和修复记录
背景
EZTor 是一个基于 Next.js 16 + PostgreSQL + Prisma 的英语词汇学习平台。最近在对线上版本进行升级部署后,陆续暴露出一系列问题。以下是完整的排查与修复过程。
问题一:Prisma 引擎版本不匹配导致 $queryRaw 全部崩溃
症状
打开"当然"(单词卡复习)功能时,始终显示**"公共词库中暂时没有单词"**,但公共词库管理页面显示数据库中有 17,000+ 条词汇记录。
Health check API 返回 {"status":"error"}。
排查
查看 PM2 日志发现:
TypeError: e.map is not a function
at xl (library.js:122:8241)
step: "flashcard_public"
msg: "Query $queryRaw flashcard_public failed"
这个错误同时出现在所有使用 $queryRaw 的地方:flashcard_public、topWordsRaw、dauRaw、dailyTrendRaw。而普通 Prisma 方法(findMany、count、findUnique)全部正常工作。
进一步检查发现,服务器的 OpenSSL 版本是 3.0.2,但 PM2 进程环境变量中强行指定了使用 openssl-1.1.x 的 Prisma 查询引擎:
PRISMA_QUERY_ENGINE_LIBRARY=.../libquery_engine-debian-openssl-1.1.x.so.node
虽然这个 .so 文件通过兼容库成功加载,但在处理 $queryRaw 的结果时,老引擎产生的结果格式与 Prisma 5.14.0 运行时预期不符,导致 e.map is not a function 的内部崩溃。safeQueryRaw 工具函数捕获了这个错误并静默返回空数组,前端于是渲染出"公共词库中暂时没有单词"。
修复
将 ecosystem.config.js 中的 PRISMA_QUERY_ENGINE_LIBRARY 指向 openssl-3.0.x 引擎:
- libquery_engine-debian-openssl-1.1.x.so.node
+ libquery_engine-debian-openssl-3.0.x.so.node
重启 PM2 后,Health check 恢复为 {"status":"ok"},$queryRaw 全部正常工作。
启示
PRISMA_QUERY_ENGINE_LIBRARY这个环境变量非常危险——它会覆盖 Prisma 的自动引擎检测- 在不同构建环境(macOS)和目标环境(Linux)之间部署时,引擎文件必须匹配目标系统的 OpenSSL 版本
safeQueryRaw这种静默吃错误的封装模式让问题变得极难排查——错误被吞掉,用户只看到一个空洞的 UI 提示
问题二:PostgreSQL 大小写敏感的列名引用
症状
日志中看到另一个 $queryRaw 错误:
column rgw.wordid does not exist (Code: 42703)
排查
问题是原始 SQL 查询中的列名引用问题:
JOIN "ReviewGroupWord" rgw ON w.id = rgw.wordId
PostgreSQL 对不带双引号的标识符会自动转为小写。rgw.wordId 实际执行时会变成 rgw.wordid,所以报了"列不存在"。
而同一查询中的其他列名是正确的:
WHERE rgw."reviewGroupId" = ${groupId} -- ✅ 带引号
LEFT JOIN "PublicWord" pw ON pw.id = w."publicWordId" -- ✅ 带引号
修复
- JOIN "ReviewGroupWord" rgw ON w.id = rgw.wordId
+ JOIN "ReviewGroupWord" rgw ON w.id = rgw."wordId"
启示
Prisma 生成的数据库表名和列名默认是 camelCase(如 wordId、reviewGroupId)。在写原生 SQL 时,所有 camelCase 列名都必须加双引号,否则 PostgreSQL 会全部转为小写。
问题三:前端对 API 错误处理不当
症状
当用户未登录时打开"当然"功能,显示的是"公共词库中暂时没有单词"而不是"请先登录"。
排查
Middleware 对未认证用户返回 401:
{"success":false,"error":"未登录"}
但前端 fetchWords 函数只检查了成功路径:
if (data.success && data.data) {
setWords(data.data)
}
失败时 words 保持初始值 [],触发空状态渲染。
修复
增加 fetchError 状态变量,在 API 返回非成功响应时设置错误信息:
if (data.success && data.data) {
setWords(data.data)
} else if (!data.success) {
setFetchError(data.error || '获取单词失败')
}
UI 也相应区分显示:
{fetchError || '公共词库中暂时没有单词'}
问题四:Next.js standalone 构建静态资源不完整
症状
部署后浏览器报大量 chunk 加载错误:
GET /_next/static/chunks/0_mnfdu5xtgcn.js 404 (Not Found)
ChunkLoadError: Failed to load chunk /_next/static/chunks/0_mnfdu5xtgcn.js
Refused to execute script because its MIME type ('text/html') is not executable
排查
0_mnfdu5xtgcn.js 在本地 .next/static/chunks/ 中存在,但在 .next/standalone/.next/static/chunks/ 中缺失。Next.js 的 standalone 构建没有完整复制所有静态资源到最终的输出目录。
修复
将本地完整的 .next/static/ 同步到服务器,同时同步到 .next/standalone/.next/static/,然后重启 PM2。
rsync -avz .next/static/ root@server:/path/to/.next/static/
rsync -avz .next/static/ /path/to/.next/standalone/.next/static/
另外,nginx 配置中 /_next/static/ 是从项目根目录 .next/static/ 直接提供文件的,不是从 standalone 目录提供,所以两个位置都需要保持同步。
启示
Next.js standalone 模式会生成一个独立的运行目录 .next/standalone/,但 nginx 等反向代理可能直接从项目根目录的 .next/ 提供静态资源。每次部署时两个位置都需要保持同步。
问题五:TTS 依赖包未打包到 standalone 输出
症状
/api/tts 返回 500 Internal Server Error,但本地开发环境正常。
日志:
Error: WebSocket error occurred.
排查
src/lib/tts.ts 依赖 @lobehub/tts 和 ws 两个包。它们在 package.json 中有声明,但 next.config.ts 的 serverExternalPackages 配置只列了 svg-captcha:
serverExternalPackages: ['svg-captcha'],
在 Next.js standalone 模式下,没有被列入 serverExternalPackages 的包如果无法被 Turbopack 内联打包,就不会被复制到 .next/standalone/node_modules/。而 @lobehub/tts 和 ws 正好是这种情况——编译后的 chunk 引用了它们,但运行时的 node_modules 里却没有。
修复
将它们加入 serverExternalPackages:
- serverExternalPackages: ['svg-captcha'],
+ serverExternalPackages: ['svg-captcha', '@lobehub/tts', 'ws'],
启示
serverExternalPackages 的作用是告诉 Next.js:"这个包不要尝试内联打包,在最终输出中保留为外部 Node.js 模块"。对于使用了原生模块(或 WebSocket 等特殊 API)的包,这是必不可少的配置。
问题六:ws 包入口文件缺失
症状
即使 ws 包已经存在于 .next/standalone/node_modules/ 中,require('ws') 仍然报错:
Error: Cannot find module 'ws/index.js'
排查
对比发现,standalone 目录中的 ws 包缺少了两个入口文件:
# 源 node_modules/ws/
index.js
browser.js
lib/
wrapper.mjs
# standalone node_modules/ws/(缺失)
index.js ❌
browser.js ❌
lib/ ✅
wrapper.mjs ✅
Next.js 的 standalone 复制逻辑似乎将 .js 文件从根目录过滤掉了,只保留了 lib/ 子目录和 .mjs 文件。
修复
手动复制缺失的文件:
cp node_modules/ws/index.js .next/standalone/node_modules/ws/index.js
cp node_modules/ws/browser.js .next/standalone/node_modules/ws/browser.js
启示
Next.js standalone 构建的 node_modules 复制并非完美——某些包的顶层 .js 文件可能被遗漏。部署前检查关键依赖的完整性是必要的步骤。
总结
这次排查修复涉及了从数据库驱动到前端 UI 的6 个不同层面的问题:
| # | 问题 | 层面 | 根因 |
|---|---|---|---|
| 1 | Prisma $queryRaw 崩溃 |
数据库/部署 | 引擎与 OpenSSL 版本不匹配 |
| 2 | PostgreSQL 列不存在 | 数据库/SQL | camelCase 列名缺少双引号 |
| 3 | UI 显示混淆的空状态 | 前端 | 未处理 API 错误响应 |
| 4 | Chunk 404 | 构建/部署 | standalone 静态资源不完整 |
| 5 | TTS 500 | 构建/部署 | 依赖未配置 serverExternalPackages |
| 6 | require('ws') 失败 |
构建 | standalone 遗漏入口文件 |
最值得反思的是问题一的 safeQueryRaw 模式——一个本意是"防止崩溃"的工具函数,却成了问题排查的最大障碍。静默吞错让数据库层面的崩溃表现为最友善、最误导的 UI 提示,把排查方向引向完全不相关的地方。"安全"不等于"静默",生产环境的错误降级策略需要可观测性兜底。