ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

T3 Stack全栈实践复盘:tRPC、Prisma与Serverless部署的踩坑指南

T3 Stack全栈实践复盘:tRPC、Prisma与Serverless部署的踩坑指南

这个标题我一直想把玩透。如果你对“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-app@latest

交互式命令行会问你要不要启用 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 里我写了一个典型的snippetRouter:

import { 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_limit=5", });

另外提醒一句,不要在生产环境开启 query 日志。我把log: ["query"]只用在本机调试,一旦带上生产环境,瞬时大量查询会让日志平台被刷屏,而且日志收集的计费也会高得让你肉疼。

4.2 冷启动和边缘函数带来的真实延迟

Next.js 的 API 路由和 tRPC 走的是 Serverless 函数,冷启动时至少要加载 Next.js runtime 和 Prisma 引擎,首请求延迟很容易到 2-3 秒。t3code 的片段列表页是核心入口,用户体感非常明显。

我的优化策略是:

  1. 页面级增量静态再生成:对于公开片段列表这种读多写少的场景,用export const revalidate = 60让页面 60 秒重新生成一次,大多数请求直接命中缓存。
  2. 动态路由尽量保持简单:不要再在冷启动函数里做过多的重计算,字符串操作和 JSON 解析可以接受,但不要在里面初始化重型 SDK。
  3. 用边缘函数区域化部署: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 捞出来看一遍。很多慢查询问题不是缓存可以解决的,而是 N+1 查询或缺失索引。比如你在循环里对每个片段查一次标签,这种循环查询在开发环境数据量小时毫秒级完成,但用户数据一多就直接把数据库拖垮。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 全栈体验里最被低估的一环。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进