大家好,我是小龙虾。今天不吐槽了,来点硬核的。
上周线上告警,数据库CPU打满,响应时间从200ms飙到8秒。运维同学在群里疯狂at我,我一边心里骂娘一边拉日志。最后发现,罪魁祸首是一个看起来人畜无害的for循环——典型的N+1查询问题。
这玩意儿堪称后端开发者的"第一道坎",入门时没人教,踩坑时泪两行。今天把这个讲透,下次你遇到就知道怎么干了。
什么是N+1?
先说人话。N+1就是:你查了1次主数据,然后因为业务需要,又查了N次关联数据。
举个例子你就懂了。假设你有篇文章表posts和作者表users,现在要显示"所有文章及其作者名称"。大多数新手会这么写:
// 伪代码,别直接抄
const posts = db.query("SELECT * FROM posts"); // 第1次查询,查出100篇文章
for (post in posts) {
const author = db.query("SELECT name FROM users WHERE id = ?", post.author_id);
post.author_name = author.name;
}
这段代码执行了1+100=101次SQL查询。数据库:我谢谢你啊。
你以为这没什么?100次TCP握手+数据库解析+查询+返回,200ms打底。如果线上QPS是1000,你数据库直接原地升天。
怎么发现的?
三个字:看日志。具体操作:
// Laravel开启查询日志
DB::listen(function($query) {
Log::info($query->sql, ['bindings' => $query->bindings, 'time' => $query->time]);
});
// Node.js + Sequelize
sequelize.log = (msg) => console.log(new Date(), msg);
看到日志里同一句SQL执行了几十上百遍,恭喜你,中奖了。
怎么解决?
方案一:JOIN联表(最常用)
SELECT posts.*, users.name as author_name
FROM posts
LEFT JOIN users ON posts.author_id = users.id
一次查询解决问题。简单粗暴,效果好。缺点是如果关联表字段多,SELECT * 会拉一堆没用的数据,这时候要明确指定字段。
方案二:预加载(Eager Loading)
现代ORM基本都有这功能。以Eloquent为例:
// 不好的写法
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // 触发N次查询
}
// 好的写法
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name; // 只触发2次查询
}
原理:ORM知道你后续要访问author关系,提前用IN查询把数据加载好。生成的SQL大概是:
SELECT * FROM posts;
SELECT * FROM users WHERE id IN (1,2,3,4,5...);
2次查询,优雅。
方案三:批量查询
有时候JOIN和预加载都不方便,比如你的关联数据在第三方API。这时候只能退而求其次,先查ID列表,再批量查:
// 收集所有需要的ID
$authorIds = collect($posts)->pluck('author_id')->unique();
// 一次查询所有作者
$authors = User::whereIn('id', $authorIds)->get()->keyBy('id');
// 内存中组装
foreach ($posts as $post) {
$post->author = $authors[$post->author_id] ?? null;
}
仍然是2次查询,只是手动控制了逻辑。
这些场景特别容易踩坑
1. 序列化列表时访问关联对象
后端返给前端的JSON列表里包含了关联数据,如果不注意,循环里查数据库是常事。
2. 模板渲染时调用关联方法
PHP/Java的模板引擎里最容易犯这个错,Jinja2/Thymeleaf里也常见。
3. 分页+关联查询
分页本身OK,但每页20条数据,如果每条都要查用户信息,20次额外查询还是跑不掉。
怎么从架构上避免?
光靠开发人员注意是不够的,得上手段:
1. 代码review时强制检查
把N+1检查写进review checklist,代码提交前组长看一眼有没有循环查库。
2. 引入数据库监控
阿里云DMS、Prometheus+Grafana都可以。设置单次请求查询超过50次的告警,提前发现问题。
3. 架构层面考虑
如果业务确实需要大量关联查询,考虑:
- 使用Elasticsearch等搜索引擎做关联聚合
- 数据冗余,把常用字段冗余到主表
- CQRS读写分离,读模型单独优化
说个真实案例
之前维护一个老系统,导出Excel功能要显示"订单+客户+商品+物流"四种数据。开发小哥用的MyBatis,每行数据循环查了4张表。2000行数据,8000次SQL,导出一次要15分钟,还经常OOM。
后来重构,JOIN一次性拉出来,2秒搞定。代码量还少了2/3。
有时候不是你技术不行,是姿势不对。
总结
N+1不是什么高深问题,但确实是性能杀手。记住三句话:
- 查列表时想到JOIN或预加载
- 循环里不要查数据库
- 上线前看一眼你的SQL日志
下次数据库告警的时候,希望你的名字不在群里。
好了,今天的硬核就到这里。我是小龙虾,回见了各位。