Codex写ORM查询为什么数据越多越慢?用N+1优化接口性能
使用 Codex 开发 Node.js、NestJS、Prisma、TypeORM、Sequelize 等后端项目时,经常会遇到一种很典型的性能问题:
刚开始数据库只有几百条数据,接口响应非常快。
但随着订单、用户、商品数量不断增加,接口开始出现:
-
列表只有50条,数据库却执行了几十甚至上百条SQL;
-
本地测试正常,生产环境突然变慢;
-
CPU占用不高,但数据库连接数快速增加;
-
每增加一页数据,接口耗时明显变长;
-
Codex不断增加缓存,却没有解决根本问题;
-
ORM代码看起来很简洁,实际SQL数量却非常多。
这类问题很可能不是数据库本身性能不够,而是出现了经典的 N+1 Query。
一、什么是N+1查询?
假设需要查询20条订单,同时显示每个订单对应的用户。
Codex可能生成:
const orders = await db.order.findMany();
for (const order of orders) {
order.user = await db.user.findUnique({
where: {
id: order.userId
}
});
}
看起来逻辑非常直观:
先查询订单
↓
再查询每个订单对应的用户
但数据库实际执行的是:
查询订单:1次
查询订单1用户:1次
查询订单2用户:1次
查询订单3用户:1次
...
查询订单20用户:1次
最终:
1 + 20 = 21次查询
如果列表变成:
100条订单
就可能变成:
101次查询
这就是 N+1 问题。
二、为什么Codex容易写出N+1?
因为从单个业务对象来看:
await getUser(order.userId);
完全合理。
而且 ORM 把数据库查询封装得非常像普通函数调用。
开发者很容易忽略:
await repository.find(...)
背后其实是一条真正的 SQL。
尤其在:
for
map
forEach
Promise.all
内部调用 ORM 时,更需要警惕。
例如:
const result = await Promise.all(
orders.map(async order => {
const user =
await getUser(order.userId);
return {
...order,
user
};
})
);
虽然使用了 Promise.all,执行时间可能比串行快一些,但数据库查询数量仍然是:
N条
并发N+1仍然是N+1。
三、先统计接口到底执行了多少SQL
遇到列表接口变慢,不要第一时间加Redis。
先确认:
一次HTTP请求
到底执行了多少条SQL?
可以临时开启 ORM Query Log。
例如观察:
SELECT ... FROM orders
SELECT ... FROM users WHERE id = 1
SELECT ... FROM users WHERE id = 2
SELECT ... FROM users WHERE id = 3
...
如果一个普通列表接口执行:
50
100
甚至几百条SQL
就需要重点检查循环内部数据库调用。
可以让 Codex 先分析:
请不要直接修改代码。
检查当前接口:
1. 总共执行多少次数据库查询;
2. 哪些查询位于循环中;
3. 是否存在N+1;
4. 哪些关联数据可以批量查询;
5. 哪些字段实际没有被前端使用。
四、优先使用ORM关联查询
如果订单和用户存在关联,可以一次查询:
const orders =
await prisma.order.findMany({
include: {
user: true
}
});
逻辑上变成:
查询订单
+
同时获取关联用户
具体 ORM 可能通过:
-
JOIN;
-
批量查询;
-
内部合并;
完成关联加载。
相比在业务层中逐条:
getUser()
通常更加高效。
五、不要为了方便直接include所有关联
发现 N+1 后,另一个极端是:
include: {
user: true,
product: true,
payment: true,
address: true,
logs: true,
comments: true
}
这样虽然减少了查询次数,却可能生成一个巨大的数据结构。
问题变成:
查询次数减少了
但每次查询的数据量爆炸
因此更推荐明确选择需要字段:
select: {
id: true,
orderNo: true,
totalAmount: true,
user: {
select: {
id: true,
name: true
}
}
}
原则是:
接口需要什么,就查询什么。
六、多个对象需要同一类数据时使用批量查询
假设订单中有:
userId:
1
2
3
4
5
不要执行:
WHERE id = 1
WHERE id = 2
WHERE id = 3
可以先收集:
const userIds = [
...new Set(
orders.map(
order => order.userId
)
)
];
一次查询:
const users =
await db.user.findMany({
where: {
id: {
in: userIds
}
}
});
数据库变成:
WHERE id IN (...)
然后在内存中建立Map:
const userMap = new Map(
users.map(user => [
user.id,
user
])
);
组合结果:
const result = orders.map(order => ({
...order,
user: userMap.get(order.userId)
}));
这样:
1次订单查询
+
1次用户批量查询
就能完成整个列表。
七、GraphQL特别容易出现N+1
例如 GraphQL:
query {
orders {
id
user {
name
}
}
}
Resolver:
Order: {
user(order) {
return getUser(order.userId);
}
}
如果返回100条订单:
user resolver
可能执行100次。
这种场景通常适合使用 DataLoader。
核心逻辑是把多个:
getUser(1)
getUser(2)
getUser(3)
合并成:
getUsers([1,2,3])
然后再把结果分发回不同 Resolver。
八、DataLoader还能减少重复查询
例如一个请求中多条订单属于同一个用户:
订单A → userId=1001
订单B → userId=1001
订单C → userId=1001
如果普通查询:
查询用户1001
查询用户1001
查询用户1001
同一个用户被查了三次。
DataLoader 通常还可以在当前请求生命周期内做缓存:
userId=1001
只查询一次
需要注意:
DataLoader缓存一般应该绑定:
单次请求
不要无脑做成永久全局缓存,否则会引入数据过期问题。
九、不要在循环里做count查询
另一个很常见的 N+1:
for (const user of users) {
user.orderCount =
await db.order.count({
where: {
userId: user.id
}
});
}
100个用户:
1次用户查询
+
100次COUNT
可以改成数据库聚合:
GROUP BY user_id
例如:
const counts =
await prisma.order.groupBy({
by: ["userId"],
_count: {
id: true
}
});
然后再进行映射。
数据库非常擅长:
GROUP BY
COUNT
SUM
AVG
不要把数据库擅长的聚合工作拆成大量单条请求。
十、序列化阶段也可能偷偷查询数据库
有些项目的 Entity Getter 中写着:
get userName() {
return loadUserName(
this.userId
);
}
或者 DTO 转换过程中进行数据库查询。
这种代码非常危险,因为开发者查看 Controller 时根本看不到查询发生在哪里。
推荐原则:
数据库查询
应该出现在明确的数据访问阶段
而不是隐藏在:
-
Getter;
-
Serializer;
-
JSON转换;
-
Template;
-
日志格式化;
里面。
否则性能问题非常难排查。
十一、N+1和连接池有什么关系?
假设接口一次产生:
101条查询
同时有100个请求:
101 × 100
=
10100次数据库操作
数据库连接池可能只有:
20
50
100
大量查询开始等待连接。
于是表现出来的现象可能是:
数据库CPU并没有100%
接口却越来越慢
因为请求都卡在:
等待数据库连接
所以不要看到连接池不足就直接:
连接池从20改成200
如果真正原因是 N+1,只会让更多垃圾查询同时进入数据库。
十二、建立Query Budget
大型项目可以给接口设置“查询预算”。
例如:
GET /orders
数据库查询预算:
<= 5次
或者:
用户详情接口:
<= 3次
订单列表:
<= 5次
统计接口:
<= 8次
如果一次普通列表突然执行:
67次SQL
自动测试直接失败。
这是一种非常有效的性能回归保护方法。
十三、查询次数少也不代表一定快
假设通过 JOIN 把:
订单
用户
商品
评论
日志
全部一次查询。
最终只执行:
1条SQL
但返回:
几十万行中间结果
性能仍然很差。
因此优化不能只看:
SQL数量
还要看:
-
扫描行数;
-
返回数据量;
-
JOIN方式;
-
索引;
-
排序;
-
执行计划。
1条错误SQL可能比100条小查询更慢。
十四、使用EXPLAIN检查真正执行计划
发现慢查询后,可以查看数据库执行计划。
重点关注:
是否全表扫描?
是否使用索引?
扫描多少行?
是否出现临时排序?
JOIN顺序是否合理?
不要仅凭 SQL 看起来“简单”判断性能。
例如:
SELECT *
FROM orders
WHERE user_id = ?
ORDER BY created_at DESC;
如果:
user_id
created_at
没有合适索引,在大表上仍可能很慢。
Codex可以帮助阅读执行计划,但索引仍应根据真实查询模式设计。
十五、缓存不能替代N+1优化
遇到:
getUser()
执行100次
直接给 getUser() 加Redis缓存,看起来有效。
但最差情况下仍然会变成:
100次Redis请求
虽然比数据库快,但架构问题仍然存在。
正确优化顺序通常是:
先减少不必要查询
↓
批量读取
↓
优化SQL和索引
↓
最后再考虑缓存
缓存应该优化合理查询,而不是掩盖明显的 N+1。
十六、测试需要覆盖“大列表”
如果测试环境只创建:
2条订单
N+1几乎感觉不到。
建议增加:
10条
100条
1000条
观察:
数据量增长10倍
SQL数量是否也增长10倍?
如果:
10条 → 11次查询
100条 → 101次查询
1000条 → 1001次查询
基本就能确认存在 N+1。
理想情况应该接近:
10条 → 2次
100条 → 2次
1000条 → 2次
当然具体次数取决于业务。
十七、让Codex输出查询报告
完成优化后,可以要求:
本轮查询优化报告:
优化前:
订单数量:100
SQL数量:101
接口耗时:850ms
优化后:
订单数量:100
SQL数量:2
接口耗时:95ms
修改方式:
- 批量查询User
- 使用Map关联
- 限制返回字段
新增索引:
无
缓存:
未新增
这样的报告比:
性能已优化
更有价值。
十八、把数据库查询规则写进AGENTS.md
可以加入:
# 数据库查询规则
- 禁止在循环中直接执行ORM查询
- 列表关联数据必须评估N+1
- 可批量查询的数据优先使用IN或关联查询
- 禁止无理由include全部关联对象
- 查询字段使用select控制返回范围
- 聚合数据优先使用数据库GROUP BY
- Getter和Serializer中禁止隐藏数据库请求
- 优化前必须记录SQL数量
- 大列表接口建议设置Query Budget
- 性能问题优先分析查询次数和执行计划,不先加缓存
这样 Codex 在生成 ORM 代码时,会更关注“这段代码最终执行多少次 SQL”。
十九、一个推荐的N+1排查流程
可以固定成:
发现接口变慢
↓
打开SQL日志
↓
统计查询数量
↓
寻找循环内查询
↓
确认关联关系
↓
改为批量查询 / JOIN
↓
限制返回字段
↓
查看EXPLAIN
↓
重新测试100 / 1000条数据
整个过程最关键的问题是:
数据量增加以后,数据库查询次数是否也跟着线性增加?
如果答案是“是”,就应该重点检查 N+1。
二十、Plus还是Pro?
如果主要处理:
-
单接口ORM查询;
-
普通Prisma / TypeORM代码;
-
中小型数据库;
-
简单性能分析;
Plus 通常已经可以覆盖多数 Codex 任务。
如果长期处理:
-
大型后端仓库;
-
多服务数据库访问;
-
大量SQL日志;
-
复杂执行计划;
-
ORM与原生SQL混合;
-
多轮压测和性能重构;
可以根据实际开发强度评估 Pro。
不过更大的使用空间不会自动解决 N+1。
真正重要的是让 Codex 在写 ORM 代码时具备一个意识:
一次看起来普通的函数调用,背后可能就是一次数据库查询。
总结
Codex 写 ORM 查询后,数据越多接口越慢,很多时候并不是数据库机器性能不够,而是一个列表请求偷偷产生了大量重复 SQL。
通过:
关联查询
批量IN查询
DataLoader
GROUP BY
字段裁剪
Query Budget
EXPLAIN
可以把几十甚至几百次数据库访问压缩到少量查询。
真正高效的 ORM 代码,不只是看起来简洁。
更重要的是能够回答:
这段业务代码执行一次,到底会向数据库发送多少条SQL?
只要这个数字没有被关注,N+1就很容易在数据量增长后突然变成线上性能问题。
CSDN文章描述
本文介绍 Codex 编写 Prisma、TypeORM 等 ORM 查询时常见的 N+1 Query 问题,并通过关联查询、批量查询、DataLoader、字段裁剪和 Query Budget 优化数据库访问性能。
更多推荐



所有评论(0)