在 Laravel 里操作 MongoDB 时,我们常遇到这样的需求:父模型(例如订单 Order)拥有多个子模型(例如商品 Item),需要根据子模型某个字段的总和来筛选父模型。传统关系型数据库的 SQL 可以通过子查询或 join 加 group by 解决,而 MongoDB 作为文档数据库,更推荐使用聚合管道(aggregate pipeline)在数据库层完成关联与计算。

一、数据模型与关联关系
假设我们有两个集合:orders 和 items。orders 文档保存订单基础信息,items 文档通过 order_id 关联订单,并带有 amount 字段表示金额。在 Laravel 中,使用 jenssegers/laravel-mongodb 扩展定义模型如下。
<?php
namespace AppModels;
use JenssegersMongodbEloquentModel;
class Order extends Model
{
protected $connection = 'mongodb';
protected $collection = 'orders';
public function items()
{
return $this->hasMany(Item::class, 'order_id', '_id');
}
}
class Item extends Model
{
protected $connection = 'mongodb';
protected $collection = 'items';
}
上面的代码建立了 Order 与 Item 的一对多关联。注意 MongoDB 的主键默认是 _id,关联字段我们选用 order_id 存储订单的主键值。这种结构下,子文档可以放在独立集合中,便于水平扩展。
如果直接在 Laravel 里用 Eloquent 取数据,很多人会写出类似 Order::with('items')->get() 再遍历求和的代码。这种方式会把所有订单及关联商品拉到内存,数据量大时极易超时或内存溢出,并不是合理的查询方案。
二、MongoDB 聚合管道思路
MongoDB 的聚合管道可以依次执行多个阶段。针对我们的场景,核心步骤如下:先用 $lookup 把 items 关联到 orders;再用 $addFields 或 $project 结合 $sum 计算子表 amount 总和;最后用 $match 筛选总和满足条件的父文档。
在 mongo shell 中,对应的聚合语句大致如下:
db.orders.aggregate([
{
$lookup: {
from: 'items',
localField: '_id',
foreignField: 'order_id',
as: 'item_list'
}
},
{
$addFields: {
total_amount: { $sum: '$item_list.amount' }
}
},
{
$match: {
total_amount: { $gt: 100 }
}
}
]);
这段管道首先在 orders 每条记录上挂载一个 item_list 数组,然后通过 $sum 对这个数组里的 amount 字段求和,生成 total_amount 字段,最后只保留 total_amount 大于 100 的订单。整个过程由 MongoDB 引擎在数据库内完成,不需要把原始子文档全部传到应用层。
需要提醒的是,$lookup 之后 item_list 是一个数组,如果子表数据极多,关联后的文档体积会变大。生产环境应确认索引情况,并在 items 表的 order_id 上建立索引以加速关联。
三、在 Laravel 中编写聚合查询
jenssegers/laravel-mongodb 提供了 raw 方法来执行原生聚合,也支持通过 MongoDB 驱动直接构造管道。下面示例演示如何在 Order 模型中封装一个作用域,用于筛选总金额大于指定值的订单。
<?php
use JenssegersMongodbEloquentModel;
use MongoDBBSONObjectId;
class Order extends Model
{
protected $connection = 'mongodb';
protected $collection = 'orders';
public function scopeTotalGt($query, $threshold)
{
$pipeline = [
[
'$lookup' => [
'from' => 'items',
'localField' => '_id',
'foreignField' => 'order_id',
'as' => 'item_list'
]
],
[
'$addFields' => [
'total_amount' => ['$sum' => '$item_list.amount']
]
],
[
'$match' => [
'total_amount' => ['$gt' => $threshold]
]
]
];
return $query->raw(function ($collection) use ($pipeline) {
return $collection->aggregate($pipeline);
});
}
}
在控制器中调用时,可以这样使用:
$orders = Order::totalGt(100)->get();
foreach ($orders as $order) {
// 此时 $order 是 Order 模型实例
// 如需访问计算字段可读取 $order->total_amount
echo $order->_id . ' 总金额:' . $order->total_amount . PHP_EOL;
}
这里用到了 Eloquent 的本地作用域(scope 开头的方法),让查询语义更清晰。raw 方法接收一个闭包,闭包参数是 MongoDB 的 Collection 对象,我们可以直接调用其 aggregate 方法并传入管道数组。
如果希望结果仍是标准 Order 模型且能继续使用模型关系,可以只对查出后的 _id 列表再做一次 whereIn 查询,但那样会增加一次数据库往返。大多数情况下,直接在聚合结果上映射为模型已能满足业务需要。
四、性能对比与注意事项
为了直观理解两种写法的差异,我们用一张简表对比常见的两种方案:
| 方案 | 数据拉取方式 | 内存占用 | 适用规模 |
|---|---|---|---|
| 先取父模型再遍历 sum | 应用层加载全部记录 | 高 | 小数据量 |
| 聚合管道下推计算 | 数据库内完成关联与过滤 | 低 | 大数据量 |
从表中可以看出,聚合管道把过滤逻辑交给 MongoDB,避免了无谓的数据传输。在百万级订单和千万级商品场景下,后者通常能将响应时间从几十秒降到毫秒级。
实际落地时,还应关注索引与字段类型。items.order_id 必须是能直接匹配 orders._id 的类型,若 orders._id 是 ObjectId,则写入 items 时也应存 ObjectId 而非字符串。另外,如果仅需父模型字段,可在管道末尾加 $project 剔除 item_list,减少返回文档大小。
五、小结
在 Laravel 配合 MongoDB 的开发中,根据子模型字段总和查询父模型并不复杂,关键是放弃在应用层循环求和的习惯,转而使用 $lookup 加 $sum 加 $match 的聚合管道。通过 jenssegers/laravel-mongodb 的 raw 方法,我们可以把原生管道无缝嵌入 Eloquent,既保留框架便利性,又获得数据库层的计算能力。
当业务规则变为按平均值、最大值或其他表达式筛选时,只需调整 $addFields 中的表达式与 $match 条件,整体结构保持不变。掌握这种写法,可以有效解决一对多关联下的统计型查询需求。