mcpField
N
n-plus-one-prevention

n-plus-one-prevention

github.com/yanko-belov开发1340 次安装40 周前更新
代码仓库

关于 n-plus-one-prevention

面向开发者的数据库查询技能,指导通过一次性获取关联数据来避免 N+1 查询,而不是在循环中重复查询。内容介绍预加载、查询计数监控和常见问题模式。

主要功能

  1. 01识别循环中的查询
  2. 02使用预加载获取关联数据
  3. 03通过子查询或 GROUP BY 获取聚合
  4. 04监控每个请求的查询数
  5. 05识别常见 N+1 征兆

适用场景

  1. 01为列表接口加载关联数据
  2. 02减少 API 响应中的查询
  3. 03检查在循环内查询的 ORM 代码

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/yanko-belov/code-craft --skill n-plus-one-prevention

安装后在 Claude Code 中输入/n-plus-one-prevention确认已加载。

详细介绍

N+1 查询防护

概述

切勿在循环中查询。应通过单次查询获取相关数据。

N+1 指先获取 N 个项目,再额外执行 N 次查询来获取相关数据。这是最常见的数据库性能杀手。

适用场景

  • 获取包含相关数据的列表
  • 循环中包含数据库查询
  • 响应缓慢的列表/索引端点
  • 一个 API 响应需要执行多次查询

铁律

NEVER put a database query inside a loop.

绝无例外:

  • 不要因为“只有几个项目”就破例
  • 不要因为“查询很快”就破例
  • 不要因为“我们会缓存”就破例
  • 不要因为“这样更简单”就破例

检测:N+1 模式

如果在循环中查询,立即停止:

// ❌ VIOLATION: N+1 queries
const orders = await Order.findAll();  // 1 query

const ordersWithCustomers = await Promise.all(
  orders.map(async (order) => {
    // N queries (one per order)
    const customer = await Customer.findByPk(order.customerId);
    return { ...order, customerName: customer.name };
  })
);
// Total: 1 + N queries

100 个订单 = 101 次数据库查询!

正确模式:预加载

// ✅ CORRECT: Single query with JOIN

// Sequelize
const orders = await Order.findAll({
  include: [{ model: Customer, attributes: ['name'] }]
});
// 1 query with JOIN

// Prisma
const orders = await prisma.order.findMany({
  include: { customer: { select: { name: true } } }
});

// TypeORM
const orders = await orderRepository.find({
  relations: ['customer']
});

// Raw SQL
const orders = await db.query(`
  SELECT o.*, c.name as customer_name 
  FROM orders o
  JOIN customers c ON o.customer_id = c.id
`);

常见的 N+1 场景

1. 关联实体
// ❌ N+1
posts.map(post => await User.findById(post.authorId));

// ✅ Eager load
Post.findAll({ include: [User] });
2. 聚合数据
// ❌ N+1
users.map(user => await Order.count({ where: { userId: user.id } }));

// ✅ Subquery or GROUP BY
User.findAll({
  attributes: {
    include: [[sequelize.fn('COUNT', sequelize.col('orders.id')), 'orderCount']]
  },
  include: [{ model: Order, attributes: [] }],
  group: ['User.id']
});
3. 多个关联
// ❌ N+1 (multiple)
orders.map(order => {
  await Customer.findByPk(order.customerId);
  await Product.findAll({ where: { orderId: order.id } });
});

// ✅ Eager load all
Order.findAll({
  include: [Customer, Product]
});

检测工具

// Log query count per request
let queryCount = 0;
db.on('query', () => queryCount++);

app.use((req, res, next) => {
  queryCount = 0;
  res.on('finish', () => {
    if (queryCount > 10) {
      console.warn(`N+1 alert: ${req.path} made ${queryCount} queries`);
    }
  });
  next();
});

抵御压力的准则

1. “只有几个项目”

压力:“我们只有 10 个订单”

**回应:**10 会变成 100,再变成 10,000。现在就修复。

**行动:**无论当前数据量有多大,始终使用预加载。

2. “查询很快”

压力:“每次查询只需 1ms”

**回应:**1ms × 1000 = 1 秒。网络开销还会增加耗时。

**行动:**一次 5ms 的查询胜过 1000 次各耗时 1ms 的查询。

3. “我们会缓存”

压力:“Redis 会缓存结果”

**回应:**缓存未命中时仍会访问数据库。首次请求依然很慢。缓存还会增加复杂性。

**行动:**先修复查询。如果仍有需要,再缓存。

4. “这样更简单”

压力:“循环更容易理解”

**回应:**速度慢 100 倍的简单代码并不简单。

**行动:**学习 ORM 的预加载语法。

危险信号——立即停止并重新考虑

  • 在 .map() 或 .forEach() 中使用 await
  • 查询次数随结果数量增加
  • 列表端点比详情端点慢
  • 列表页面一直显示“正在加载……”
  • ORM 默认采用延迟加载

这些都意味着:重构为预加载。

快速参考

N+1(错误)预加载(正确)
循环 + 查询JOIN / include
1 + N 次查询1 次查询
O(N) 次往返O(1) 次往返
数据越多越慢查询次数恒定

常见借口(均不成立)

借口事实
“项目很少”数据会增长。现在就修复。
“查询很快”N 次慢查询 > 1 次中等耗时的查询。
“我们会缓存”缓存无法修复糟糕的查询。
“这样更简单”慢并不简单。
“ORM 会处理”ORM 默认采用延迟加载。

归根结底

列表用一次查询。相关数据再用一次查询。切勿在循环中查询。

使用预加载(include/join)获取相关数据。留意查询次数。任何循环内的查询都是一个迟早会影响扩展性的 bug。

评论

支持 Markdown