LOADING
1564 words
8 minutes
查询设计的三个陷阱(四)

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 轮换到底在防什么、以及改密码后旧会话应该全部失效。

查询设计的三个陷阱(四)
/posts/2026-7-24/4-完整crud与查询设计/
Author
Atopos
Published at
2026-07-24
License
CC BY-NC-SA 4.0

Some information may be outdated