4.3 关联关系 模型能单打独斗了,本节让模型牵手:一对一、一对多、多对多怎么定义、怎么用、怎么避免 N+1 查询这个经典性能陷阱。关联是 ORM 提供的最大抽象红利——用对象图代替手写 JOIN,业务代码的可读性完全不同。读完本节,你应当能把书店项目全部的表关系声明清楚,并能一眼认出代码里的 N+1 现象。 一、三种关系:先画图再写代码 书店项目的核心关系固定为三种形态:用户对书籍是一对多(一个卖家挂多本书);书籍对详情是一对一(每本书一份扩展描述);订单对书籍是多对多(一单含多本,一本书出现在多单,中间靠订单明细表连接)。
模型能单打独斗了,本节让模型牵手:一对一、一对多、多对多怎么定义、怎么用、怎么避免 N+1 查询这个经典性能陷阱。关联是 ORM 提供的最大抽象红利——用对象图代替手写 JOIN,业务代码的可读性完全不同。读完本节,你应当能把书店项目全部的表关系声明清楚,并能一眼认出代码里的 N+1 现象。
书店项目的核心关系固定为三种形态:用户对书籍是一对多(一个卖家挂多本书);书籍对详情是一对一(每本书一份扩展描述);订单对书籍是多对多(一单含多本,一本书出现在多单,中间靠订单明细表连接)。定义都写在模型方法里:
// app/model/User.php:一对多 hasMany class User extends Model { public function books() { return $this->hasMany(Book::class, 'seller_id', 'id'); } } // app/model/Book.php:一对一 hasOne + 反向 belongsTo class Book extends Model { public function detail() { return $this->hasOne(BookDetail::class, 'book_id', 'id'); } public function seller() { return $this->belongsTo(User::class, 'seller_id', 'id'); } } // app/model/Order.php:多对多 belongsToMany(经明细表) class Order extends Model { public function books() { return $this->belongsToMany(Book::class, 'order_item', 'book_id', 'order_id'); } }
三个方法的参数记忆法:第二个参数都是「外键在对面表还是本表」的问题——hasMany 的外键在子表、belongsTo 的外键在本表、belongsToMany 的两个外键都在中间表。拿不准时回表结构数外键,永远比背参数顺序可靠。

定义之后,关联像属性一样自然取用:
// 一对多:卖家与他的书 $user = User::find(7); $books = $user->books; // Book 模型集合 foreach ($books as $book) { echo $book->title; } // 反向:书找到卖家 $book = Book::find(42); echo $book->seller->nickname; // 一对一:书与详情 echo $book->detail->summary; // 多对多:订单与书 $order = Order::find(1001); foreach ($order->books as $b) { echo $b->pivot->num; // pivot 是中间表行,数量在明细里 }
关联直接取用有一个隐性成本,就是臭名昭著的 N+1:
// 危险写法:取 20 本书,循环里逐本查卖家 $list = Book::limit(20)->select(); foreach ($list as $book) { echo $book->seller->nickname; // 每本书触发一条 SELECT user } // 实际 SQL:1 条查书 + 20 条查用户 = 21 条
列表页、接口集合输出几乎必然踩到它。解法是预载入——一条 JOIN 或 IN 把关联一并取回:
// 修正:with 预载入,全程 2 条 SQL $list = Book::with(['seller'])->limit(20)->select(); foreach ($list as $book) { echo $book->seller->nickname; // 不再发新查询 } // 嵌套与条件化预载入 $orders = Order::with(['books' => function ($q) { $q->where('status', 1)->with(['detail']); }])->limit(10)->select();
用 2.2 装的「速度探针」观察改造前后的查询条数,反差非常直观。团队可以把「集合循环里取关联」列入代码评审红线——出现即要求补 with。
背景:订单列表要展示每单的书名与卖家昵称,当前页面查询数随订单数线性增长。操作:先开 trigger_sql 记录现状查询条数;把关联声明补齐(Order belongsToMany books、Book belongsTo seller);用嵌套 with 重写查询;对比改造前后 SQL 条数与页面耗时。结果示例:改造前几十条查询,改造后三条左右,页面耗时降到零头。解读:注意嵌套预载入里 where 的位置——条件加在子查询上而非主查询上,位置错了语义完全不同。变式:给「我卖出的书」页面反向做一遍,体会 belongsTo 与 hasMany 在同一份表结构上的两个视角。
⚠️ 常见坑:预载入不是越多越好——with 把关联全量取回,列表页明明只展示昵称却把用户整行拖回来是浪费;大表关联配合 field 限定字段再预载入。
💡 关键直觉:关联的价值在「用对象图表达查询意图」,性能账单要靠预载入自己付——ORM 不会替你省 SQL,只会替你写 SQL。
读路径的预载入之外,关联在写路径上还有一个高频现场:一次业务动作要同时落两张关联表——下单要建订单再建明细,发帖要建帖子再同步标签绑定。用关联的写法能把两步收进模型层:订单模型的关联里追加明细,外键由框架按关联定义自动带上,调用方只见「给这个订单加两条明细」一句话。要点有两条:外键字段交给关联定义维护,调用方不要手工填外键(填错即脏数据);成组的关联写入与 4.4 的事务天然相邻——两张表的写入要同成败,包在同一事务里再动手。
对象网织好了,还差并发秩序:多个用户同时改同一行时,谁来维持一致?下一节事务与锁。