上个月给一个电商项目做性能排查,发现订单列表接口的响应时间超过了两秒。点开数据库慢查询日志一看,某个接口在一次请求里执行了三百多条SQL。翻开代码,控制器里一个简单的订单分页查询,模板里却循环调用了$order->user->nickname、$order->items、$order->items[0]->sku->name——典型的N+1问题,而且嵌套了三层关联。
这类问题在业务开发中极其常见。模型关联写得爽,寥寥几行就能拿到所有相关数据,但如果不加干预,底层的SQL执行次数会在数据量上来之后直接爆炸。ThinkPHP 8对模型关联这块做了不少优化,预加载的语法也比之前版本更灵活。这篇文章就把我在那个项目里用到的优化手段从头捋一遍,从最基础的with用法到关联统计、延迟预加载,最后给出一个订单列表的完整优化案例。
先看清楚N+1是怎么产生的
假设我们有三张表:orders(订单)、order_items(订单明细)、users(用户)。模型里定义了关联:
// Order模型
class Order extends Model
{
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
public function items()
{
return $this->hasMany(OrderItem::class, 'order_id');
}
}
// OrderItem模型
class OrderItem extends Model
{
public function sku()
{
return $this->belongsTo(ProductSku::class, 'sku_id');
}
}
控制器里一句Order::limit(20)->select()取出20条订单,然后模板里这样渲染:
foreach ($orders as $order) {
echo $order->user->nickname; // 每次都查一次users表
foreach ($order->items as $item) {
echo $item->sku->name; // 每个item再查一次sku表
echo $item->quantity;
}
}
数据库里发生了什么?第一条SQL取20条订单,然后每遍历一个订单就去查一次用户表(20次),每个订单还要查一次明细表(20次),每条明细再查一次SKU表(假如每个订单平均3条明细,就是60次)。总共一百多次查询,而且大部分查的是同一张表、同样的几个用户ID——完全可以用一条WHERE user_id IN (1,2,3...)搞定的事,硬是被拆成了一地鸡毛。
问题的根源在于,模型关联默认是懒加载的。你只有在代码里真正访问关联属性的时候,它才会发SQL去查。这在单条记录的场景下没什么问题,但一进入列表循环,每条记录都独立触发一次查询,查询次数就随着数据量线性增长。
with预加载:一劳永逸的解决方案
最直接的办法是在查询时告诉框架:我后面要用到哪些关联,你一次性帮我查好。ThinkPHP的with方法就是干这个的。
$orders = Order::with(['user', 'items'])
->limit(20)
->select();
加上这一行之后,SQL的执行情况变成了:第一条取订单,第二条SELECT * FROM users WHERE id IN (1,2,3...),第三条SELECT * FROM order_items WHERE order_id IN (1001,1002...)。无论订单列表有多少条,总共只执行三次查询。100次变3次,这就是预加载的威力。
但上面的代码只处理了两层关联。items里面的sku关联还没预加载,模板里遍历$item->sku->name的时候还是会触发N+1。嵌套关联的预加载用点号语法或者数组嵌套来声明:
$orders = Order::with(['user', 'items.sku'])
->limit(20)
->select();
items.sku意味着先预加载items关联,然后在items的结果集上再预加载sku关联。框架会自动识别这种层级关系,生成的SQL是先查订单,再查明细,最后用明细里的sku_id集合一次性查出所有相关的SKU。三层嵌套也挡不住,比如再加一个items.sku.brand,继续往后点就行。
给预加载加筛选条件
有时候不需要把关联数据全部取出来。比如订单列表里只展示最近一条物流记录,或者只取未删除的商品明细。这时候直接在with里加闭包做条件过滤:
$orders = Order::with([
'user',
'items' => function ($query) {
$query->where('is_deleted', 0)->order('id', 'asc');
},
'items.sku',
'logistics' => function ($query) {
$query->order('created_at', 'desc')->limit(1);
}
])->limit(20)->select();
闭包里可以写任意查询条件,包括排序、限制条数、字段筛选。需要留意的是,limit在关联预加载中有些微妙——如果外层是20条订单,每个订单的物流记录都limit(1),框架会生成20条独立的SQL分别去取每个订单的最新物流。这是因为不同订单的limit条件无法合并成一条IN查询。如果这个查询次数仍然让你不安,可以考虑用后面提到的关联统计来替代部分场景。
还有一种常见需求是只取关联数据中的部分字段。用field方法限制即可,但务必把关联外键也包含进去,否则框架匹配不上数据集:
$orders = Order::with([
'user' => function ($query) {
$query->field(['id', 'nickname', 'avatar']);
},
'items' => function ($query) {
$query->field(['id', 'order_id', 'sku_id', 'quantity', 'price']);
}
])->select();
注意items的字段里必须包含order_id,user的字段里必须包含id——这些是关联建立对应关系的关键字段,漏了就会导致关联数据无法匹配到正确的父模型上。
关联统计:只查数量不查数据
列表页经常要展示”该订单包含几个商品”这种统计数字。如果为了拿一个计数去预加载整套明细数据,明明只需要一个数字,却把明细表的几十条记录全查出来,内存和IO都浪费了。ThinkPHP提供了专门的关联统计方法:
$orders = Order::withCount(['items', 'items as valid_items_count' => function ($query) {
$query->where('is_deleted', 0);
}])
->with(['user'])
->limit(20)
->select();
// 模板里直接用
foreach ($orders as $order) {
echo $order->items_count; // 总明细数
echo $order->valid_items_count; // 有效明细数
}
withCount不会去查明细表的所有字段,而是生成一条SELECT order_id, COUNT(*) FROM order_items GROUP BY order_id,用IN条件圈定当前页的订单ID范围。查询结果以{关联名}_count的属性名挂在每个订单模型上。用as可以自定义统计字段名,配合不同的筛选条件可以同时统计多个口径的数据。
除了withCount,还有withSum、withAvg、withMax、withMin这些聚合方法。比如统计每个订单的总金额:
$orders = Order::withSum('items', 'amount')
->limit(20)
->select();
foreach ($orders as $order) {
echo $order->items_sum_amount; // 订单总金额
}
这些聚合查询同样支持闭包条件过滤,语法和withCount完全一致。一个容易被忽略的细节是,聚合字段名会自动转换成蛇形命名:关联名items加sum加字段名amount,最终属性名就是items_sum_amount。如果关联名是驼峰的,建议用as显式指定别名避免混淆。
延迟预加载:事后补救
有些场景下,你在写查询的时候不确定后面要不要用到关联数据。比如一个方法里既可能返回精简列表(不需要关联),也可能返回详情(需要关联)。这时候可以用延迟预加载,在已经查出主数据之后,按需补上关联。
$orders = Order::where('status', 'paid')->limit(20)->select();
// 后来发现需要用户信息和明细
$orders->load(['user', 'items.sku']);
load方法会在已有的结果集上执行预加载,参数格式和with完全一样,也支持闭包条件。这次调用会让框架对着这20条订单去批量查用户和明细,执行完之后关联数据就挂上去了。和with的区别仅仅是一个事前声明、一个事后追加,底层生成的SQL完全一致。
还有一个实用的变体是loadCount,对应withCount的事后版本:
$orders->loadCount(['items', 'logistics']);
在列表页先查出订单主体,后续根据前端传参决定是否需要统计数据和关联信息,用load和loadCount按需追加,能有效减少不必要的数据库查询。
关联预加载在分页场景下的配合
列表接口一般都要分页,预加载和分页的配合有一个细节:预加载是在select返回的结果集上工作的,所以它只会预加载当前页的数据关联。如果你先paginate再对结果集调load,那预加载的范围就是当前这一页的数据,不会去碰其他页。这个行为是正确的,也是高效的做法。
$list = Order::where('status', 'paid')
->paginate(20);
// 对当前页的20条数据做预加载
$list->getCollection()->load(['user', 'items.sku']);
这里getCollection返回的是当前页的结果集,load只在这20条上生效。如果直接在paginate之前写with,效果相同但更简洁:
$list = Order::with(['user', 'items.sku'])
->where('status', 'paid')
->paginate(20);
两种写法在SQL执行上没有区别,选择哪种取决于你的代码组织方式。如果查询条件分散在多个方法里,load的写法更灵活。如果查询逻辑集中在一处,with更干净。
完整案例:电商订单列表接口优化
回到开头那个项目。优化前的订单列表接口大概长这样:
// 优化前:N+1地狱
public function list()
{
$orders = Order::where('buyer_id', $this->userId)
->order('created_at', 'desc')
->paginate(15);
return view('order_list', compact('orders'));
}
模板里遍历了用户昵称、订单明细、SKU名称、SKU图片、物流状态等,实际SQL执行次数在首次加载时达到了180次以上。优化之后:
// 优化后:逐步收紧查询
public function list()
{
$orders = Order::with([
// 买家信息,只取展示需要的字段
'buyer' => function ($query) {
$query->field(['id', 'nickname', 'avatar']);
},
// 商品明细 + SKU + 图片,三层嵌套预加载
'items' => function ($query) {
$query->where('is_deleted', 0)
->field(['id', 'order_id', 'sku_id', 'quantity', 'price', 'amount']);
},
'items.sku' => function ($query) {
$query->field(['id', 'name', 'image_url', 'spu_id']);
},
// 物流信息只取最新一条
'logistics' => function ($query) {
$query->order('updated_at', 'desc')->limit(1);
},
])
->withCount([
// 商品种类数
'items as item_kind_count' => function ($query) {
$query->where('is_deleted', 0);
},
// 物流记录总数
'logistics as logistics_count',
])
->withSum('items as total_amount', 'amount')
->where('buyer_id', $this->userId)
->order('created_at', 'desc')
->paginate(15);
return view('order_list', compact('orders'));
}
优化后,不管当前页有多少条订单,SQL执行次数稳定在6到8次之间(取决于关联层级和条件)。模板里的代码不需要任何改动,还是$order->buyer->nickname、$order->items、$item->sku->name那样用,但背后的查询已经从一条条单独查变成了批量IN查询,响应时间从两秒多降到了两百毫秒以内。
这里有一个容易被忽略的性能增益:field方法限制了返回字段。很多开发者习惯不写field,让框架返回SELECT *。但在订单明细表里,可能存着长文本备注、JSON格式的快照数据等大字段,预加载时如果不加限制,这些大字段会被全部拉进内存。只取模板渲染需要的字段,内存占用能减少一大截。
排查关联查询的实用工具
优化关联查询的前提是知道哪里出了问题。ThinkPHP自带的SQL日志功能可以帮上大忙。在config/database.php里开启调试模式后,可以用Db::getLastSql()逐条检查,但更高效的办法是监听DbQueryExecuted事件,把每次查询的SQL和执行时间记下来:
// 在事件服务提供者中注册
use thinkfacadeEvent;
use thinkfacadeLog;
Event::listen('thinkeventDbQueryExecuted', function ($event) {
Log::record("[SQL] {$event->sql} [耗时: {$event->time}ms]", 'sql');
});
然后请求一次列表接口,翻看日志文件,如果看到大量重复模式的查询,比如几十条SELECT * FROM users WHERE id = ?,那这里的关联预加载肯定没做好。定位到具体的关联名之后,回到模型定义或查询构建处,补上对应的with即可。
另外,如果用了with但SQL数量依然没有下降,检查一下是不是在闭包里写了limit或者某些无法合并的条件。这种情况下框架只能拆成单条查询,可以看看是否能用withCount或withSum替代部分场景,或者考虑在业务层面做妥协——比如物流信息不预加载最新一条,而是只统计条数,点击详情再单独请求。
几个容易翻车的点
关联字段别漏。 前面提过,用field限制字段时务必带上外键。漏了外键,关联数据查回来之后框架没法把它们分配给对应的父模型,结果就是关联属性永远为空,调试起来一头雾水。
预加载和JOIN不能混淆。 with生成的是独立的批量查询,不是SQL的JOIN。这两种策略各有优劣:JOIN把多表数据合在一条查询里,网络往返少,但结果集膨胀严重(主表一行变多行),且无法利用模型关联的缓存机制;with分多条查询,网络往返多几次,但每张表的数据独立、结果集紧凑,PHP端拼装效率更高。大多数场景下with是更安全的选择,除非你明确需要跨表条件筛选,那才考虑join配合hasWhere。
循环里的预加载陷阱。 如果你在一个循环里对单条记录调load,实际上退化成了一次一条查询,和没预加载没有区别。load必须作用在结果集(Collection)上才有批量效果。如果需要逐条处理又要预加载,先把所有记录查出来组成集合,一次性load,再循环处理。
多态关联的预加载。 ThinkPHP 8支持多态关联(morphTo),预加载多态关联时需要用星号通配符或者显式列出所有可能的关联类型。这部分语法相对复杂,而且在预加载时框架需要按不同类型分别执行查询,效果不如普通关联那么显著。如果多态关联的数据量很大,考虑单独优化对应的查询逻辑。
总结:一个检查清单
每次写完一个列表接口,可以对照下面几条快速自查:
- 模板里有没有在循环中访问关联属性?有的话,控制器里对应的
with声明了没有? - 嵌套关联是否全部展开了?
with(['a.b.c'])不仅预加载a,也预加载了a.b和a.b.c。 field限制了返回字段吗?外键有没有漏?- 只需要计数的场景,用
withCount或withSum替代了没有? - 日志里的SQL条数和数据量是否匹配?如果20条记录产生了超过10条SQL,大概率还有优化空间。
这五条过一遍,大部分关联性能问题都能在开发阶段被发现和解决,不用等到线上慢查询告警才手忙脚乱。模型关联用好了是利器,用不好就是性能杀手,差的往往只是查询构建时那几行with声明。

