你的 Next.js 应用在每次页面加载时执行相同的数据库查询 5 次
Source: Dev.to

Source: …
问题描述
打开你的 Next.js 应用。导航到任意包含多个组件的页面。现在统计一下下面这条 SQL 语句
SELECT * FROM users WHERE id = ?执行了多少次。
你可能不知道。几乎没有人会去数。因为每个请求都会返回 200,每个组件都渲染正常,应用“工作”了。
但在这页正常渲染的背后,同样的查询可能已经执行了 5 次:
- 导航栏一次。
- 侧边栏一次。
- 主内容一次。
- 设置面板一次。
- 再一次,因为 React 严格模式让你的 effect 执行了两遍。
这就是 5 次相同的往返 到数据库,查询的数据在最近的 200 ms 内根本没有变化。
这并非假设
下面是一个典型的 Next.js API 路由:
// app/api/user/route.ts
export async function GET() {
const user = await prisma.user.findUnique({
where: { id: session.userId }
});
return NextResponse.json(user);
}简洁、清晰、易于代码审查。
现在这个端点在多个地方被调用:
| 组件 | 为什么会请求 /api/user |
|---|---|
| Layout 导航栏 | 显示用户的名字 |
| 仪表盘页面 | 展示用户统计信息 |
| 设置页面 | 预填表单 |
| 个人资料侧边栏 | 显示头像 |
| 通知铃铛 | 检查偏好设置 |
每个组件各自调用 fetch('/api/user')。每一次调用都会命中 API 路由,进而执行 prisma.user.findUnique()。每个查询都会去 PostgreSQL,返回相同的用户记录。
五个组件 → 五次请求 → 五次查询 → 同一条用户记录。
为什么会发生这种情况
这并不是糟糕的代码;而是组件化架构的自然结果。
- 在 React/Next.js 中,组件是自包含的。
fetch自己获取数据。- 组件独立检查权限。
这是一种 良好 的软件设计,但它意味着每个需要用户数据的组件都会自行发起请求。重复的请求往往不被注意,因为:
- DevTools 只显示一个平面列表。 你会看到 5 次对
/api/user的请求,混杂在其他 20 条请求中。必须手动去发现它们是相同的。 - 每个请求都成功。 200 OK,数据正确,速度足够快。没有错误,也没有警告。
- 不同的开发者编写了不同的组件。 开发者 A 构建了导航栏,开发者 B 构建了侧边栏。彼此都不知道对方也在请求
/api/user。 - 代码审查没有捕捉到。 每个组件的代码在单独审视时都是正确的。重复只在所有组件同页面渲染时的运行时出现。
真实成本
“那又怎样?查询很快。”
让我们算一算:
| 指标 | 值 |
|---|---|
| 每页加载的重复查询 | 5 |
| 每次查询耗时(热数据库) | 30 ms |
| 每日活跃用户 | 1,000 |
| 每次会话的页面加载次数 | 10 |
每日不必要的查询:50,000
每页浪费的延迟:150 ms
如果你的数据库负载较高,那些“快速”的 30 ms 查询可能会变成 200 ms,导致应用在没有明显原因的情况下显得迟缓。
每一次重复查询还意味着一次额外的 HTTP 请求、一次额外的 API 路由调用、一次额外的连接池连接以及额外的内存分配——所有这些都是为你已经拥有的数据而产生的。
React 严格模式让情况更糟
React 严格模式(在 Next.js 中默认启用)在开发环境下会让每个 useEffect 运行两次。效果中的每个 fetch() 都会被触发两次。
你的 5 次重复请求会变成 10 次。你的 5 次数据库查询会变成 10 次。
令人困惑的地方在于:你会看到对 /api/user 的 10 次请求,进而觉得 “我的应用坏了”。 但其中有 5 次是严格模式的“幽灵”,在生产环境中根本不会发生,另外 5 次才是真正的重复请求。你在 DevTools 中无法分辨哪一个是哪一个。
常见建议方案
| 方案 | 优点 | 缺点 |
|---|---|---|
| 使用 React Query / SWR | 在客户端去重。表现良好。 | 需要重构每个组件以使用相同的查询键。对 Server Components 没有帮助。 |
| 将获取提升到父组件 | 通过 props 向下传递数据。 | 打破组件封装。 |
| 使用共享布局加载器 | 在 layout.tsx 中获取数据,通过 Context 共享。 | 这是个好模式,但需要提前进行架构规划。 |
| 添加缓存(Redis、内存等) | 减少数据库负载。 | 带来缓存失效问题;需要自行决定 TTL。 |
所有方案都有效,但它们要求你已经知道哪些查询是重复的——这才是难点。看不见的问题是无法解决的。
看到问题
在 API 级别的可观测性至关重要。既不是应用层日志(太冗长),也不是基础设施监控(层次太高)。你需要将 API 视为一个相互关联的系统——哪些端点被一起调用,哪些查询被共享,以及它们如何与用户实际的操作关联。
Brakit 是一个开源开发工具,正是为此而生。只需在你的 Next.js 应用中添加一行代码,它就会捕获每个 HTTP 请求、数据库查询和外部 fetch——并按用户操作进行分组。
与在 DevTools 中看到的 10 条请求的平铺列表不同,你将看到所有内容以嵌套、计时和关联的方式呈现:
React/Next.js 中的重复查询

重复项会自动标记。跨多个端点运行的 SELECT users 被高亮显示。React Strict Mode 的重复项会单独标记 — “React Strict Mode duplicate — does not happen in production” — 这样你就能知道哪些需要修复,哪些可以忽略。
如何处理
一旦看到重复的请求,解决办法就很直接:
1. 在请求层面去重
使用 React Query 或 SWR,让多个组件共享同一个请求:
// hooks/useUser.ts
export function useUser() {
return useQuery({
queryKey: ['user'],
queryFn: () => fetch('/api/user').then(r => r.json()),
staleTime: 30_000,
});
}每个调用 useUser() 的组件现在都会共享同一个请求和缓存。
2. 在查询层面去重
将共享的查询抽取到数据访问层:
// lib/data/user.ts
import { cache } from 'react';
export const getUser = cache(async (userId: string) => {
return prisma.user.findUnique({ where: { id: userId } });
});React 的 cache() 会在单次服务器渲染中去重。若需要跨请求缓存,可使用 unstable_cache 或专门的缓存层。
3. 将共享数据移动到布局
一次获取后通过 context 共享:
// app/layout.tsx
export default async function Layout({ children }) {
const user = await getUser(session.userId);
return (
<UserContext.Provider value={user}>
{children}
</UserContext.Provider>
);
}更广泛的模式
重复查询是基于组件的架构中最常见的后端性能问题。它们在代码审查中不可见,在单元测试中无法检测,只有在真实组件在真实页面上组合时才会在运行时显现。
解决办法并不复杂——难点在于看到问题本身。
如果你想了解你的 Next.js 应用在每次页面加载背后到底做了什么——哪些查询被执行,哪些被重复,以及是哪 个组件导致的——请尝试 Brakit。只需一条命令,零配置,你的重复查询将一目了然。
- 开源,在本地运行,生产环境不做任何操作。
- 你的数据永不离开你的机器。

