关于 n-plus-one-prevention
面向开发者的数据库查询技能,指导通过一次性获取关联数据来避免 N+1 查询,而不是在循环中重复查询。内容介绍预加载、查询计数监控和常见问题模式。
主要功能
- 01识别循环中的查询
- 02使用预加载获取关联数据
- 03通过子查询或 GROUP BY 获取聚合
- 04监控每个请求的查询数
- 05识别常见 N+1 征兆
适用场景
- 01为列表接口加载关联数据
- 02减少 API 响应中的查询
- 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