⚠️ Alpha内测版本警告:此为早期内部构建版本,尚不完整且可能存在错误,欢迎大家提Issue反馈问题或建议
Skip to content

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 长什么样?展开看看,不看也没关系
typescript
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 这个方法——它不会在校验失败时抛异常,而是返回一个包含 successerror 的对象,让你自己决定怎么处理。这比 try-catch 更可控。

校验应该严格到什么程度?

小明问老师傅:"是不是每个字段都要加一堆校验规则?"

老师傅说:"看场景。核心原则是——不信任任何外部输入,但也不要过度校验。"

必须校验原因
必填字段不能为空空数据进数据库会导致各种下游问题
字符串长度限制防止有人传一个 10MB 的字符串把内存撑爆
数字范围限制评分不能是 -1 或 999,年份不能是 3000
枚举值校验状态只能是 "draft"/"published"/"archived",不能是任意字符串
外键存在性directorId 指向的导演必须真实存在(数据库外键约束也会拦,但提前校验能给更友好的错误信息)
不需要过度校验原因
内部服务之间的调用你自己的前端调你自己的后端,数据格式是你控制的,基本校验就够
已经有数据库约束的字段如果数据库已经有 UNIQUE 约束,代码里不需要再查一遍"有没有重复"

跟 AI 说:

"给所有 POST 和 PATCH 接口加上 Zod 参数校验。必填字段不能为空,字符串限制合理长度,数字限制合理范围。校验失败返回 400 和具体的错误信息,格式用 { success: false, error: { message: '...' } }。"


点击状态码查看说明。记住规律:2xx 成功、4xx 你的锅、5xx 服务器的锅。

2xx 成功
200
OK
请求成功,返回数据
201
Created
资源创建成功
204
No Content
成功但无返回体(如删除)
4xx 客户端错误
400
Bad Request
请求格式有误
401
Unauthorized
未登录 / Token 过期
403
Forbidden
已登录但没权限
404
Not Found
资源不存在
409
Conflict
数据冲突(如重复创建)
422
Unprocessable
格式对但内容不合法
5xx 服务端错误
500
Internal Server Error
服务器内部出错
502
Bad Gateway
上游服务无响应
503
Service Unavailable
服务暂时不可用

同一部电影被添加了 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 参数错误:

json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "标题不能为空",
    "details": [
      { "field": "title", "message": "标题不能为空" },
      { "field": "year", "message": "年份必须是 1888-2030 之间的整数" }
    ]
  }
}

404 资源不存在:

json
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "找不到 ID 为 999 的电影"
  }
}

500 服务器错误:

json
{
  "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. 查询所有关注了相关标签的用户(几百毫秒)
  3. 给每个用户发通知(一两秒)

这三步是串行执行的——第 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 connectionsconnection pool exhaustedtimeout 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 系列是服务器的问题
  • 缓存要主动管理:写操作之后必须清缓存,否则用户看到过期数据
  • 非核心操作别阻塞响应:发通知、记日志这些事放到响应之后做
  • 连接池防崩溃:几十个并发就能打满免费套餐的连接数,确保使用连接池

下一步

接口能用、能扛了。但随着功能越加越多,你会发现接口本身也需要"管理"。去 让接口更好用 看看怎么把接口当产品来打磨。