系统上线那一天,大家还挺高兴的。结果用了没两天,运营同事就反馈:订单列表页面转圈圈,有时候要等好几秒才出数据。更让人头疼的是,数据库的CPU时不时就飙到百分之八九十,报警邮件一会儿一封。你心里清楚,用户量还没上来,这明显不正常。于是开始排查接口性能,一查发现,时间几乎全花在数据库查询上。而罪魁祸首,往往就是Eloquent关联模型查询时那个不起眼的“N+1”问题。
一、问题从哪里来
先说说我遇到的那个具体场景。有一个订单列表接口,需要展示订单的基本信息,同时还要带上每个订单对应的用户昵称、收货地址,以及订单里包含的商品名称。刚开始写代码的时候,觉得特别简单。订单表查出来,循环遍历,然后通过关联关系挨个取数据。比如这样:
// 技术栈:PHP + Laravel Eloquent
$orders = Order::where('status', 1)->get();
foreach ($orders as $order) {
// 这里每次循环都会执行一次 user 表的查询
echo $order->user->name;
// 这里每次循环都会执行一次 address 表的查询
echo $order->address->province;
// 这里每次循环都会执行一次商品关联表的查询
echo $order->products->pluck('name')->implode(',');
}
这段代码相信很多朋友都写过。当时项目小,数据量几千条,感觉没啥问题。后来订单涨到几万条,问题就暴露了。你想想,假设一次查出100条订单,那么上述代码会执行多少次数据库查询?订单本身1次,用户查询100次,地址查询100次,商品查询100次,总共301次查询。这可太夸张了。每次查询哪怕只有几毫秒,加起来也快一秒多了。要是订单涨到1000条,那就是3001次查询,接口不慢才怪。
而且更可怕的是,数据库连接不是无限的。每次查询都要走一遍网络、SQL解析、执行计划、数据返回。大量重复相似的SQL堆积在数据库上,CPU和内存都被白白消耗掉。这就是为什么很多看似简单的接口,数据量一大就变得特别卡顿。
二、顺着日志找真凶
光靠猜可不行,我们需要实打实的证据。好在Laravel自带的查询日志功能,能帮我们清清楚楚地看到每次请求到底执行了哪些SQL。开启日志只需要几行代码,通常放在AppServiceProvider的boot方法里,或者直接放到路由中间件里。线上环境建议按请求ID或者用户ID来记录,方便定位问题。
2.1 开启查询日志
// 技术栈:PHP + Laravel Eloquent
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
// 在AppServiceProvider的boot方法中注册
public function boot()
{
// 只在开发环境或调试模式下开启
if (config('app.debug')) {
DB::listen(function ($query) {
// $query->sql 是原始SQL语句
// $query->bindings 是绑定参数
// $query->time 是执行耗时(毫秒)
Log::info('SQL日志', [
'sql' => $query->sql,
'bindings' => $query->bindings,
'time' => $query->time . ' ms',
'url' => request()->fullUrl(),
]);
});
}
}
开启之后,随便调一下那个慢接口,然后打开日志文件,就能看到一长串SQL。你会发现大量一模一样的SQL,只是where条件的id不同。比如这几条:
select * from users where id = 1
select * from users where id = 2
select * from users where id = 3
还有:
select * from addresses where order_id = 101
select * from addresses where order_id = 102
select * from addresses where order_id = 103
看到了吧?这些SQL除了参数不一样,结构完全相同。这哪里是查询,分明是在“重复劳动”。按这个日志统计,一次请求里面重复的查询占了大部分,真正有用的大查询反而没几条。
2.2 从日志里发现重复模式
为了更直观,我们可以把日志里相同SQL的执行次数做一个粗略统计。简单写个脚本,把上面日志里的SQL按文本归类,算一下执行次数。通常你会发现,某条SQL执行了几十次甚至上百次,而其它SQL就几次。这种“一条主查询 + 大量重复关联查询”的模式,就是教科书级的N+1问题。
为什么会这样?因为Eloquent的关联属性是懒加载的。什么意思呢?当你访问$order->user的时候,如果这个关联属性没有被提前加载,Eloquent就会立刻去数据库查一次,查完再缓存到这个订单模型上。可一旦订单集合里有100个不同的订单,那每个订单都会自己去查一次user,自然就产生了100条相同的SQL。这完全是“兵来将挡,水来土掩”的做法,对手多了你也跟着多跑几趟,完全没想过一次性把所有人的资料都拿回来。
三、用with预加载一次性把数据拿全
Laravel提供了很贴心的一个方法——with()。作用很简单:在查询主模型的时候,顺便把需要的关联模型一次性查出来,然后通过主键映射,把关联数据挂到对应的模型上。这样,主查询还是1次,关联查询变成固定的几次,不会再随主模型数量增加而增加。
3.1 with的基本用法
还拿刚才的订单列表举例。我们用with()把user和address都预先加载进来:
// 技术栈:PHP + Laravel Eloquent
use App\Models\Order;
// 一次性加载 user 和 address 两个关联关系
$orders = Order::where('status', 1)
->with(['user', 'address'])
->get();
// 循环的时候,关联数据已经在内存里了,不会再查数据库
foreach ($orders as $order) {
// 下面的每一行都不会触发新的SQL查询
echo $order->user->name;
echo $order->address->province;
// 这里依然会触发商品关联查询,我们稍后处理
echo $order->products->pluck('name')->implode(',');
}
改了之后,上面代码的SQL执行次数变成多少了呢?订单主查询1次,然后Eloquent会生成一条查询用户表的SQL,比如select * from users where id in (?, ?, ? ...),把所有订单里的user_id打包成一个in查询。地址表同理。总共就3次查询,跟订单数量无关。假设订单100条,之前301次,现在3次,这差距可太大了。
3.2 嵌套关联和字段收窄
有时候关联关系不是一层,比如订单关联了商品,商品又关联了品牌。这时候我们可以用点语法,让with一层层往下带。同时如果你担心数据量太大,也可以在关联闭包里加上条件,只加载需要的关联数据。比如订单里的商品可能有很多历史版本,但我们只关心当前有效的:
// 技术栈:PHP + Laravel Eloquent
$orders = Order::where('status', 1)
->with([
'user', // 加载用户
'address', // 加载收货地址
'products' => function ($query) { // 加载有效商品
$query->where('is_active', 1)->select('id', 'order_id', 'name');
},
'products.brand' => function ($query) { // 加载商品品牌,只取id和名称
$query->select('id', 'name');
}
])
->get();
这里要提个关键点:select('id', 'name')是很有必要的。如果你不手动指定字段,子查询会把这个表的全部字段查出来,虽然数据不多,但多少会浪费一点内存和带宽。更重要的是,如果子查询不包含关联外键,Eloquent可能没法正确匹配。比如上面products.brand的关联里,brand表需要通过id和products表的brand_id关联,所以id必须包含在select里,否则匹配不上。同理,加载products的时候,order_id也要带上,不然订单和商品的对不上。很多人踩过这个坑,以为选字段没啥影响,结果关联数据变成null,排查半天。
四、只取需要的字段,让查询更轻量
上面提到用select收窄字段,这其实是个大话题。很多开发者在查询时习惯用*,图省事。但在接口响应里,可能根本用不到那么多字段。比如订单列表只需要展示用户昵称,你却把用户的密码、邮箱、手机号全查出来。这不仅让数据库多传数据,还会让我们的代码存在安全隐患。
4.1 关联字段收窄
我们进一步优化。在主查询上也只取订单的必要字段。注意,主表查询必须包含关联的外键字段,比如user_id、address_id,不然Eloquent无法把关联挂到订单上。所以select一般是这样:
// 技术栈:PHP + Laravel Eloquent
$orders = Order::where('status', 1)
->select('id', 'order_no', 'user_id', 'address_id', 'total_amount')
->with([
// 用户表只取 id 和 name
'user:id,name',
// 地址表只取 id、province、city、detail
'address:id,province,city,detail',
])
->get();
这样查询出来的SQL会变成:
-- 主查询
select id, order_no, user_id, address_id, total_amount from orders where status = 1;
-- user 预加载
select id, name from users where id in (1, 2, 3 ...);
-- address 预加载
select id, province, city, detail from addresses where id in (101, 102, 103 ...);
每个字段都很精准,没有多余的数据。这样数据库传回的数据包会小很多,接口占用的内存也少了,解析速度自然快。特别是当某个表有几十个字段时,这个优化效果特别明显。
4.2 配合select和with的注意事项
这里要特别提醒一下。如果你用了select收窄字段,一定要保证关联的外键在select列表里。例如订单表要关联用户,那么user_id必须出现在订单的select里;用户表要关联它的订单,那么id必须出现在用户的select里。否则Eloquent找不到外键,就会返回null,甚至报错。
另外,关联模型上如果你用了select('id', 'name'),但后续代码里访问了该模型的其它属性,比如$user->email,Eloquent不会去数据库补查,而是直接给你null。因为预加载已经结束,它认为你已经拿到所有需要的数据了。所以选字段的时候,要仔细盘算一下,后面到底会用到哪些字段。如果不太确定,宁可多选一两个,也别漏掉关键的。
还有一种更严格的方式,是在关联定义时使用select。但这样会影响所有用到这个关联的地方,有点一刀切。建议还是在查询时用with里的闭包或字符串形式来动态控制,灵活度更高。
五、应用场景、技术优缺点、注意事项
任何技术都不是银弹。with预加载虽然好用,但也要看清楚场景。
5.1 适合用with的场景
- 列表接口:一次展示多条记录,每条记录都有关联数据需要显示。这是最典型的场景,用
with能彻底避免N+1。 - 详情页:如果你只查一条记录,比如订单详情,那用不用
with区别不大。因为懒加载也会先查订单,再查关联,各执行一次,总共也就两次。但如果你在详情页里循环访问了很多嵌套关联,比如订单里的商品,商品又关联了促销活动,那还是值得用with一口气加载出来。 - 聚合查询:比如要根据关联表的状态统计数量,用
withCount或withExists,能减少很多冗余查询。
5.2 with预加载的优缺点
优点很明显:
- 查询次数少,从N+1变成固定几次。
- 代码简洁,不用自己写一堆whereIn去处理关联。
- 配合字段收窄,能有效降低数据库的CPU和IO负载。
- 有清晰的关联结构,代码可读性好。
缺点和风险也有:
- 如果你无脑加载过多关联,而且每个关联都查全字段,那你可能一次取回几万条记录,内存直接爆掉。比如一个订单有很多商品,你全部查出来,但接口只需要数量,那就得不偿失了。
- 不当的字段收窄可能导致运行时错误或数据缺失,调试起来有点隐蔽。
- 深层嵌套关联如果全都加载,可能会产出非常大的SQL和结果集,反而拖慢性能。
- 预加载是片状的,如果你只加载一层,第二层依然懒加载,需要确保分层都处理好。
5.3 注意事项
- 使用
with时,注意数据库索引。关联查询用的是where in,这通常会走主键索引,但如果关联字段不是索引,比如where order_id in (...),那就要给order_id加索引,否则依然全表扫描。 - 避免循环内使用Lazy Loading。如果业务逻辑里有一个循环,循环内部调用了关联属性,而外层查询没有
with,那就要小心N+1再次出现。建议单元测试里加一个断言,统计SQL查询次数不超过某个阈值,防止以后别人改动代码又引入问题。 - 对于超大分页,
with也不能解决一切。比如一次性能取10万条订单,即使只要3次查询,但传输和内存消耗还是很大。这时候要配合分页、游标分页等技术,限制每次取的数据量。 - 日志功能在线上一定要谨慎开启,记录SQL本身也有性能开销。最好的方式是临时开启,定位问题后立刻关闭,或者用专门的调试工具,只在请求耗时超过某个阈值时记录。
- 除了
with,还可以考虑将一些高频且不经常变化的关联数据放到缓存里。不过缓存会带来一致性问题,适合读多写少的场景。先别急着上缓存,把with用对了,性能提升往往已经足够。
六、文章总结
回到最初的问题。订单接口慢,数据库压力大,其实根源就是关联模型的懒加载导致产生了大量的重复SQL。通过开启查询日志,我们能直观地看到这些重复模式。然后使用with()方法,一次把用户、地址、商品等关联数据全部预加载进来,配合字段收窄,只取真正用得到的列。你会发现查询次数从几百次降到了几次,数据库CPU也平稳了下来。像是给一只乱跑的小马驹套上了缰绳,让它沿着最节约资源的路径一步步走。
当然,性能优化没有终点。with解决了N+1问题,但后续可能面临更复杂的数据量、更频繁的并发。到时候还需要考虑索引优化、分页策略、缓存、读写分离等等。不过至少,在面对关联查询带来的压力时,我们手里有了一个趁手的工具。希望这篇文章能帮你把Eloquent关联查询的基础打牢,让“慢接口”这个幽灵离你的项目远远的。
评论
围绕“Eloquent关联模型查询导致接口响应缓慢,通过实时查询日志识别重复查询模式,利用with预加载与字段收窄,彻底消除数据库访问压力。”参与讨论