T3 Stack全栈实践复盘:tRPC、Prisma与Serverless部署的踩坑指南
发布时间:2026/10/26 8:54:19 锦皓数字建站

这个标题我一直想把玩透。如果你对“t3code”这个词还有印象大概率是在前端社区里刷到过 TypeScript、Tailwind、Next.js 这套组合也就是大家口中的 T3 Stack。我去年折腾一个小工具项目时整个技术选型就是从create-t3-app起步的一路把数据库、鉴权、接口层和前端类型全部串起来最后上线跑了几个月过程相当有代表性。这篇就把我实际动手的全过程复盘一遍包括脚手架初始化、数据建模、tRPC 路由设计、NextAuth 的坑、Prisma 的事务与迁移以及部署到 Serverless 环境后的连接池和冷启动问题。无论你是刚开始接触这套组合还是在已有项目里局部接入应该都能找到值得抄作业的地方。1. 为什么让 t3code 站在 T3 阵营而不是东拼西凑一次技术选型的完整复盘动手写代码之前最容易被忽略但恰恰最值得花时间的是搞清楚自己到底需要什么。我当初想做的是一个面向开发者的代码片段管理工具名叫 t3code核心功能很直接支持 Markdown 写片段、按标签检索、带简单的用户体系和云端同步。需求听起来不复杂但一旦开始选型就会发现“全栈”这两个字的水很深。1.1 全栈类型安全是怎么“香”起来的先讲一个我个人的体会。以前做前后端分离项目接口文档写得好好的联调阶段照样会冒出字段名不一致、类型不匹配这种破事。T3 Stack 的核心卖点不是某个框架多强而是把 TypeScript 一路从数据库贯穿到浏览器数据库表结构Prisma Schema ↓ 生成类型Prisma Client / Zod 推断 ↓ 接口入参与输出tRPC Router ↓ 前端调用React Query 自动类型推断这一整条链路里每一层的数据形状都是同构的。你在前端写了trpc.snippet.list.useQuery()返回结果的类型是后端替你推断好的你在后端改了 Prisma 模型跑一次prisma generate后前端立刻报类型错误。这相当于用编译期检查把大量“运行时才知道爆炸”的问题提前拦住了。但这里要澄清一个常见误解T3 Stack 不等于“三个工具组合那么肤浅”。严格来说它的核心是 TypeScript 优先、全栈端到端类型安全具体工具通常是 Next.js Tailwind tRPC Prisma NextAuth。Tailwind 管样式Next.js 管页面和渲染tRPC 管接口Prisma 管数据库NextAuth 管登录。这五个东西拼在一起比你自己去搭 Express Sequelize CORS 中间件要省心得多。1.2 create-t3-app 的初始化选项到底该怎么选按照官方一条命令就能初始化pnpm create t3-applatest交互式命令行会问你要不要启用 Tailwind、tRPC、Prisma、NextAuth、Drizzle 之类。我当时的勾选是这样的选项我的选择理由TailwindCSS勾选开发 UI 效率高原生的 utility class 很适合后台管理页tRPC勾选我不想再写 REST 路由 手写类型定义这一项就是核心Prisma勾选迁移工具链成熟类型生成和 tRPC 配合得非常好NextAuth勾选节省自建登录的时间后面一个 adapter 就能接数据库Drizzle不勾当时 Drizzle 的生态还没完全成熟我选了更稳的 Prisma导入路径别名勾选/server这类写法极大减少相对路径地狱全局安全校验勾选默认开strict新建项目没必要跟自己过不去如果你完全没概念建议照单全收官方推荐项先用默认配置跑通再说。直接把所有内容都接通比一个个工具单独集成然后再拼起来要顺滑很多。注意create-t3-app会自动生成一套合理的目录结构比如src/server/api/root.ts作为所有子路由的聚合出口src/server/db.ts作为 Prisma Client 的单例入口。这套约定比你自己随手放几个文件夹要科学后续维护时能省掉大量找文件的时间。一个值得多花 10 分钟去理解的地方是 Prisma 实例的写法。新版本的db.ts大多长这样import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error, warn] : [error], }); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;之所以要挂到globalThis上是因为开发环境下热重载会频繁创建新的 PrismaClient 实例每次初始化都新建数据库连接很容易把连接数打满。这个写法在 T3 App 生成器里是默认给出的但很多人第一次见时会觉得多余实际上这是压测过的保命技巧。2. t3code 的数据模型与接口骨架先设计表再设计路由初始化跑通后我会习惯先别急着写页面而是把数据模型和接口雏形定下来。t3code 的场景比较典型用户、代码片段、标签、收藏关系再加一条审计日志。2.1 从零开始建模我踩过的重复字段和软删除的坑Prisma 的 Schema 文件是prisma/schema.prisma我先定义了最核心的几个模型当时的设计大致如下model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] snippets Snippet[] favorites Favorite[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Snippet { id String id default(cuid()) title String content String db.MediumText language String default(plaintext) visibility Visibility default(PRIVATE) authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) tags Tag[] favorites Favorite[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String snippetId String? snippet Snippet? relation(fields: [snippetId], references: [id]) } model Favorite { userId String user User relation(fields: [userId], references: [id], onDelete: Cascade) snippetId String snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) createdAt DateTime default(now()) id([userId, snippetId]) } enum Visibility { PRIVATE PUBLIC UNLISTED }这里有几个当时反复斟酌的点直接分享给你ID 用 CUID 而不是自增整数多实例部署或数据迁移时字符串 ID 不会撞车也不容易被爬虫枚举。标签用简单的关联表还是多对多我第一批版本图省事做了单字段关联后面发现一个片段要打多个标签时查询逻辑变得非常别扭。后来老老实实加了中间表按Tag反查片段时一条findMany就能解决。软删除要不要做对代码片段这种数据用户误删可能引发较大损失。我刚开始直接delete后来面对用户反馈才补了deletedAt字段。我的建议是一开始就在模型里预留deletedAt DateTime?哪怕第一版不用后期加上去也不用改一堆引用关系。db.MediumText默认的 String 在某些数据库下被映射为TEXT长度往往够用但如果用户粘贴的片段非常大建议主动声明db.MediumText或db.LongText不要等到生产环境被截断才追悔。2.2 tRPC 路由塑形把输入校验和鉴权焊死在入口T3 Stack 的接口层不是传统意义的“路由”而是一个个 tRPC Router。每个 Router 里可以定义query查询和mutation写操作前端调用时就像调用本地函数一样。t3code 里我写了一个典型的snippetRouterimport { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from /server/api/trpc; import { TRPCError } from trpc/server; import { Visibility } from prisma/client; const snippetInput z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string().default(plaintext), visibility: z.enum([Visibility.PRIVATE, Visibility.PUBLIC, Visibility.UNLISTED]).default(PRIVATE), tagNames: z.array(z.string().max(20)).max(8).optional(), }); export const snippetRouter createTRPCRouter({ create: protectedProcedure .input(snippetInput) .mutation(async ({ ctx, input }) { const userId ctx.session.user.id; const snippet await ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, visibility: input.visibility, authorId: userId, tags: { create: input.tagNames?.map((name) ({ name })) ?? [], }, }, }); return snippet; }), listMine: protectedProcedure .input(z.object({ cursor: z.string().nullable().optional(), limit: z.number().min(1).max(50).default(20) })) .query(async ({ ctx, input }) { const userId ctx.session.user.id; const items await ctx.db.snippet.findMany({ where: { authorId: userId, deletedAt: null }, include: { tags: true }, orderBy: { updatedAt: desc }, cursor: input.cursor ? { id: input.cursor } : undefined, take: input.limit 1, }); let nextCursor: string | null null; if (items.length input.limit) { const nextItem items.pop(); nextCursor nextItem?.id ?? null; } return { items, nextCursor }; }), });你可能会疑惑为什么用protectedProcedure而不是publicProcedure。答案很简单这是 T3 Stack 提供的鉴权中间件它会自动检查ctx.session未登录用户直接抛出未授权异常省得自己在每个 mutation 里写一遍if (!ctx.session) throw...。关于 cursor 分页提醒你一句我一开始想用skip做偏移量分页数据多了以后性能肉眼可见地下降而且用户删除中间数据会导致页码错位。换成 cursor 分页后列表加载和下拉刷新都稳定了很多。Prisma 的 cursor 分页不是简单用skip而是要保证分页字段有唯一性所以通常直接用主键id当 cursor再配合take: limit 1判断是否还有下一页。2.3 为什么我最终放弃了另一半 REST 接口这个项目我一开始是 tRPC 和 REST 混着写的理由是“某些外部系统可能需要调接口”。直到很后面我才意识到对 t3code 这种前后端一体的项目来说纯内部调用场景下 REST 唯一的好处是“看着熟悉”而代价是每个接口都要手动定义入参、出参类型前端要维护一套请求封装和错误处理接口文档和代码实现很快就会不同步如果你确实需要把接口暴露给第三方我的建议是在外层再单独起一层 OpenAPI 的适配器而不是在 Next.js 里同时维护两套路由体系。内部功能全走 tRPC外部合作走 OpenAPI 网关这个边界非常清晰。3. 最容易翻车的地方不是类型是数据库与身份验证的互操作T3 Stack 的亮点是类型全链路但真正容易让人血压飙升的往往是工程细节。我这边首当其冲踩坑的就是 NextAuth 与 Prisma 的搭配以及 Prisma 自己在事务上的小脾气。3.1 NextAuth 配置笔记Session 策略、Adapter 和回调一个都不能少create-t3-app生成的auth.ts已经帮我们挂好了 PrismaAdapter但默认配置直接上线会有几个坑。拿我的实际配置举例import { PrismaAdapter } from next-auth/prisma-adapter; import { NextAuthOptions } from next-auth; import { db } from /server/db; export const authOptions: NextAuthOptions { adapter: PrismaAdapter(db), session: { strategy: database }, callbacks: { session: ({ session, user }) { if (session.user) { session.user.id user.id; } return session; }, }, providers: [], };重点解释两个地方session: { strategy: database }默认的 JWT 策略会把用户信息编码进 Cookie好处是无状态但用户资料变化时前端拿到的还是旧信息。t3code 里有用户改名、头像更新等需求用 database 策略更合适。代价是每次读取 session 都会查一次数据库后面我会用缓存缓解。callbacks.session里把user.id挂到session.user上这个非常关键。PrismaAdapter 模式下session 回调的user参数才是真实数据库用户对象而默认的session.user只有name、email、image。如果你在代码里写session.user.id不加这个回调就是一个空值。我上线第一个版本时因为少加这个回调导致前端所有protectedProcedure都查不到用户 ID报错定位了半天。另外如果你用了第三方登录 Provider强烈建议在pages.signIn里自定义一个版权和隐私说明页避免直接跳官方的默认页。3.2 Prisma 连表查询、事务与迁移的实战细节Prisma 的学习曲线不算陡但有几个细节会让人抓狂。第一个是嵌套查询与 include 深度。t3code 的片段列表页需要展示作者姓名和标签列表我最开始写了这样的查询await ctx.db.snippet.findMany({ include: { author: { select: { name: true } }, tags: true }, });这个写法没问题但要注意include里的关联字段如果又包含多层关系查询性能会指数级下降。SPA 列表页数据量到几万条以后务必把不需要的大字段比如content剔除掉。Prisma 支持select白名单哪怕 include 也需要在每层指定要的字段不要让数据库把整行对象都捞出来。第二个是事务与并发更新。如果用户给片段新增标签的同时另一处逻辑在更新片段标题两个操作交叉执行可能会导致数据覆盖。Prisma 提供两种事务// 交互式事务 await ctx.db.$transaction(async (tx) { await tx.tag.create({ data: { name: react, snippetId } }); await tx.snippet.update({ where: { id: snippetId }, data: { updatedAt: new Date() } }); });交互式事务适合需要读取中间结果再决定后续操作的场景但要注意事务内部的查询也会占用连接别在里面写耗时的第三方接口调用。如果只是把几个固定写操作合并执行用数组式事务更轻量await ctx.db.$transaction([ ctx.db.favorite.deleteMany({ where: { userId, snippetId } }), ctx.db.snippet.update({ where: { id: snippetId }, data: { favoriteCount: { decrement: 1 } } }), ]);第三个是迁移流程。很多初学者会问为什么prisma migrate dev生成的 SQL 和本地表对不上这通常是因为你手改数据库表但没有同步 Schema 文件。记住Prisma Schema 是唯一事实来源所有库表结构的修改都必须先改 Schema再跑迁移命令不要直接在数据库客户端里 alter table。我一个人开发时经常犯这个错后面直接约定所有结构变更都走 PR 评审提交里必须带迁移文件。3.3 全栈类型打通后开发效率的质变我有一个很直观的感受当 tRPC Prisma 的类型链路真的跑通之后前端代码里查“这个接口到底返回什么字段”的次数会骤降。因为编辑器里鼠标悬停一下就能看到完整返回类型。这让 t3code 在加功能时非常快。比如想给片段列表加一个“复制代码”按钮我不需要先去后端接口文档里确认content字段叫什么直接写const { data } trpc.snippet.listMine.useQuery({ limit: 20 }); code{item.content}/code重点在于类型错误会在编译期直接阻断你而不是等运行时报 undefined 或 Unexpected token。这一点在多人协作时尤其值得因为你的同事改完后端字段后跑一遍类型检查就知道自己破坏了多少调用方。4. 上线部署后的踩坑日志Serverless 环境下的连接池、冷启动与监控t3code 第一版部署在 Vercel 上数据库用的是 Postgres。整体跑得挺顺但真实流量进来后我体会到“开发环境没问题”和“生产环境稳如老狗”是两回事。这一节的几个坑请务必提前规避。4.1 数据库连接池Vercel 之上最容易无声崩溃的地方Vercel 的函数是 Serverless 的每次请求可能被路由到不同的实例。如果每个实例都新建一个 PrismaClient 连接连接数会迅速爆炸。我在db.ts里已经做了全局单例但这只能解决“热重载”问题生产环境每个 Lambda 函数实例仍然可能创建自己的连接池。我当时踩到的现象是运行一段时间后数据库偶发Too many connections报错但重启一下又恢复了。排查后发现原因是非连接池模式在请求高峰期撑不住。最终的解法是用连接池代理。最省事的是 Prisma 官方推荐的PrismaDataProxy或者自建 PgBouncer。如果你用的普通 Postgres又不想引入额外组件至少要把 Prisma 的connection_limit调低一点避免单个实例占太多连接new PrismaClient({ datasourceUrl: process.env.DATABASE_URL ?connection_limit5, });另外提醒一句不要在生产环境开启 query 日志。我把log: [query]只用在本机调试一旦带上生产环境瞬时大量查询会让日志平台被刷屏而且日志收集的计费也会高得让你肉疼。4.2 冷启动和边缘函数带来的真实延迟Next.js 的 API 路由和 tRPC 走的是 Serverless 函数冷启动时至少要加载 Next.js runtime 和 Prisma 引擎首请求延迟很容易到 2-3 秒。t3code 的片段列表页是核心入口用户体感非常明显。我的优化策略是页面级增量静态再生成对于公开片段列表这种读多写少的场景用export const revalidate 60让页面 60 秒重新生成一次大多数请求直接命中缓存。动态路由尽量保持简单不要再在冷启动函数里做过多的重计算字符串操作和 JSON 解析可以接受但不要在里面初始化重型 SDK。用边缘函数区域化部署Vercel 支持把函数部署到离用户更近的区域如果主要用户在国内部署到香港或新加坡区域会比默认的 us-east 好一些。但我不会把这里说得太玄乎实测延迟改善在 30%-50% 之间具体看网络环境。如果你用了 NextAuth 的 database session 策略还要注意一个联动问题每个页面请求读取 session 都会触发数据库查询。配合静态生成时尽量把 session 相关部分收敛到客户端组件里取避免在服务端渲染阶段为每个页面都打一遍数据库。t3code 后来把导航栏的用户信息改成客户端获取首屏 HTML 生成速度明显提升。4.3 第一批真实用户带来的数据问题从慢查询到索引优化上线两周后用户开始大量创建标签和收藏关系。慢查询开始出现主要集中在两个地方按标签搜索片段Tag表按name过滤但因为一开始没有索引数据量到数万条后查询时间明显爬升。收藏列表Favorite表按userId查列表同样缺索引。我的解决措施是在 Schema 里显式加上关系字段的索引model Tag { id String id default(cuid()) name String snippetId String snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) index([name]) index([snippetId]) } model Favorite { userId String snippetId String id([userId, snippetId]) index([userId]) index([snippetId]) }然后运行prisma migrate dev生成索引迁移。这里再次体现 Schema 即事实源的好处索引改动也能在迁移文件里被记录和回溯不会像在数据库客户端里直接执行CREATE INDEX那样容易丢记录。关于慢查询定位我的建议是上线前就把 Prisma 的 query 日志导向到本地或者直接看数据库侧的慢查询日志。Prisma 本身不会帮你分析 SQL 性能但它会忠实地把每条查询语句打到终端。用PrismaClient初始化时的log配置开发环境开启query生产环境至少要保留warn和error。这里还有个小技巧当你在 tRPC 里发现某个接口慢不要急着加缓存先把实际生成的 SQL 捞出来看一遍。很多慢查询问题不是缓存可以解决的而是 N1 查询或缺失索引。比如你在循环里对每个片段查一次标签这种循环查询在开发环境数据量小时毫秒级完成但用户数据一多就直接把数据库拖垮。Prisma 的include可以一次性把关联数据都拽回来千万不要顺手写循环内查询。4.4 可观测性日志和错误上报越早接越好这个项目刚上线时没有任何监控所有错误都靠用户截图反馈极其被动。后来我接了一套轻量方案前端错误用 Sentry 上报服务端日志通过结构化 JSON 输出再转发到日志平台。如果你不想引一堆依赖至少要做到两件事所有 tRPC mutation 的错误都在onError里统一上报确保用户操作一旦失败你能立刻看到堆栈和入参export const withErrorReporting (router: Router) { return router.onError(({ error, ctx }) { console.error({ message: error.message, code: error.code, ctx, timestamp: new Date().toISOString(), }); }); };给数据库加慢查询阈值告警。Postgres 可以使用pg_stat_statements观察高频慢查询Vercel 自带的监控面板也够用。不要等到用户投诉“页面转圈”才去查日志。个人建议对中小型全栈项目来说不用一上来就搭一整套微服务级别的链路追踪一个错误上报 一份结构化日志足够撑到十万级用户。重点在于从第一天开始就要把错误记录下来而不是在故障复盘时才想起当时为什么什么日志都没留下。5. 收尾但不过时t3code 延期规划里我打算做什么、不做什么项目上线只是开始后续演进才真正考验审美。t3code 目前的功能已经稳定但我在后续规划上有一条非常明确的原则不盲目加模块只解决真实需求。至少会把数据库的软删除机制落到位。之前预留的deletedAt字段至今还没有真正开启全局过滤每一处查询都要在where里手动加deletedAt: null时间一长就很容易漏。更好的做法是在 Prisma 层面通过中间件统一过滤或者干脆用视图/分区表去兜底保证改动对业务层透明。微调收藏和标签之间的关系。收藏列表页现在还会偶尔触发深分页问题虽然用 cursor 已绕开但性能仍不够优雅。理想方案是把收藏关系改为独立聚合表冗余一份片段摘要字段避免收藏列表每次都要去 join 大表拿 title 和 language。这个属于典型的“用空间换时间”优化数据量到几十万条后收益会非常明显。不想做什么呢第一是不做移动端原生应用Web 渐进式增强已经覆盖了绝大多数使用场景第二是不给片段系统引入复杂的权限组概念个人工具的价值恰恰在于极简第三是不跟风把链路改成 monorepo 的微服务拆分。项目规模没有到那个量级之前维护一套干净的全栈代码比引入运维复杂度更实际。如果再让我从零开始选一次技术栈我大概率还是会选 T3 这一套。不是因为它是社区热门而是因为它在中小型项目上真的把“全栈类型安全”和“开发效率”平衡得很好。如果你也正在用t3code或类似的代号规划自己的项目希望上面这些踩坑记录能帮你少走几段弯路。最后分享一个非常小的实用技巧写 tRPC 路由时可以把所有入参 Zod Schema 单独放到一个schema.ts文件里这样同时方便前端做表单校验时引用同一套规则。你会发现这个简单的抽离让前后端校验逻辑永远保持同步也是 T3 全栈体验里最被低估的一环。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。