7.2 当接口出了问题
本节目标:了解 API 上线后最常见的几类问题——脏数据、重复提交、错误处理、资源耗尽——以及该怎么跟 AI 描述这些问题让它帮你修。
小明的应用上线了
小明的"个人豆瓣"终于做完了。电影列表有分页、有筛选、有排序,详情页一个接口返回所有数据,体验不错。他把链接发到朋友群里,让大家试试。
前三天,一切风平浪静。
第四天,问题开始了。
数据库里多了一条空标题的电影
小明打开 Drizzle Studio 检查数据,发现多了一条记录:标题是空的,年份是 0,导演 ID 指向一个不存在的导演。他很困惑——前端明明有输入框校验,标题为空时"提交"按钮是灰的,年份输入框只接受数字,怎么还能提交这种数据?
他问了一下朋友,朋友说:"我用 Postman 直接调你的 API 试了试,什么参数都没传,居然也能成功。"
小明这才明白:前端校验只能拦住"正常使用"的用户。任何人都可以绕过前端——打开浏览器开发者工具直接发请求、用 Postman 或 curl 调接口、甚至写个脚本批量调用。前端的输入框限制、按钮禁用、格式检查,在这些方式面前形同虚设。
这就像商场的门口有个保安,会提醒你"请走正门"。但如果有人从侧门、后门、甚至翻窗户进来,保安管不着。真正的安全检查在里面——每个柜台都会核实你的身份和购买资格。
前端校验是为了用户体验(即时反馈,不用等服务器响应就能告诉用户"标题不能为空"),后端校验是为了数据安全(守住底线,不管请求从哪里来,脏数据都进不了数据库)。两者缺一不可。
Zod——AI 会用的校验库
你跟 AI 说"给接口加参数校验",它大概率会用 Zod。Zod 是 TypeScript 生态中最流行的校验库,你不需要学它的语法,只需要知道三件事:
- 它做什么:定义"数据应该长什么样"的规则(比如标题必须是 1-100 个字符的字符串,年份必须是 1888-2030 之间的整数),然后自动校验传入的数据
- 校验失败怎么办:返回具体的错误信息给前端,告诉它哪个字段不对、为什么不对
- 为什么用它而不是手写 if-else:Zod 把校验规则和 TypeScript 类型合二为一——定义了校验规则,TypeScript 类型自动推导出来,不用写两遍。而且 Zod 的错误信息格式统一,前端处理起来方便
好奇 Zod 长什么样?展开看看,不看也没关系
import { z } from 'zod'
// 定义规则:标题必须是 1-100 个字符的字符串,年份是 1888-2030 的整数
const createMovieSchema = z.object({
title: z.string().min(1, '标题不能为空').max(100, '标题太长了'),
year: z.number().int('年份必须是整数').min(1888, '年份不能早于 1888').max(2030),
directorId: z.number().int().positive('导演 ID 必须是正整数'),
})
// 在 API 里使用
export async function POST(request: Request) {
const body = await request.json()
const result = createMovieSchema.safeParse(body)
if (!result.success) {
// 校验失败,返回 400 和具体的错误信息
return Response.json(
{ success: false, error: { message: result.error.issues[0].message } },
{ status: 400 }
)
}
// result.data 是校验通过的安全数据,类型自动推导
const newMovie = await db.insert(movies).values(result.data).returning()
return Response.json({ success: true, data: newMovie[0] })
}注意 safeParse 这个方法——它不会在校验失败时抛异常,而是返回一个包含 success 和 error 的对象,让你自己决定怎么处理。这比 try-catch 更可控。
校验应该严格到什么程度?
小明问老师傅:"是不是每个字段都要加一堆校验规则?"
老师傅说:"看场景。核心原则是——不信任任何外部输入,但也不要过度校验。"
| 必须校验 | 原因 |
|---|---|
| 必填字段不能为空 | 空数据进数据库会导致各种下游问题 |
| 字符串长度限制 | 防止有人传一个 10MB 的字符串把内存撑爆 |
| 数字范围限制 | 评分不能是 -1 或 999,年份不能是 3000 |
| 枚举值校验 | 状态只能是 "draft"/"published"/"archived",不能是任意字符串 |
| 外键存在性 | directorId 指向的导演必须真实存在(数据库外键约束也会拦,但提前校验能给更友好的错误信息) |
| 不需要过度校验 | 原因 |
|---|---|
| 内部服务之间的调用 | 你自己的前端调你自己的后端,数据格式是你控制的,基本校验就够 |
| 已经有数据库约束的字段 | 如果数据库已经有 UNIQUE 约束,代码里不需要再查一遍"有没有重复" |
跟 AI 说:
"给所有 POST 和 PATCH 接口加上 Zod 参数校验。必填字段不能为空,字符串限制合理长度,数字限制合理范围。校验失败返回 400 和具体的错误信息,格式用
{ success: false, error: { message: '...' } }。"
点击状态码查看说明。记住规律:2xx 成功、4xx 你的锅、5xx 服务器的锅。
同一部电影被添加了 10 次
小明的朋友发消息说:"我点了一下添加按钮,网络有点卡,我又点了几下,结果《千与千寻》出现了 10 次……"
小明打开数据库一看,果然——同一部电影有 10 条一模一样的记录,只是 ID 不同。
这是一个经典问题:重复提交。用户不是故意的,但网络延迟让他以为第一次点击没成功,于是又点了几次。每次点击都发了一个 POST 请求,后端老老实实地创建了 10 条记录。
前端拦一道,后端兜一道
前端层面——点击后立即禁用按钮,显示"提交中...",等请求返回再恢复。这能拦住 90% 的重复提交。但它拦不住所有情况——比如用户刷新页面重新提交、网络超时后自动重试、或者有人用脚本调接口。
后端层面——让接口具备幂等性(Idempotency)。
幂等是个数学概念,但用生活例子很好理解:
- 电梯按钮是幂等的。你按一次"3 楼",电梯去 3 楼。你着急多按了 5 次,电梯还是只去 3 楼,不会去 5 个 3 楼。
- 开灯开关是幂等的。灯已经开着,你再按一次开关,灯还是开着,不会变成"双倍亮"。
- 投币机不是幂等的。你投一次币出一瓶水,投两次出两瓶。
对于 API 来说:
GET天然是幂等的——查询同一个资源,不管查几次,结果都一样,数据库不会变DELETE也是幂等的——删除同一条记录,第一次删成功,第二次返回"已经不存在了",数据库状态不变POST(创建)默认不是幂等的——每次调用都会创建一条新记录,这就是小明遇到的问题
怎么让创建接口变幂等?
有几种方案,看场景选:
方案一:数据库唯一约束。 如果业务上"同一个标题 + 同一个年份"的电影不应该重复,就在数据库层面加联合唯一约束。第二次插入相同数据时,数据库会拒绝并报错,后端捕获这个错误返回 409(冲突)。
这是最简单的方案,适合有天然唯一标识的场景。但不是所有数据都有天然唯一标识——比如评论,同一个用户完全可以对同一部电影发两条不同的评论。
方案二:幂等键(Idempotency Key)。 前端每次提交时生成一个唯一的请求 ID(比如 UUID),放在请求头里。后端收到请求后,先检查这个 ID 有没有处理过——处理过就直接返回之前的结果,没处理过就正常执行。
这是最通用的方案,适合任何场景。支付接口几乎都用这种方式——你绝对不想因为网络重试导致用户被扣两次钱。
方案三:前端去重 + 后端兜底。 前端禁用按钮 + 后端加唯一约束,双保险。大部分场景这就够了。
小明的电影应用,方案一就够了——同一部电影(标题 + 年份相同)不应该重复录入。
跟 AI 说:
"给 movies 表的 title + year 加联合唯一约束。添加电影的接口如果遇到重复数据,返回 409 状态码和提示信息'这部电影已经存在了',而不是报 500 错误。同时前端在点击添加按钮后禁用按钮,等请求返回再恢复。"
所有报错都显示"出错了"
小明的朋友又来反馈了:"我添加电影时填错了年份,页面弹了个'出错了'。我搜一部不存在的电影,也弹'出错了'。你的服务器挂了也弹'出错了'。到底是我的问题还是你的问题?我该重试还是该改输入?"
小明检查了一下代码,发现所有接口的错误处理都是这样的:
catch (error) {
return Response.json({ error: '出错了' }, { status: 500 })
}不管什么错误——参数不对、数据不存在、数据库挂了——统统返回 500 和"出错了"。前端拿到这个响应,也只能显示一个通用的错误提示。用户完全不知道发生了什么、该怎么办。
HTTP 状态码:错误的分类系统
你肯定见过"404 页面不存在"——点了一个过期链接、输错了网址,浏览器就会显示 404。HTTP 状态码就是这套分类系统,404 只是其中一个。你不需要记住所有状态码,只需要知道最常用的几个:
| 状态码 | 含义 | 什么时候用 | 用户该看到什么 |
|---|---|---|---|
| 200 | 成功 | 请求正常处理 | 正常显示数据 |
| 201 | 创建成功 | POST 创建了新资源 | "添加成功!" |
| 400 | 请求有误 | 参数校验失败、格式不对 | "标题不能为空"(具体的错误信息) |
| 404 | 资源不存在 | 查询的电影 ID 不存在 | "找不到这部电影" |
| 409 | 冲突 | 重复添加同一部电影 | "这部电影已经存在了" |
| 429 | 请求太频繁 | 短时间内发了太多请求 | "操作太频繁,请稍后再试" |
| 500 | 服务器错误 | 代码 bug、数据库挂了 | "服务暂时不可用,请稍后再试" |
关键原则:400 系列是"你(调用者)的问题",500 系列是"我(服务器)的问题"。
这个区分非常重要,因为它决定了前端该怎么处理:
- 400 系列:用户可以自己修正。显示具体的错误信息,引导用户改正输入。比如"标题不能为空"——用户填上标题就能重新提交。
- 500 系列:用户做什么都没用,只能等。显示"服务暂时不可用,请稍后再试"就行。不要把内部错误信息暴露给用户——"PostgreSQL connection refused at 10.0.0.5:5432"这种信息对用户没用,还可能泄露服务器内部架构。
500 错误信息不要暴露内部细节
这是一个安全问题。如果你的 500 错误返回了数据库连接字符串、SQL 语句、文件路径、堆栈跟踪等信息,攻击者可以利用这些信息找到你系统的弱点。
正确的做法:500 错误只返回通用提示("服务暂时不可用"),详细的错误信息记到服务器日志里,方便你自己排查。
错误响应的统一格式
跟序言里约定的响应格式一致,错误响应也应该有统一的结构:
不同类型错误的响应示例
400 参数错误:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "标题不能为空",
"details": [
{ "field": "title", "message": "标题不能为空" },
{ "field": "year", "message": "年份必须是 1888-2030 之间的整数" }
]
}
}404 资源不存在:
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "找不到 ID 为 999 的电影"
}
}500 服务器错误:
{
"success": false,
"error": {
"code": "INTERNAL_ERROR",
"message": "服务暂时不可用,请稍后再试"
}
}注意 400 错误带了 details 数组,列出每个字段的具体问题。这样前端可以在对应的输入框旁边显示错误提示,而不是只在页面顶部弹一个笼统的"参数错误"。
跟 AI 说:
"统一所有接口的错误处理。参数校验失败返回 400,带上每个字段的具体错误信息。资源不存在返回 404。重复数据返回 409。服务器内部错误返回 500,只返回通用提示,详细错误记到日志。所有错误响应统一用
{ success: false, error: { code, message } }格式。"
"刚才添加成功了,但列表里没显示"
小明的朋友说:"我刚添加了一部电影,接口返回成功了,但回到列表页刷新了好几次都看不到。过了一会儿又出现了。"
这不是 bug,是缓存在作怪。
Next.js 有多层缓存机制,具体行为取决于你的数据获取方式。如果列表页是 Server Component 直接查数据库,Next.js 可能会缓存整个页面的渲染结果。如果列表页通过 fetch 调用 API,fetch 本身也有缓存。不管哪种情况,症状都一样:写入了新数据,但读取时拿到的还是旧的。
这个问题不是 AI 写错了代码,而是框架的缓存机制在起作用。解决方式是在修改数据后主动告诉 Next.js "这部分数据变了,请刷新":
- 在 POST/PATCH/DELETE 操作成功后,调用
revalidatePath('/movies')或revalidateTag('movies')通知 Next.js 清除对应的缓存 - 下次有人访问这个页面时,Next.js 会重新查询数据库,拿到最新数据
跟 AI 说:
"添加/修改/删除电影后,用 revalidatePath 或 revalidateTag 清除相关页面的缓存,确保用户能看到最新数据。"
缓存是好东西,但要主动管理
缓存能大幅提升性能——同一个列表页被 100 个用户访问,只需要查一次数据库,后面 99 次直接返回缓存。但如果不主动清除,用户就会看到过期数据。原则是:读可以缓存,写之后必须清缓存。加载了 next-best-practices Skill 的 Claude Code 在生成写操作代码时会自动加上 revalidate 调用,但知道这个机制能帮你在出问题时快速定位原因。
添加电影成功了,但用户等了两秒
缓存问题解决后,小明又加了一个功能:用户添加电影后,给所有关注了这个标签的人发一条通知。
功能上线后,朋友反馈:"添加电影变慢了,点完'提交'要等两秒才看到'添加成功'。以前是秒回的。"
小明检查了一下,发现问题出在通知逻辑上。添加电影的接口现在做了三件事:
- 把电影数据写入数据库(几十毫秒)
- 查询所有关注了相关标签的用户(几百毫秒)
- 给每个用户发通知(一两秒)
这三步是串行执行的——第 1 步做完才做第 2 步,第 2 步做完才做第 3 步,全部做完才返回"添加成功"。用户要等所有通知都发完才能看到结果。
但用户关心的只是"电影有没有添加成功"。通知发没发、发给了谁,用户根本不需要等。
这就是阻塞 vs 非阻塞的区别。数据库写入是核心操作,必须等它完成才能告诉用户"成功了"。但发通知是附带操作,完全可以在返回响应之后再做——用户先看到"添加成功",通知在后台慢慢发。
Next.js 提供了一个叫 after() 的功能,专门干这件事:把不需要阻塞响应的操作(发通知、记日志、更新统计)推迟到响应发送之后执行。
跟 AI 说:
"添加电影的接口,数据库写入完成后立即返回成功响应。发通知、更新统计这些操作用 Next.js 的 after() 放到响应之后执行,不要阻塞用户。"
判断标准:这个操作的结果需要告诉用户吗?
如果需要(比如"添加成功"或"标题不能为空"),就必须在响应之前完成。如果不需要(比如发通知、记日志、刷新缓存),就可以放到响应之后。加载了 next-best-practices Skill 的 Claude Code 会自动识别哪些操作适合用 after(),但你在审查 AI 方案时如果发现"接口变慢了",可以想想是不是有操作不该阻塞响应。
某天 API 突然全部 500
小明的应用跑了一个月都没问题。某天晚上,他在朋友群里分享了链接,一下子来了几十个人同时访问。然后所有接口都开始返回 500 错误。
他把报错信息丢给 AI,AI 说是"数据库连接池耗尽"——too many connections。
连接池是什么?
这个概念在第六章 6.4 节已经详细讲过。简单回顾一下:
每次 API 要查数据库,都需要先建立一个"连接"。建连接有成本——要经过 TCP 握手、身份认证、分配内存,通常需要几十毫秒。如果每个请求都新建连接、用完就丢,几十个并发请求就能把数据库打满。
连接池预先建好一批连接放在"池子"里,请求来了借一个用,用完还回去。就像共享单车——不需要每个人都买一辆,路边放一批,需要的人骑走,到了还回来。
小明的问题是:他用的免费数据库套餐,最大连接数只有 20 个。几十个人同时访问,每人触发几个请求,连接数瞬间打满。后面的请求全部排队等待,等太久就超时报 500。
怎么排查和解决
第一步:确认是不是连接池的问题。 看报错信息里有没有 too many connections、connection pool exhausted、timeout waiting for connection 这类关键词。
第二步:检查连接池配置。 免费套餐的数据库通常限制 20-50 个连接。你的应用实际需要多少?一般来说,连接池大小设为 CPU 核心数 × 2 + 1 就够了——对于大多数云服务器,5-10 个连接就能处理几百个并发请求(因为每个请求占用连接的时间很短)。
第三步:检查有没有连接泄漏。 如果代码里有"借了连接但没还"的情况(比如查询报错后没有释放连接),连接池会慢慢被耗光。这就像有人骑了共享单车但不还,车越来越少。
跟 AI 说:
"我的 API 在并发访问时报 'too many connections' 错误。帮我检查:1)数据库连接池配置是否合理?2)有没有连接泄漏(查询报错后连接没释放)?3)是否使用了连接池地址而不是直连地址?"
加载了 Skills 的 Claude Code 会自动处理
如果你加载了 supabase-postgres-best-practices Skill,Claude Code 在生成数据库相关代码时会自动配置合理的连接池参数、添加超时设置、确保连接正确释放。但了解这个概念仍然有用——出问题时你知道该往哪个方向排查。
排错的 Prompt 模板
上线后遇到问题,最高效的排错方式是把完整的上下文丢给 AI。模糊的描述只会得到模糊的回答。
接口报错时:
"我的
POST /api/movies接口报了这个错:[粘贴完整报错信息,包括堆栈跟踪]。请求的 body 是 [粘贴请求内容]。帮我定位问题并修复。"
数据异常时:
"数据库 movies 表里出现了标题为空的记录(ID: 42, 43, 47)。正常情况下标题不应该为空。帮我排查是哪个接口没做校验导致的,然后:1)给接口加上 Zod 校验;2)清理这三条脏数据。"
性能问题时:
"我的电影列表接口在数据量到 5000 条后变得很慢,响应时间从 50ms 涨到了 3 秒。帮我分析原因——是缺索引、N+1 查询、还是其他问题?用 EXPLAIN ANALYZE 检查一下。"
间歇性问题时:
"我的 API 大部分时候正常,但每天晚上 8-10 点会偶尔返回 500 错误。报错信息是 [粘贴]。这个时间段是用户访问高峰。帮我排查是不是连接池不够用。"
关键是给具体数据。不要说"接口很慢",要说"响应时间从 50ms 涨到了 3 秒"。不要说"有时候报错",要说"每天晚上 8-10 点报错,报错信息是 xxx"。AI 拿到具体数据才能给出针对性的方案,否则只能给你一堆"可能是这个、可能是那个"的猜测。
本节核心要点
- 后端校验是底线:前端校验拦不住绕过前端的请求,后端必须再校验一次
- 幂等性防重复:数据库唯一约束是最简单的方案,幂等键是最通用的方案
- 状态码区分错误类型:400 系列是调用者的问题,500 系列是服务器的问题
- 缓存要主动管理:写操作之后必须清缓存,否则用户看到过期数据
- 非核心操作别阻塞响应:发通知、记日志这些事放到响应之后做
- 连接池防崩溃:几十个并发就能打满免费套餐的连接数,确保使用连接池
下一步
接口能用、能扛了。但随着功能越加越多,你会发现接口本身也需要"管理"。去 让接口更好用 看看怎么把接口当产品来打磨。
