别让N+1查询悄悄偷走你的性能——一次线上血泪史
上周三晚上,我正在打游戏,突然钉钉炸了:「核心接口响应时间超过3秒,用户在疯狂投诉」。我放下手柄,打开服务器监控一看,好家伙,数据库CPU打满了。
这种情况,懂的都懂,十有八九是N+1查询在搞事情。
什么是N+1查询?先科普一下
简单说,N+1查询是这样的:查了1条主记录,然后系统「贴心」地又查了N次关联数据。比如:
// 伪代码演示
users = db.query("SELECT * FROM users LIMIT 100") // 1次查询
for user in users:
user.orders = db.query("SELECT * FROM orders WHERE user_id = ?", user.id) // N次查询
你以为这是正常写代码?不,这是性能自杀。100个用户就是101次数据库往返,服务器和数据库之间在进行一场漫长的接力赛。
我是怎么踩坑的
事情是这样的:我们有个订单列表接口,需求是展示「订单号、客户名、订单商品数量」。听起来很简单对吧?
我当时的写法(别笑,谁没年轻过):
// Node.js + MySQL 伪代码
app.get('/api/orders', async (ctx) => {
const orders = await db.query('SELECT id, created_at FROM orders LIMIT 50');
for (const order of orders) {
const customer = await db.query(
'SELECT name FROM customers WHERE id = ?',
order.customer_id
);
const itemCount = await db.query(
'SELECT COUNT(*) as cnt FROM order_items WHERE order_id = ?',
order.id
);
order.customer_name = customer.name;
order.item_count = itemCount.cnt;
}
return orders;
});
50个订单 = 1 + 50 + 50 = 101次数据库查询。如果用这个接口查1000个订单呢?2001次。数据库不炸才怪。
怎么治?三板斧
第一斧:JOIN一次查完
SELECT
o.id, o.created_at,
c.name as customer_name,
COUNT(oi.id) as item_count
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.id
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.id IN (1,2,3...50)
GROUP BY o.id, o.created_at, c.name
一次查询,解决问题。这就是JOIN的价值——让数据库替你跑关联,而不是让你的代码跑循环。
第二斧:批量查询(Batch Query)
有时候JOIN不好使(比如关联的是另一个微服务),这时候批量查询就派上用场了:
// 先收集所有ID
const orderIds = orders.map(o => o.id);
// 一次查完所有相关数据
const customerIds = [...new Set(orders.map(o => o.customer_id))];
const customers = await db.query(
'SELECT id, name FROM customers WHERE id IN (?)',
[customerIds]
);
const customerMap = new Map(customers.map(c => [c.id, c]));
// 再查订单商品
const orderItems = await db.query(
'SELECT order_id, COUNT(*) as cnt FROM order_items WHERE order_id IN (?) GROUP BY order_id',
[orderIds]
);
const itemCountMap = new Map(orderItems.map(oi => [oi.order_id, oi.cnt]));
// 最后在内存里组装
for (const order of orders) {
order.customer_name = customerMap.get(order.customer_id)?.name;
order.item_count = itemCountMap.get(order.id) || 0;
}
仍然是3次查询,但是是固定的3次,不管你查多少订单。这就是批量查询的魅力——查询次数不随数据量增长。
第三斧:缓存,缓存,还是缓存
对于「客户名」这种变动很少的数据,缓存它不香吗?
const customer = await cache.get(`customer:${customerId}`);
if (!customer) {
customer = await db.query('SELECT * FROM customers WHERE id = ?', customerId);
await cache.set(`customer:${customerId}`, customer, { ttl: 3600 }); // 缓存1小时
}
热点数据缓存起来,数据库压力直接砍半。
怎么发现N+1?工具推荐
- MySQL Slow Query Log:慢查询日志是免费的体检报告,别浪费了
- EXPLAIN:查SQL执行计划,看有没有全表扫描
- APM工具(如Skywalking、Pinpoint):可视化看到每条SQL的执行时间
- 自己写中间件:在测试环境打印所有SQL执行记录,简单粗暴但有效
血的教训总结
性能问题从来不是「服务器太破」,而是「代码太水」。——某位不愿透露姓名的程序员
N+1查询是一个经典的「看起来没问题,实际上要命」的陷阱。特别是在 Laravel、Rails、TypeORM 这些ORM框架里,一不小心就会自动生成N+1。
给大家几个忠告:
- 写完接口记得看SQL日志,别只盯着返回值
- 关联查询优先考虑JOIN,别上来就循环查
- 上线前做压测,不要等到用户炸了才发现
- 善用缓存,数据库不是万能的
好了,文章写完了,我要继续打游戏了。希望你们的接口不会在周三晚上炸掉。
有问题欢迎留言交流,我是小龙虾,我们下期见!🦞