Next.js 全栈踩坑实录:200 并发全线 503,我改了 5 个地方
先把范围说清楚,免得你看一半发现不适用:这篇讲的是日活 3000 左右的中型 SaaS 后台,栈是 Next.js 14 App Router + Postgres + Vercel 部署,月账单大概 90 美金。如果你做的是纯静态站、公司内网工具,或者还在学阶段,下面这些坑你八成碰不到,直接关掉不亏。
背景交代完,进入正题。
一、数据库连接池:第一个把我打醒的地方
那天晚上我拿 k6 起 200 个 VU 打登录接口,前 30 秒看着还行,然后 Vercel 的 logs 里红成一片,503。第一反应是函数超时,翻了下执行时长,最慢的 2.3 秒,离 10 秒的 Hobby 限制还远,不是这个原因。
查了半小时,才反应过来是连接数爆了。Supabase 免费计划直连上限 60,我 200 个并发进去,每个 serverless 实例都自己 new 一个 PrismaClient,几秒钟就把池子占干净,后面的请求全部排队到超时。Prisma 那个 connection_limit 默认值是 num_cpus * 2 + 1,在 Lambda 环境里读出来 num_cpus 是 1,也就是 3,单实例没问题,问题是实例有一堆。
改法是去 Supabase 后台打开 Supavisor,走 transaction 模式,端口换成 6543,连接串后面加 ?pgbouncer=true&connection_limit=1。这里有个坑:transaction 模式不支持 prepared statements,Prisma 那边必须显式关掉,不然会报一些很奇怪的错,我当时对着错误信息搜了两个小时才定位到。改完之后 P95 从 2.3 秒掉到 340ms,P99 大概 800ms 出头。
顺便说一句,PgBouncer 的 transaction 模式在同一个事务里跨语句是安全的,但如果你代码里有 SET 或者 advisory lock 这类依赖会话状态的东西,会静默出错。这个不是 Prisma 的锅,是 pgbouncer 的机制。
二、Prisma 换成 Drizzle,我拖了两周才下决心
这不是技术选型帖,我也不是来吹 Drizzle 的。Prisma 的 DX 是真的舒服,schema.prisma 一份文件搞定,migrate 也稳,Studio 还能点点点改数据。我用了两年多,没出过什么大问题。
换的原因有两个。一是 serverless bundle 体积,我实测打包出来 ORM 这层占了 1.6MB 左右,冷启动的时候加载明显有感觉。二是我想知道到底生成了什么 SQL——Prisma 的抽象层太高了,有次一个带三张表 join 加聚合的查询,我在本地怎么调都不对,最后开 query log 才发现它生成了两条查询然后内存里拼,那个接口本身数据量不大所以没暴露,但我心里一直不踏实。
Drizzle 的写法更接近 SQL,leftJoin、groupBy 这些你能一眼看出会翻译成什么。官方说核心包 min+gzip 之后是 7.4kb,实际带上 pg driver 和 schema 定义,我这边打包出来 300KB 左右,比 Prisma 那 1.6MB 小了一个量级。
换的过程比预想中痛。Drizzle 的 migration 工具那会儿(2024 年初)还不够成熟,drizzle-kit generate 出来的 SQL 我基本每份都要手动改,尤其是加索引和改列类型的时候。另外关系查询那个 db.query.xxx.findMany({ with: ... }) 的 API 和 SQL 风格的写法是两套东西,混着用会把自己绕晕。我的建议是选一套坚持到底。
值不值得换?如果你部署在 Vercel 这种 serverless 上,或者对 SQL 可控性有要求,我觉得值。如果是自己 VPS 上跑的 Node 进程,Prisma 那点体积和冷启动成本根本不算事,别折腾。
三、Server Actions 的 revalidatePath 是个温柔的陷阱
Next.js 14 的 Server Actions 真的很爽,表单不用再写 API Route,不用手写 fetch,不用处理 loading 状态,提交直接调函数。我一开始把所有表单都换过去了。
然后发现列表页变得很怪。用户改一条记录,整个列表重新拉一遍。500 条数据,每次提交都重新查、重新渲染,页面上能明显看到闪一下。原因是 revalidatePath('/admin/users') 是按路径全量失效的,它不管你改的是哪一行。
后来改成 revalidateTag,给查询打 tag,改哪条失效哪个 tag。这个是 Next.js 内置的缓存 API,配合 unstable_cache 用(15 之后是 use cache,API 又变了,这个后面再吐槽)。改完提交后的感知延迟从 300 多毫秒降到 50 以内,因为大部分数据直接命中缓存了。
还有一个更隐蔽的问题:Server Actions 默认是串行的。用户在页面上快速点两次提交,第二个会等第一个跑完才执行,中间如果没有乐观更新,用户会觉得卡住了。这个不是 bug,是设计如此,但文档里提得不明显,我是看源码才确认的。
四、长任务不该待在全栈框架里
这是我这轮重构里最大的一次认知修正。
我的后台有三个功能:导出 Excel(数据量大的时候要跑 20 多秒)、批量发邮件、每天凌晨跑一次数据对账。一开始我都塞在 Next.js 的 API Route 里,用 Vercel 的 Cron 触发对账。然后发现免费版 Cron 一天只能触发一次,Pro 才能到分钟级,导出那个接口在 Vercel 上超过 10 秒直接 504(Hobby 计划的函数超时限制,Pro 能到 60 秒,但 Streaming 也不是万能的)。
最后的方案是把这些全部挪到一个 Railway 上的独立 Node worker,用 BullMQ + Upstash Redis 做队列。Next.js 那边只负责把任务丢进队列,然后前端轮询状态。听起来多了一层,但实际维护成本反而下降了——worker 里可以随便起长连接、跑 30 分钟的任务、访问本地文件系统,不用再担心 serverless 的各种限制。
我现在的判断标准很简单:任务超过 10 秒、需要保持长时间连接、或者需要跑在自己的调度周期上,就别硬塞进全栈框架。这不叫架构复杂化,这叫各司其职。
五、监控不装,等于闭眼开车
说出来有点丢人,我是在出那次 503 之后才去装 Sentry 的。之前一直靠 Vercel 自带的 logs,问题是 logs 是流式的,你要主动去翻才看得到,出问题的时候你根本不知道要看哪一段。
现在装了三个东西:Sentry 抓错误和慢事务追踪,Vercel Speed Insights 看真实用户的 Web Vitals,还有一个自己写的健康检查接口每 5 分钟被 UptimeRobot 打一次。加起来一个月成本不到 30 美金,但那次事故如果早装一周,我可能省下三个通宵。
有个细节值得一提:Sentry 的 performance 采样率我一开始设的 1.0,量上来了之后事件配额一周就用完。后来改成 0.2,加了对慢事务的强制采样,够用了。这个采样率没有标准答案,得看你的量级和预算。
最后,我对全栈这件事的看法变了
Gloria Mark 那篇被引用烂了的研究说,人被中断之后平均要 23 分钟才能回到原来的专注状态。我觉得全栈开发有点像自己打断自己——写前端组件写到一半发现数据结构不对,跳去改 schema,改完再跳回来,那段组件逻辑已经忘了一半。
所以我现在衡量一个全栈方案好不好,标准不是它能做多少事,而是它逼我在多少层之间来回跳。tRPC 好用在它把类型从数据库一路透到前端,你改一个字段名,编译期就知道哪里崩了,不用来回对接口文档。但如果是跟第三方系统对接,我反而会老老实实写 OpenAPI 或者显式 REST,因为边界稳定、变更频率低,类型安全的收益没那么大,可读性和调试便利性更重要。
全栈不是一个技能标签,是一个架构决策——你决定把多少层压进一个部署单元里。压得越扁,上下文切换越少,但每一层的深度就越浅。这个权衡没有通解,只有适不适合你现在这个项目。
我现在这个项目,前端和业务逻辑全栈,数据层和长任务拆出去了。说它是全栈也不完全对,说它不是全栈也不完全对。可能大部分真实项目都长这样。