全栈开发者都该知道的 7 个隐性串行陷阱。先给你一个分析模型,再用它逐个拆解,每个陷阱都附原理、实验,以及怎么发现、怎么改。
你可能遇到过这种情况:本地开发一切顺滑,上线后一个人用也没问题,可只要两三个人同时用,所有人都开始变慢。页面转圈,接口超时,流式输出一卡一卡的。
这时候最常听到的一句话是:"并发扛不住了,上个消息队列吧。"
我最近排查一个 Next.js + FastAPI 的项目,遇到的正是这个场景。查到最后,问题和消息队列一点关系都没有。真正的原因是:代码里写的是并发,运行时却在某个地方排起了队。
这类问题很难发现。它不报错,单看每一行代码也都挑不出毛病。所以这篇文章不打算直接罗列问题,而是先给你一个模型,让你知道该往哪里看;再沿着一个请求经过的路径,从后端到前端,把 7 个陷阱逐个放进这个模型里拆开。最后,我们回到开头那个问题:到底要不要上消息队列。
先建立一个模型:用户的等待时间从哪里来
用户点下一个按钮,到看到结果,中间等待的时间可以拆成两部分:
用户等待时间 = 关键路径上每一段的耗时 + 每个"单通道"上的排队时间
关键路径是指从点击到结果出现,必须一段接一段完成的那条链:下载 JS、发请求、服务端渲染、查数据库、返回、渲染到屏幕上。任何一段变长,用户都要多等。
单通道是指系统里一次只能处理一件事的地方:一个线程、一把锁、一条队列,甚至一个 await。多个任务经过单通道时只能排队。如果排在最前面的那个任务很慢,后面所有任务都要跟着等,这种现象叫队头阻塞(head-of-line blocking)。这个词原本来自网络协议,但它在应用代码里同样随处可见。
有了这个模型,减少等待的办法就只有三种:
- 把慢任务移出单通道,让它不再挡住别人。这是根治。
- 把单通道扩成多通道,比如多开几个进程。这是扩容,能缓解,但单通道本身还在。
- 缩短关键路径上的某一段,比如让要下载的 JS 更小。
下面这 7 个陷阱,前 5 个都是"排队":本该并行的事被某个单通道排成了一列,要用第一种办法解决。后 2 个是"负重":它们不制造排队,但把关键路径上的某一段拉长了,要用第三种办法解决。
| 陷阱 | 你写的代码 | 实际发生的事 | 类型 |
|---|---|---|---|
| ① 异步函数里的同步调用 | async def 里调用同步的数据库查询 | 一个请求卡住所有用户 | 排队 |
| ② 请求路径上的建连 | 每个请求现场新建数据库连接 | 别人建连时,你也在等 | 排队 |
| ③ Suspense 边界外的 await | 在 layout 里 await 数据 | 整页等一个请求 | 排队 |
| ④ 用 Server Action 读数据 | 'use server' 的读取函数 | 读请求排队,跳转后还会整页刷新 | 排队 |
| ⑤ 用错地方的"优化" | 服务端取完客户端再取;首屏也防抖 | 关键路径上多出好几段等待 | 排队 |
| ⑥ 一个包装下所有依赖 | 自定义分包;静态导入重型组件 | 404 页面也要下载 1 MB | 负重 |
| ⑦ 订阅整个 store | 不带 selector 调用 store hook | 任何变化都触发重新渲染 | 负重 |
它们分别藏在请求经过的不同层里:
怎么读这张图:四列是一个请求依次经过的四层,蓝色箭头是请求和响应的方向。红色卡片是排队类陷阱,橙色卡片是负重类陷阱。值得注意的是 ④:Server Action 虽然是 Next.js 的功能,但它排队的那条队列在浏览器里。
正文按 ① 到 ⑦ 的顺序,从后端往前端讲。这样安排是因为后端的排队会被前端放大:你会在陷阱 ③ 里看到,前端写好的并行请求,正是被陷阱 ① 还原成了串行。
第一部分:后端
陷阱 ①:异步函数里的同步调用
一句话总结:在 async def 里调用同步函数,会让所有用户的所有请求排成一队。
你可能写过这样的代码
@app.get("/orders")
async def list_orders(user_id: str):
return db.query("SELECT * FROM orders WHERE user_id = %s", user_id) # 同步的数据库驱动
看起来没毛病:接口是异步的,数据库查询也能正常返回。本地自己点一点,速度也很快。
为什么会排队
Python 的异步框架 asyncio 只用一个线程处理所有请求,这个线程叫事件循环。它之所以能"同时"服务很多人,靠的是每个请求在等待网络或数据库时,在 await 的地方主动把线程让出来。你可以把它想成一个服务员同时照看很多桌客人:这桌在等菜,他就先去照顾别的桌。
问题出在同步调用上。上面那个 db.query 是一个普通函数,它不会让出线程。在它返回之前,这唯一的线程就被占住了,就像服务员站在一桌旁边干等厨房出菜,其他所有桌都没人管。
FastAPI 对此有一条很明确的规则。接口写成普通的 def,FastAPI 会把它放进线程池执行,线程池里有很多线程,一个被阻塞不影响别人。接口写成 async def,FastAPI 就认定你保证了里面没有阻塞调用,直接在事件循环上执行它。
所以,async def 不是一个"让代码变快"的开关,而是你对框架的一份承诺:这个函数里没有阻塞调用。一旦违背承诺,它比老老实实写 def 还糟。这个道理不只适用于 Python:Node.js 也是单线程事件循环,在请求处理函数里调用 fs.readFileSync、crypto.pbkdf2Sync 这类同步 API,后果完全一样。
对照模型:单通道是事件循环这唯一的线程;堵在队头的是同步数据库调用;办法是第一种,把它移出单通道。
实验:差距有多大
我写了一个最小的 FastAPI 应用,用 time.sleep(0.3) 模拟一次 300 ms 的同步查询,分别用三种写法实现同一个接口:
@app.get("/async-blocking")
async def async_blocking():
time.sleep(0.3) # async def 里调用同步函数
return {"ok": True}
@app.get("/sync-def")
def sync_def():
time.sleep(0.3) # 普通 def,FastAPI 放进线程池
return {"ok": True}
@app.get("/async-to-thread")
async def async_to_thread():
await asyncio.to_thread(time.sleep, 0.3) # 显式把同步调用丢进线程
return {"ok": True}
用单个 uvicorn 工作进程启动,同时发出 10 个请求,50 ms 后再发一个什么都不做的健康检查 /health。三轮结果高度一致:
| 写法 | 10 个请求全部完成 | 健康检查等了多久 |
|---|---|---|
async def 里调用同步函数 | 3.12–3.13 s | 3.05–3.07 s |
普通 def | 0.35 s | 0.003–0.021 s |
async def 里用 asyncio.to_thread | 0.35–0.36 s | 0.003–0.005 s |
怎么读这张图:上下两部分的横轴相同,每一行是一个请求从发出到返回的时间。上半部分里,浅红色是排队,深红色才是真正执行。深红色的执行段一个接一个向右排成阶梯,说明 10 个请求是被轮流执行的。最下面那行 /health 几乎全是浅色,它自己什么都不做,却等了 3047 ms。下半部分里,所有执行段在同一时刻并排完成。
3.12 秒几乎正好等于 10 × 0.3 秒,这就是单通道的特征:总耗时约等于单个耗时乘以请求数。
怎么发现自己中招了
最好认的信号就是上面那张图的形状:延迟随并发人数线性增长,而且连毫不相干的接口(比如健康检查)也一起变慢。
还有两个工具可以直接定位。设置环境变量 PYTHONASYNCIODEBUG=1 打开 asyncio 调试模式,任何占用事件循环超过 0.1 秒的回调都会被记录到日志里。线上可以用 py-spy dump 抓一次所有线程的调用栈,如果事件循环线程停在数据库驱动的网络读写上,就是它了。
正确写法
# 方法一:没有 await 的接口,干脆写成 def,交给线程池
@app.get("/orders")
def list_orders(user_id: str):
return db.query("SELECT * FROM orders WHERE user_id = %s", user_id)
# 方法二:必须是 async 的地方(比如流式输出),把同步调用丢进线程
@app.get("/orders")
async def list_orders(user_id: str):
return await asyncio.to_thread(db.query, "SELECT * FROM orders WHERE user_id = %s", user_id)
如果你用的数据库有成熟的异步驱动,也可以直接换成异步驱动,比如 PostgreSQL 的 asyncpg。
记住:
async def是一份承诺,承诺里面没有任何阻塞调用。做不到,就写def。
陷阱 ②:请求路径上的建连
一句话总结:在处理请求时现场建立连接,等于把登录耗时转嫁给所有在线用户。
你可能写过这样的代码
@app.post("/chat")
async def chat(req: ChatRequest):
conns = [connect_as_user(req.token) for _ in range(3)] # 每个请求现场建 3 个连接
...
为什么会排队
建立一个数据库连接,要经过网络握手、身份认证、会话初始化,是一次实实在在的 I/O,通常比一次普通查询还慢。如果它发生在事件循环上,就是陷阱 ①;如果一个请求还要一个接一个地建好几个,堵在队头的任务就变成了好几倍长。
对照模型:单通道还是事件循环;堵在队头的是几次串行的登录;办法有两种,先用第一种把建连移出事件循环,再用第三种通过复用连接,让"登录"这一段从关键路径上消失。
实验:别人发一条消息,你的回复停一秒
我在实验应用里模拟了一个聊天场景。用户 A 正在接收 AI 的流式回复,服务端每 50 ms 推送一个字。0.5 秒时,用户 B 发了一条消息,服务端在事件循环上连续做 3 次 300 ms 的同步操作,模拟串行建立 3 个连接。
怎么读这张图:每根竖线代表用户 A 收到的一个字,横坐标是收到的时刻,高度是它和上一个字之间隔了多久。正常情况下竖线都在 50–70 ms 附近。浅红色阴影是用户 B 在建连的时间段,这段时间里没有任何红色竖线,直到 1.45 秒出现一根 936 ms 的高线,那是被憋了将近一秒才送到的字。绿色那组是把建连改成 asyncio.to_thread 后的结果,同一时间段完全不受影响。
三轮实验里,最长停顿在 929–968 ms 之间;改用 to_thread 后,最长间隔只有 64–77 ms。300 ms 是我设定的参数,真实的建连耗时取决于你的数据库和网络,但规律不会变:你感受到的卡顿,约等于别人的阻塞时长乘以同时活跃的人数。
正确写法
能复用的连接,用连接池复用。必须按用户建立的连接,可以按用户缓存一段时间,而不是每个请求都重建。实在要在请求里建连,至少用到时再建,并且放进线程:
conn = await asyncio.to_thread(connect_as_user, req.token)
这里有一个容易踩的坑。很多项目会用 ContextVar(Python 里一种按请求隔离的"全局变量")把连接共享给同一个请求里的其他代码。而 asyncio.to_thread 会在一份拷贝的上下文里执行函数,你在线程里设置的 ContextVar,调用方是看不到的:
# 错误:在线程里设置 ContextVar,回到事件循环后看不到
await asyncio.to_thread(lambda: current_conn.set(connect_as_user(token)))
# 正确:只把建连放进线程,在事件循环里设置 ContextVar
conn = await asyncio.to_thread(connect_as_user, token)
current_conn.set(conn)
记住:连接是昂贵的 I/O。别在请求路径上同步地建它,更别一次建好几个。
第二部分:Next.js 的服务端与客户端路由
陷阱 ③:Suspense 边界外的 await
一句话总结:流式渲染能不能生效,取决于 await 写在 Suspense 边界的里面还是外面。
你可能写过这样的代码
// app/(dashboard)/layout.tsx
export default async function Layout({ children }) {
const { user, sessions } = await getUserAndSessions();
return (
<Shell>
<Sidebar user={user} sessions={sessions} />
{children}
</Shell>
);
}
旁边还放了一个 loading.tsx,心想"数据没到的时候会显示 loading,没问题"。
为什么会排队
Next.js App Router 支持流式渲染:服务端可以先把页面的框架发给浏览器,没准备好的部分先显示占位内容,等数据到了再补上。决定"哪部分先发、哪部分等"的,是 React 的 Suspense 边界。loading.tsx 就是 Next.js 自动帮你加的一个 Suspense 边界。
关键在于这个边界的位置:loading.tsx 包住的是同一层的 page 和更深的内容,包不住同一层 layout 自己的 await。上面那段代码里,await 写在 layout 里,它旁边的 loading.tsx 根本保护不了它。
怎么读这张图:左右两边是同一个页面的两种写法,虚线框是 Suspense 边界。左边红框是 await 所在的位置,它在内层边界 B 的外面,所以 B 起不了作用,侧边栏、标题栏、主内容区全都要等它,用户只能看到外层边界 A 的 loading。右边的 layout 不再等待,只有侧边栏的数据被包在绿色虚线的 Suspense 里,标题栏和主内容区立刻就能渲染。
对照模型:单通道是 layout 这一层的渲染,整页只能从这一个出口出去;堵在队头的是那个 await 的数据请求;办法是第一种,把等待移进一个更小的 Suspense 边界里。
怎么发现自己中招了
最直接的办法是用 curl -N(-N 表示关闭 curl 自己的输出缓冲)请求页面,看 HTML 是一次性全部返回,还是先返回一部分、过一会儿再补上。如果是一次性返回,流式渲染就没有生效。
这时还要检查一个容易被忽略的环节:反向代理。nginx 默认会缓冲上游的响应,攒够了再发给浏览器,这会把流式渲染变成一次性返回。Next.js 的自托管文档专门提醒过这一点。如果你的应用前面有 nginx,需要关闭 proxy_buffering,或者在响应里加上 X-Accel-Buffering: no。
正确写法
layout 只负责外壳,不等数据。需要数据的部分抽成一个独立的异步组件,自己包一层 Suspense:
// app/(dashboard)/layout.tsx
export default function Layout({ children }) {
return (
<Shell>
<Suspense fallback={<SidebarSkeleton />}>
<SidebarWithData /> {/* 在这个组件里 await getUserAndSessions() */}
</Suspense>
{children}
</Shell>
);
}
loading 的内容也值得花心思。全屏 logo 只能告诉用户"在加载",和真实页面结构一致的骨架屏则能让用户立刻知道页面长什么样、能做什么。
记住:在 Suspense 边界外
await,等于告诉框架"整页都等我"。
陷阱 ④:用 Server Action 读数据
一句话总结:Server Action 在浏览器端是排队执行的,而且和页面跳转共用同一条队列。
你可能写过这样的代码
// app/actions.ts
'use server';
export async function getProducts() {
return db.product.findMany();
}
// 某个客户端组件
useEffect(() => {
getProducts().then(setProducts);
}, []);
Server Action 写起来实在太方便了:在文件顶部写上 'use server',客户端就能像调用本地函数一样调用服务端代码,不用写 API 路由,不用管请求格式。于是很多人把它当成了万能的数据接口。
为什么会排队
Next.js 文档对 Server Action 的定位是:它是为服务端的写操作设计的,比如提交表单、删除数据。浏览器端会一个一个地发送并等待它们。如果需要并行读取数据,文档建议在服务端组件里读,或者用 Route Handler。
只看文档还不够。我翻了 Next.js 15.5.18 的源码,发现还有一个更隐蔽的问题。浏览器端调用 Server Action 时,最终会把它放进 App Router 内部的一条队列:
// next@15.5.18 dist/esm/client/app-call-server.js
dispatchAppRouterAction({ type: ACTION_SERVER_ACTION, actionId, actionArgs, resolve, reject });
这条队列里不只有 Server Action,页面跳转和刷新也在里面。当用户点击链接跳转页面,而队列里还有一个 Server Action 没完成时,源码是这样处理的:
// next@15.5.18 dist/esm/client/components/app-router-instance.js(节选)
} else if (payload.type === ACTION_NAVIGATE || payload.type === ACTION_RESTORE) {
actionQueue.pending.discarded = true; // 把正在执行的 action 标记为丢弃
if (actionQueue.pending.payload.type === ACTION_SERVER_ACTION) {
actionQueue.needsRefresh = true; // 如果它是 Server Action,记下"之后要刷新"
}
runAction({ actionQueue, action: newAction, setState }); // 页面跳转优先执行
}
等队列清空,needsRefresh 会触发一次刷新,而刷新的实现是:
// next@15.5.18 .../router-reducer/reducers/refresh-reducer.js(节选)
// Fetch data from the root of the tree.
flightRouterState: [currentTree[0], currentTree[1], currentTree[2], 'refetch'],
// ...
mutable.prefetchCache = new Map();
也就是说:从最外层开始,把整个页面的服务端部分重新渲染一遍,并清空所有预取好的页面。
怎么读这张图:从左往右是时间顺序,三行分别是浏览器里的 action 队列、服务端渲染和预取缓存。红色虚线是用户点击链接的时刻:左边正在执行的 Server Action 被打上叉(标记为丢弃),页面跳转插队先执行。跳转结束、队列清空后,自动出现一个 REFRESH,向下引发两件事:所有 layout 重新渲染,预取好的页面全部作废。用户只点了一次,服务端却渲染了两次。
对照模型:单通道是 App Router 在浏览器里的 action 队列;堵在队头的是一个慢的 Server Action;办法是第一种,读请求改走普通 fetch,根本不进这条队列。
所以,用 Server Action 读数据有三重代价:所有读请求排队,一个慢请求挡住后面所有请求;用户在读请求返回前跳转,跳转后会多一次整页刷新,layout 里的数据请求再跑一遍;每次调用都是 POST,没法利用浏览器缓存。
为什么大家还这么写
除了方便,还有一个常见的理由:读数据需要带上用户的身份信息(cookie 或者网关注入的请求头),用 Server Action 在服务端中转,可以确保这些信息一定被转发给后端。
这个理由值得先验证一下。在那个项目里,部署平台的入口网关会给每一个进入服务的请求都注入身份请求头,浏览器直接请求后端也带得上。项目里就有组件直接 fetch('/api/...'),工作完全正常。所以那层中转其实不是必需的。你的项目情况可能不同,但值得确认,而不是默认需要。
正确写法
首屏需要的数据,在服务端组件里直接读取,配合陷阱 ③ 的 Suspense 写法。客户端后续需要的数据,用普通的 fetch 加一个负责缓存和去重的库,比如 SWR:
// 首屏:服务端组件里直接读
export default async function ProductsPage() {
const products = await db.product.findMany();
return <ProductList initialProducts={products} />;
}
// 客户端后续读取:普通请求,不进路由队列
const { data } = useSWR('/api/products', fetcher);
Server Action 只留给真正的写操作:提交、删除、更新。
记住:Server Action 是给写操作用的。拿它做读取,所有读请求都会排队,还可能被页面跳转打断,换来一次整页刷新。
第三部分:浏览器
陷阱 ⑤:用错地方的"优化"
一句话总结:每一个优化手段都有它的前提。前提不成立时,它只会在关键路径上多加一段等待。
先补一个背景:浏览器里的请求,最早也要等 JS 下载完、页面完成"水合"(React 把事件绑定到服务端发来的 HTML 上,让页面可以交互)、组件里的 useEffect 执行之后才能发出。所以客户端请求链的起点本身就很晚,每多一环,用户就多等一段。
下面四个小陷阱,写的人都是出于好意,单独看都"有道理",可它们一起出现在了同一个首页上:
怎么读这张图:最上面一行是页面加载的四个阶段,只有到了"effect 执行"才开始发请求。之后分出三路,编号对应下面的 5.1 到 5.4。红色这一路是关键路径:推荐问题依赖那份被请求了两次的数据,拿到之后还要等 2 秒防抖,最后才轮到最慢的 LLM 接口。用户最终等多久,取决于这一路。
对照模型:这里的单通道是时间上的串行依赖,下一棒必须等上一棒跑完;堵在队头的是每一棒本身。办法是第一种的变体:去掉不必要的棒(去重、去防抖、去掉每次都做的同步),或者把起点提前到服务端。
5.1 服务端刚取完,客户端又取一遍
function SessionList({ initialSessions }) {
const [sessions, setSessions] = useState(initialSessions);
useEffect(() => {
fetchSessions().then(setSessions); // 挂载后在后台"刷新"一次
}, []);
...
}
这是 stale-while-revalidate 模式:先显示手里的旧数据,后台再拉一次新的。它的前提是手里的数据可能已经过期了,比如来自缓存,或者几分钟前的请求。如果 initialSessions 是服务端在这次请求里刚从数据库查出来的,几百毫秒前的数据谈不上过期,这次"刷新"只是多一次请求。在那个项目里,这次多余的请求还恰好同时踩中了陷阱 ① 和 ④。
5.2 父组件预取,子组件兜底,结果取了两次
父组件为了"提前准备好数据",安排了一次预取,但为了不影响首屏,用 requestIdleCallback 推迟到浏览器空闲时才执行。子组件挂载时急着用数据,发现预取结果还没到,于是自己又请求了一次。两边都没错,但同一份数据被请求了两次。同一份数据,应该只有一个地方负责获取。
5.3 首次加载也防抖
useEffect(() => {
const timer = setTimeout(() => fetchSuggestions(selection), 2000); // 防抖 2 秒
return () => clearTimeout(timer);
}, [selection, catalogVersion]);
防抖(debounce)的作用是:用户连续操作时,等他停下来再发请求。它的前提是有连续的用户输入。首次加载时用户什么都没做,这 2 秒就是白等。更隐蔽的是依赖数组里的 catalogVersion:只要它在 2 秒内变化一次(比如另一份数据刚加载完),计时器就会被清掉重来。
5.4 每次打开页面都做一次全量同步
为了保证数据最新,页面每次加载都调用一次"全量同步"接口,让后端重新扫描所有数据并写回缓存。没有时间间隔限制的话,用户刷新十次页面就同步十次。这类操作应该按时间间隔触发,或者交给用户手动刷新。
记住:stale-while-revalidate 的前提是数据可能过期,防抖的前提是有连续输入。前提不成立,就别用。
陷阱 ⑥:一个包装下所有依赖
一句话总结:首屏 JS 越大,后面所有客户端请求的起点就越晚。
这一项不是排队,而是负重:陷阱 ⑤ 那张图最上面一行的"下载 JS",就是由它决定的。
你可能写过这样的配置和代码
// next.config.ts:想"优化"一下分包
config.optimization.splitChunks = {
...config.optimization.splitChunks,
cacheGroups: {
...config.optimization.splitChunks.cacheGroups,
vendor: { test: /[\/]node_modules[\/]/, name: 'vendors', chunks: 'all', maxSize: 244000 },
},
};
// 聊天组件:直接导入所有可能用到的组件
import { QueryResultsChart } from '@/components/query-results-chart'; // 图表库
import { CodeEditor } from '@/components/code-editor'; // 代码编辑器
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; // 完整版语法高亮
为什么会变重
第一段配置的本意是"拆分过大的第三方包",实际效果却是把所有 node_modules 合并成一个所有页面共享的分组,覆盖了 Next.js 默认的按页面拆分。结果每个页面都要加载全部第三方库。maxSize 只是把这个大包切成了很多小块,总量一点没少。
第二段代码的问题更普遍:只要一个组件被静态导入,它和它依赖的所有库就会进入首屏 JS。图表、编辑器这类组件可能只在用户点开某个功能时才用到,却在首页就被下载了。
对照模型:这是第三种办法要解决的问题,缩短关键路径上"下载 JS"这一段。
实验:一次对照,拆出两个问题
在那个项目里,我执行 next build 后统计了每个页面实际加载的 JS(gzip 压缩后)。然后临时删掉上面那段 splitChunks 配置,重新构建一次做对照:
怎么读这张图:每个页面有两根条,红色是原配置,绿色是删掉自定义分包后。上面红框里的四个页面,红色是绿色的 5 到 10 倍,说明它们的问题出在配置上。下面黄框里的两个页面,红绿两根几乎一样长,说明删掉配置也没用,它们的问题出在代码本身。
如果只看"首页 1.4 MB",很容易把锅全扣给分包配置。对照实验说明,这里其实有两个独立的问题:分包配置拖累的是其他页面,连 404 页面都要下载 1088 KB;首页的重量则主要来自静态导入。我在首页的 JS 里还搜到了 cobol 和 brainfuck 这样的字符串,它们是完整版 Prism 内置的语言名。一个数据问答应用的首页,为 COBOL 和 Brainfuck 的语法高亮付了下载费。
怎么发现自己中招了
next build 的输出里会列出每个页面的 First Load JS,这是第一个该看的数字。想知道具体是哪些库,可以用官方的 @next/bundle-analyzer 生成可视化报告。如果怀疑某段配置有问题,最可靠的办法就是像上面那样删掉它重新构建一次,用对照数据说话。
正确写法
删掉自定义的 splitChunks,Next.js 默认的拆分策略已经足够好。只在特定交互后才用到的重型组件,改成按需加载:
const QueryResultsChart = dynamic(
() => import('@/components/query-results-chart').then((m) => m.QueryResultsChart),
{ loading: () => <ChartSkeleton /> },
);
语法高亮只注册实际用到的语言:
import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql';
SyntaxHighlighter.registerLanguage('sql', sql);
顺带检查一下 <link rel="preload">。那个项目在根 layout 里预加载了亮色、暗色两张原始 PNG logo,合计 673 KB,可用户同一时间只会看到其中一张,而且页面上实际显示的是经过 next/image 处理的另一个地址。预加载了却用不上,就是纯粹的浪费。
记住:先做对照实验,再下结论。"包太大"背后,可能是两个原因叠在一起。
陷阱 ⑦:订阅整个 store
一句话总结:不带 selector 的 store hook,等于订阅了整个应用的所有变化。
你可能写过这样的代码
function Chat() {
const { messages, sendMessage, setActiveSession } = useChatStore();
...
}
为什么会变慢
zustand v5 的 useStore 实现只有几行:
// zustand esm/react.mjs
const identity = (arg) => arg;
function useStore(api, selector = identity) {
const slice = React.useSyncExternalStore(
api.subscribe,
() => selector(api.getState()),
() => selector(api.getInitialState())
);
return slice;
}
不传 selector(用来指定"我只关心哪一部分"的函数)时,默认用 identity,返回整个状态对象。而 zustand 每次 set 都会生成一个新的状态对象,React 发现引用变了,就重新渲染组件。所以,store 里任何一个字段的任何一次变化,都会让这个组件重新渲染,哪怕变化的字段它根本没用到。
在聊天类应用里,这个问题会被放大:AI 流式输出时,每收到一段内容就更新一次 store。如果最顶层的聊天组件订阅了整个 store,每一段内容都会触发整棵组件树重新渲染,包括另一个会话正在输出的时候。React Context 也有同样的问题:Provider 的 value 一变,所有消费它的组件都会重新渲染。
对照模型:同样是第三种办法,缩短关键路径上"渲染"这一段,让每次更新只重新渲染真正受影响的组件。
正确写法
// 只订阅一个字段
const messages = useChatStore((s) => s.messages);
// 需要多个字段时,用 useShallow 做浅比较
import { useShallow } from 'zustand/react/shallow';
const { sendMessage, setActiveSession } = useChatStore(
useShallow((s) => ({ sendMessage: s.sendMessage, setActiveSession: s.setActiveSession })),
);
记住:只订阅你用到的那一小块。
回到开头的问题:要不要上消息队列?
现在可以用模型来回答这个问题了。
把陷阱 ① 到 ⑤ 放在一起看,它们是同一种结构:
怎么读这张图:每一行是前面讲过的一条单通道。红色是堵在队头的慢任务,灰色是被它挡住的其他任务,右侧绿色是对应的出路。注意右侧这五条出路,全都是"把慢任务移出单通道",没有一条是"加消息队列"。
为什么消息队列不在其中?消息队列做的事情,是把一个任务从当前请求里拿出来,交给后台的另一组进程慢慢处理。它适合那些不需要在这次请求里完成的任务,比如生成报表、发送邮件、批量导入。可陷阱 ① 到 ⑤ 里的慢任务,查会话列表、建连接、渲染页面、读数据,都是这次请求必须完成的。你没法把"查会话列表"丢进队列,然后告诉用户"稍后再来看你的会话列表"。而且就算加了消息队列,接收请求的那个 Web 进程里,那个被同步调用占住的事件循环依然存在。
那多开几个 worker 呢?这是模型里的第二种办法,把单通道扩成多通道。它确实能缓解:4 个 worker 就是 4 个事件循环,一个被卡住,另外三个还能干活。但每个 worker 里的单通道和慢任务都还在,用户多起来,问题会重新出现。而且进程之间不共享内存,原本放在内存里的缓存会被拆成好几份,各自重新加载。
所以答案是:先用第一种办法,把慢任务移出单通道;问题解决后如果还需要更高的并发,再用第二种办法扩容。消息队列解决的是另一类问题。
下次遇到"卡",按这个顺序问
这四个问题,其实就是把模型反过来用:先判断是排队还是负重,再找到那条单通道。
一个人用就慢,还是多个人一起用才慢? 多人才慢,说明是排队,而且单通道在共享资源上:事件循环、锁、连接池。看看延迟是不是随人数线性增长,健康检查是不是也变慢了。一个人用就慢,先看负重,也就是关键路径上哪一段太长。
慢在页面打开时,还是慢在操作时? 打开时慢,按关键路径的顺序查三段:首屏 JS 有多大,服务端在 Suspense 边界外等了什么,页面加载后有哪些前后依赖的请求。操作时慢,查浏览器里的请求时间轴、读取是不是走了 Server Action、状态订阅的粒度。
每一个"并行"的写法,背后是什么执行模型? async def 背后是单线程事件循环;Promise.all 背后要看后端能不能真的并发处理;Server Action 背后是路由队列;loading.tsx 背后是 Suspense 边界的位置。把执行模型说清楚,单通道自然就浮出来了。
结论有证据吗? 做对照实验,读框架源码,把没验证过的地方明确标出来。
代码评审时,可以问这 7 个问题
- 这个
async def里,有没有不带await的 I/O 调用? - 这个请求处理过程中,有没有现场建立连接?能不能复用?
- 这个 layout 里有没有
await?它在 Suspense 边界的里面还是外面? - 这个 Server Action 是在写数据,还是在读数据?
- 这份数据是不是在两个地方各取了一次?这个防抖、这次后台刷新,前提成立吗?
- 这个组件是不是只在某个交互后才用到?它被静态导入到首屏了吗?
- 这个 store hook 带 selector 了吗?
写在最后
这 7 个陷阱,没有一个是因为写代码的人不懂技术。恰恰相反,每一处代码背后都有一个合理的意图:写 async def 是想要并发,在 layout 里取数据是想让首屏有内容,用 Server Action 是图方便和安全,后台刷新是想要新鲜数据,防抖是想少发请求,自定义分包是想优化体积。
问题在于,每一个意图都只在某个前提下成立,而写代码的时候,没有人去检查那个前提。
所以,比记住这 7 个陷阱更有用的,是养成一个习惯:每当你写下一段"应该会更快"的代码,问自己一句,它在运行时,会不会在某个地方排队?