在劳务分包管理系统里,有一个非常常见的业务需求:创建或编辑订单时,需要选择分包商。选择器的逻辑是列出某个分类下的所有分包商,但要剔除掉那些已经被当前订单绑定的分包商,避免重复添加。听起来简单,但拆开数据表结构就会发现,这背后涉及五张表:分类表、分包商表、分包商与分类的关联表、订单表以及订单与分包商的关联表。稍有处理不当,查询结果就会出现重复行或者漏数据。

假设我们的表结构如下:categories 是分类表,subcontractors 是分包商表,category_subcontractor 是两者多对多的中间表,orders 是订单表,order_subcontractor 记录订单与分包商的绑定关系。目标很明确:传入分类ID和订单ID,返回该分类下未被这个订单使用的分包商列表。下面我们逐一分析几种实现思路,并给出推荐方案。
为什么直接join会产生重复数据
最容易想到的写法是把分包商表通过中间表关联分类,再通过订单中间表关联订单,然后在where条件里筛选。问题在于,一个分包商可以属于多个分类,也可以被多个订单引用。当你用innerJoin把 category_subcontractor 和 order_subcontractor 都连进来时,每条关联记录都会让结果集膨胀一行。比如分包商A同时挂在两个分类下,即使你限定了其中一个分类ID,如果它的订单关联记录有多条,结果里A照样会出现多行。
有人会想到加 distinct 去重,这在数据量小的时候确实能解决问题。但distinct的去重是在结果集已经膨胀之后进行的,MySQL需要额外的临时表来完成去重操作,数据量大时性能损耗明显。更重要的是,如果业务上要求展示分包商的排序权重、关联时间等字段,distinct之后这些字段取哪一行的值是不确定的,容易埋下隐患。
正确的思路应该是转换问题:不是join出所有数据再过滤,而是问一句这个分包商在当前订单的关联表里是否存在记录。存在就排除,不存在就保留。这种反向思维正是子查询方案的出发点。
三种实现方案对比:leftJoin、whereIn与whereNotExists
方案一是leftJoin加null判断。思路是把 order_subcontractor 左连进来,条件限定订单ID,然后要求 order_subcontractor.id 为null。左连接找不到匹配时会补null,所以id为null就意味着该分包商没有被这个订单使用。写法大致如下:
$subcontractors = DB::table('subcontractors as s')
->join('category_subcontractor as cs', 'cs.subcontractor_id', '=', 's.id')
->leftJoin('order_subcontractor as os', function ($join) use ($orderId) {
$join->on('os.subcontractor_id', '=', 's.id')
->where('os.order_id', '=', $orderId);
})
->where('cs.category_id', '=', $categoryId)
->whereNull('os.id')
->select('s.*')
->distinct()
->get();这个方案功能上是对的,但依然需要distinct,因为分类中间表可能让分包商出现多行。方案二是whereIn加notWhereIn,先查出该订单已使用的分包商ID集合,再用 whereNotIn 排除:
$usedIds = DB::table('order_subcontractor')
->where('order_id', $orderId)
->pluck('subcontractor_id');
$subcontractors = DB::table('subcontractors as s')
->join('category_subcontractor as cs', 'cs.subcontractor_id', '=', 's.id')
->where('cs.category_id', $categoryId)
->whereNotIn('s.id', $usedIds)
->select('s.*')
->distinct()
->get();这个方案逻辑清晰,但有两个隐患:一是两次数据库交互,二是 whereNotIn 在排除集合很大时会生成很长的IN列表,MySQL解析和优化都比较吃力。更推荐的是方案三,whereNotExists。它把存在性判断交给数据库在执行计划里处理,每次判断走索引,不产生中间结果集:
$subcontractors = DB::table('subcontractors as s')
->join('category_subcontractor as cs', 'cs.subcontractor_id', '=', 's.id')
->where('cs.category_id', $categoryId)
->whereNotExists(function ($query) use ($orderId) {
$query->select(DB::raw(1))
->from('order_subcontractor as os')
->whereColumn('os.subcontractor_id', 's.id')
->where('os.order_id', $orderId);
})
->select('s.*')
->groupBy('s.id')
->get();三种方案的取舍可以总结为一张表:
| 方案 | 优点 | 缺点 |
|---|---|---|
| leftJoin + whereNull | 单次查询,逻辑直观 | 依赖distinct去重,大表连接开销大 |
| whereNotIn | 代码最简单,易调试 | 两次查询,排除集大时IN列表过长 |
| whereNotExists | 走索引存在性判断,性能最优 | 写法稍复杂,需理解相关子查询 |
需要说明的是,由于分类中间表仍可能让同一分包商出现多行,方案三里加了个 groupBy。如果业务上能保证 category_subcontractor 表对subcontractor_id和category_id做了联合唯一索引,这行groupBy可以去掉,查询会更轻。
用Eloquent关联模型让代码更优雅
如果项目使用Eloquent,可以先把关联关系定义好,查询代码会可读很多。分包商模型中定义多对多分类关联和订单多对多关联:
class Subcontractor extends Model
{
public function categories()
{
return $this->belongsToMany(Category::class, 'category_subcontractor');
}
public function orders()
{
return $this->belongsToMany(Order::class, 'order_subcontractor');
}
}然后在查询时用 whereDoesntHave 表达排除语义,整体代码几乎就是业务描述的直译:
$subcontractors = Subcontractor::query()
->whereHas('categories', function ($q) use ($categoryId) {
$q->where('categories.id', $categoryId);
})
->whereDoesntHave('orders', function ($q) use ($orderId) {
$q->where('orders.id', $orderId);
})
->orderBy('name')
->get();Laravel在底层会把 whereDoesntHave 编译成not exists的相关子查询,与我们手写的方案三在SQL层面基本等价。这种方式的优势在于语义清晰、复用性强,如果选择器还需要叠加状态筛选、关键字搜索等条件,直接链式追加即可。
最后是性能层面必须注意的索引问题。category_subcontractor 表要建 category_id 与 subcontractor_id 的联合唯一索引,既保证数据一致性又加速join;order_subcontractor 表要对 order_id 与 subcontractor_id 建联合唯一索引,子查询按订单ID过滤时可以直接命中。上线前建议用 toSql 或 DB::enableQueryLog 检查生成的SQL,再用explain确认执行计划,确保没有全表扫描。经过这些优化后,即便分包商表有几十万条数据,分类选择器的响应也能稳定在几十毫秒以内。
Laravel多表关联分包商查询whereNotExists去重修改时间:2026-09-11 23:01:53