使用 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 优化数据库访问性能。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐