一次一次的代价

2 min read
Zekari
性能优化系统思维技术哲学

你需要给十个人打电话,通知同一件事。

一个一个打,每次三分钟,总共三十分钟。或者建个群,一次搞定,三十秒。

大多数人选择一个一个打。不是因为不知道建群更快,而是因为「一个一个」太自然了。

表格里的陷阱

表格有一百行数据。每一行需要显示一个关键词的源文本。

最直接的写法:在每一行的单元格里,调用一次 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 问题:

  1. 在循环里看到查询for 循环、map 函数里出现数据库调用或 API 请求
  2. 组件渲染时触发多次请求:打开 Network 面板,看到大量相似的请求
  3. 页面加载时间和数据量成线性关系:10条数据 0.1秒,100条数据 1秒,1000条数据 10秒

如果符合这些特征,很可能就是 N+1 问题。

为什么这么难察觉

因为开发时测试数据少。

十行数据的表格,一百毫秒加载完成,感觉不到问题。上线后,用户的表格有一千行,卡了十秒。

小规模下正常的东西,大规模下崩溃。这是很多性能问题的共性。

单个单元格的逻辑没错,但把这个逻辑放进表格里重复一千次,就错了。

局部正确不等于整体正确。

最后

一次一次地做事,是人的直觉。但直觉不总是对的。

有些事情,需要先退一步,看到整体,然后批量处理。

表格是这样,数据库查询是这样,很多事情都是这样。

不要被「一个一个」的便利蒙蔽。累积起来,代价可能比你想象的大得多。


解决 N+1 问题的常见策略:

  1. 批量查询(Batch Loading)

    • 收集所有需要的 ID
    • 一次性查询所有数据
    • 在前端维护 ID 到数据的映射
  2. 数据预加载(Eager Loading)

    • 在查询主数据时,通过 JOIN 或子查询一次性获取关联数据
    • 适用于后端 API 设计
  3. 使用 DataLoader

    • Facebook 开发的批量加载工具
    • 自动合并相同时间窗口内的查询
    • 内置缓存和去重功能
  4. 分页加虚拟滚动

    • 如果数据量巨大,不要一次渲染所有行
    • 只渲染可见区域的数据
    • 配合批量查询使用
  5. 服务端优化

    • 提供批量查询的 API 端点
    • 例如:GET /keywords?ids=1,2,3,4,5
    • 后端一次查询返回所有结果

相关阅读:

Related Posts

Articles you might also find interesting

解析 Payload

3 min read

Payload 不只是技术术语,它揭示了一个更深刻的模式:真正有价值的东西,往往需要层层包装才能到达目的地

系统思维技术哲学
Read More

依赖注入

2 min read

依赖注入不是关于框架或工具,而是关于控制权的转移。理解这个转移,就理解了软件设计的核心原则。

软件设计系统思维
Read More

Google Fonts 官方集成

2 min read

Next.js 提供了 next/font 模块,让字体加载变得简单且性能优化。Google Fonts 是最直接的商用免费字体选择。

Next.js字体
Read More

请求包含 gzip 压缩的任务结果 JSON

2 min read

数据传输的本质是在空间和时间之间做选择,压缩是对带宽的节约,也是对等待的妥协

HTTP性能优化
Read More

引入懒加载模式

1 min read

懒加载不是优化技巧,而是关于时机的选择。何时创建,决定了系统的效率和复杂度。

软件设计性能优化
Read More

减少 Next.js 启动时的工作量

2 min read

开发服务器启动缓慢不是偶然。它在做的事太多了。

Next.js性能优化
Read More

信息的归宿

1 min read

关于持续输出的系统性思考:为什么观点要慎发,为什么节奏比速度重要,为什么生活质量决定创作质量

产品设计系统思维
Read More

指数退避超时 - 防止无限重试循环

3 min read

失败后立即重试是本能。但有些失败,需要时间来消化。指数退避不是逃避失败,而是尊重失败。

系统设计可靠性工程
Read More

莫向外求:内在力量的回归

1 min read

所有真正持久的幸福、力量和智慧,其根源不在于外部世界,而在于内在状态。这是一次关于收回主导权的深刻探索。

哲学思考个人成长
Read More