CRUD 的 C、U、D 通常一次操作只涉及一条记录,性能问题不明显。但 R(查询)——尤其是列表查询——是性能问题的高发区。这一篇聚焦三个最常见的陷阱,它们有一个共同特征:在开发环境的数据量下完全看不出来,生产环境里才会暴露。
一、N+1 问题:21 次查询替代了 1 次
它长什么样
// 1 次查询:拿到 20 篇文章const posts = await prisma.post.findMany({ take: 20 });
// N 次查询:每篇文章单独查作者for (const post of posts) { const author = await prisma.user.findUnique({ where: { id: post.authorId } });}// 总计:1 + 20 = 21 次数据库往返这个模式在代码里看起来完全无害——每一行都调了 ORM 方法,类型也正确。但数据库视角下,这是 21 次网络往返。20 篇文章变成 200 篇,就是 201 次查询。
N+1 之所以”最容易被忽视”,是因为在开发环境里 21 次查询和 1 次查询的差异感觉不到——数据库就在本地,延迟不到 1 毫秒。生产环境里数据库可能在另一台机器上,每次往返 5 毫秒,21 次就是 105 毫秒——用户已经能感知到了。
三种解法,分别适用不同的场景
解法 1:用 include 一次查完。 findMany({ include: { author: true } }) 把文章和作者用 JOIN 一次查出来。适用于大多数场景——关联数据量可控、没有深层嵌套。但如果一篇文章的评论有几百条,include: { comments: true } 会让 JOIN 结果爆炸——每篇文章重复 200 次。
解法 2:手动批量查询。 先查文章列表,收集所有 authorId,用一个 WHERE id IN (...) 一次查出所有作者,在内存里做关联。从 N+1 变成 2 次查询。适合关联数据量大、层级多、不适合用 JOIN 的场景。
解法 3:让 ORM 做。 某些 ORM 会自动将重复的关联查询合并为批量查询。但不要依赖 ORM 替你优化——你至少应该知道你的代码产生了多少条 SQL。开发环境开启查询日志,看到异常多的 SQL 语句,说明有 N+1。
不只是 include
N+1 不只出现在手动循环里。include 本身也可能触发 N+1,如果关联再嵌套关联——文章→评论→评论者——Prisma 会为每层关联生成额外的查询。关键是意识到你的查询在数据库层面发生了什么,而不是信任 ORM 的默认行为。
二、offset 分页的真实代价
不只是”越翻越慢”
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 10000;数据库需要先按 created_at DESC 排序,然后把前 10000 行全部扫描一遍,跳过它们,再取 20 行。OFFSET 越大,数据库扫描的行数越多——不是线性增长,而是每翻一页多扫描 20 行。翻到第 500 页,每次查询扫描 10000 行只为了取 20 行。
更隐蔽的问题:翻页漂移
你正在浏览文章列表的第二页(第 11-20 条)。此时有人发布了 3 篇新文章。你翻到第三页——前三篇其实是刚才第二页的第 8-10 条,因为你翻页时它们被新文章挤到了第三页。用户的感觉是”翻页时内容在跳”。
cursor 分页:另一种思路
SELECT * FROM posts WHERE id < 'cursor_id' ORDER BY id DESC LIMIT 20;Cursor 分页不按”第几页”定位,而是按”上次看到的最后一条记录的 ID”来定位。数据库直接用索引定位到 cursor,不需要扫描前面的行。好处是性能不受数据量影响,不会出现翻页漂移。代价是不能跳到”第 7 页”,只能”上一页 / 下一页”。
什么时候用哪种
- 用户浏览内容流(首页、通知)→ cursor 分页。用户不需要跳页,只往下滑
- 管理后台(用户列表、订单管理)→ offset 分页。管理员需要跳页、看总数
- 数据导出 → 基于 ID 范围的批次处理:
WHERE id > lastId ORDER BY id LIMIT 1000
三、select vs include:不是哪个更方便
列表和详情需要不同的返回类型
文章列表 API 和文章详情 API 返回的 JSON 不应该相同。列表只需要标题、作者名、发布时间——不需要文章正文(可能是几千字的 Markdown)。详情页需要正文和评论列表——但不需要每篇文章都返回全部字段。
列表 DTO 和详情 DTO 应该是两种不同的 TypeScript 类型:
// 列表:最小字段集interface PostListItem { id: string; title: string; author: { name: string }; createdAt: string; commentCount: number; // 不需要加载全部评论,只需要计数}
// 详情:完整字段interface PostDetail { id: string; title: string; content: string; // 几千字的 Markdown author: { name: string }; comments: CommentItem[]; createdAt: string;}select 对应列表场景——只拿指定字段。include 对应需要整条记录的关联数据的场景——但要注意 include: { author: true } 里的 true 意味着返回 User 表的所有字段。如果你只需要 author.name,用 select 而不是 include。
实用规则
永远用 select,除非你明确需要这张表的全部字段。 include 的默认行为是”全部返回”,这在大多数场景下返回了太多不必要的数据。列表里返回 20 篇文章,每篇附带几千字的正文——用户根本没点进去看,但带宽和序列化时间已经花了。
小结
三个陷阱都在开发环境里隐形:
- N+1:本地数据库延迟可忽略,21 次查询感觉像 1 次
- offset:数据量几百条时扫描成本可忽略,几十万条时才暴露
include返回全部字段:API 响应几百 KB 时感觉不到,几百个并发请求时才暴露
下一篇:认证系统的四个硬骨头(五)——JWT 和 Session 不是二选一、Token 撤销为什么是阿喀琉斯之踵、Refresh Token 轮换到底在防什么、以及改密码后旧会话应该全部失效。
Some information may be outdated