一次一次的代价
你需要给十个人打电话,通知同一件事。
一个一个打,每次三分钟,总共三十分钟。或者建个群,一次搞定,三十秒。
大多数人选择一个一个打。不是因为不知道建群更快,而是因为「一个一个」太自然了。
表格里的陷阱
表格有一百行数据。每一行需要显示一个关键词的源文本。
最直接的写法:在每一行的单元格里,调用一次 useKeyword hook,获取那个关键词的文本。
function TableRow({ keywordId }) {
const keyword = useKeyword(keywordId)
return <td>{keyword.text}</td>
}
这代码看起来没问题。逻辑清晰,职责单一。但运行起来,一百行表格就是一百次查询。
N+1 问题的本质:
当你需要处理 N 条数据时,如果每条数据都触发一次额外的查询,你最终会执行 N + 1 次数据库操作(1 次主查询 + N 次子查询)。
在一百行的表格里,这意味着:
- 1 次查询获取表格数据
- 100 次查询获取每个关键词的详细信息
- 总共 101 次数据库访问
如果每次查询需要 10ms,这就是额外的 1 秒延迟。表格越长,延迟越明显。
这是 N+1 查询问题。看起来像技术问题,其实是思维问题。
一次一次的惯性
为什么会写出这样的代码?
因为「一个一个处理」是人的本能思维模式。看到一行数据,就想着处理这一行。看到下一行,再处理下一行。
这种思维简单、直接、符合直觉。就像给十个人打电话,第一个打完再打第二个。
但计算机不是人。计算机擅长的是批量处理,不是一个一个来。
人的思维模式和计算机的工作模式,在这里产生了分歧。
聚合的智慧
解决方法很简单:不要在每一行里查询,在表格外面,一次性把所有关键词都查出来。
function Table({ rows }) {
const keywordIds = rows.map(row => row.keywordId)
const keywords = useKeywords(keywordIds) // 一次查询获取所有
return rows.map(row => (
<TableRow keyword={keywords[row.keywordId]} />
))
}
从一百次查询,变成一次查询。
这不是优化,这是换了一种思考方式。从「一个一个处理」变成「先收集,再处理」。
💡 Click the maximize icon to view in fullscreen
聚合不是技巧,是视角的转换。从局部看到整体,从单个看到批量。
代价的叠加
一次查询的代价很小。十毫秒,感觉不到。
但一次变成一百次,十毫秒变成一秒。一秒在网页加载里,用户已经开始感到焦虑。
代价会叠加。一次一次的便利,累积起来就是系统的瓶颈。
这不只是数据库查询。所有的重复操作都是这样:单次无害,累积有毒。
如何发现代码里的 N+1 问题:
- 在循环里看到查询:
for循环、map函数里出现数据库调用或 API 请求 - 组件渲染时触发多次请求:打开 Network 面板,看到大量相似的请求
- 页面加载时间和数据量成线性关系:10条数据 0.1秒,100条数据 1秒,1000条数据 10秒
如果符合这些特征,很可能就是 N+1 问题。
为什么这么难察觉
因为开发时测试数据少。
十行数据的表格,一百毫秒加载完成,感觉不到问题。上线后,用户的表格有一千行,卡了十秒。
小规模下正常的东西,大规模下崩溃。这是很多性能问题的共性。
单个单元格的逻辑没错,但把这个逻辑放进表格里重复一千次,就错了。
局部正确不等于整体正确。
最后
一次一次地做事,是人的直觉。但直觉不总是对的。
有些事情,需要先退一步,看到整体,然后批量处理。
表格是这样,数据库查询是这样,很多事情都是这样。
不要被「一个一个」的便利蒙蔽。累积起来,代价可能比你想象的大得多。
解决 N+1 问题的常见策略:
-
批量查询(Batch Loading)
- 收集所有需要的 ID
- 一次性查询所有数据
- 在前端维护 ID 到数据的映射
-
数据预加载(Eager Loading)
- 在查询主数据时,通过 JOIN 或子查询一次性获取关联数据
- 适用于后端 API 设计
-
使用 DataLoader
- Facebook 开发的批量加载工具
- 自动合并相同时间窗口内的查询
- 内置缓存和去重功能
-
分页加虚拟滚动
- 如果数据量巨大,不要一次渲染所有行
- 只渲染可见区域的数据
- 配合批量查询使用
-
服务端优化
- 提供批量查询的 API 端点
- 例如:
GET /keywords?ids=1,2,3,4,5 - 后端一次查询返回所有结果
相关阅读:
Related Posts
Articles you might also find interesting
解析 Payload
Payload 不只是技术术语,它揭示了一个更深刻的模式:真正有价值的东西,往往需要层层包装才能到达目的地
依赖注入
依赖注入不是关于框架或工具,而是关于控制权的转移。理解这个转移,就理解了软件设计的核心原则。
Google Fonts 官方集成
Next.js 提供了 next/font 模块,让字体加载变得简单且性能优化。Google Fonts 是最直接的商用免费字体选择。
请求包含 gzip 压缩的任务结果 JSON
数据传输的本质是在空间和时间之间做选择,压缩是对带宽的节约,也是对等待的妥协
引入懒加载模式
懒加载不是优化技巧,而是关于时机的选择。何时创建,决定了系统的效率和复杂度。
减少 Next.js 启动时的工作量
开发服务器启动缓慢不是偶然。它在做的事太多了。
信息的归宿
关于持续输出的系统性思考:为什么观点要慎发,为什么节奏比速度重要,为什么生活质量决定创作质量
指数退避超时 - 防止无限重试循环
失败后立即重试是本能。但有些失败,需要时间来消化。指数退避不是逃避失败,而是尊重失败。
莫向外求:内在力量的回归
所有真正持久的幸福、力量和智慧,其根源不在于外部世界,而在于内在状态。这是一次关于收回主导权的深刻探索。