引言
Eloquent 是 Laravel 的 ORM,但它绝不只是「把表映射成类」。真正的大型 Laravel 项目里,Eloquent 用得好坏直接决定接口响应时间——一个 N+1 查询就能让 API 从 20ms 崩到 2s。本文覆盖 Eloquent 的高级用法:关联模型、Eager Loading、分页、查询优化、事务与锁、以及常见的性能优化实战。
前置:/php-laravel-internals/(Laravel 内核)、/php-mysql-database/(PDO/数据库访问)。本专题聚焦 ORM 层的模型设计与查询优化。
目录
- 1. 关联模型:关系的本质
- 2. 常见关联类型:一对多、多对多、多态
- 3. Eager Loading:消灭 N+1 查询
- 4. 延迟加载与预加载策略选择
- 5. 分页与游标分页
- 6. 查询构建器优化与索引
- 7. 事务、锁与并发控制
- 8. Observers、Scopes 与 Accessors
- 9. 性能优化实战案例
- 10. 速查表与一句话记忆
- 延伸阅读
1. 关联模型:关系的本质
1.1 关联 = 外键约定
Eloquent 的关联基于约定优于配置:user 表的主键 → posts.user_id 外键,Eloquent 自动推断:
class User extends Model
{
public function posts()
{
return $this->hasMany(Post::class);
}
}
1.2 为什么不直接写 SQL 关联
- 返回的是模型集合,可链式调用/序列化
- 支持懒加载、预加载、条件过滤
- 关联方法是 Query Builder 的扩展,可继续加约束
记忆:Eloquent 关联基于外键约定(posts.user_id → users.id),返回模型集合而非裸行;关联方法本质是 Query Builder 扩展。
2. 常见关联类型:一对多、多对多、多态
2.1 一对多(hasMany / belongsTo)
// 一对多
public function posts() { return $this->hasMany(Post::class); }
// 反向
public function user() { return $this->belongsTo(User::class); }
$user->posts; // 该用户的全部文章
$post->user->name; // 文章的作者
2.2 多对多(belongsToMany)
需要中间表 role_user:
public function roles()
{
return $this->belongsToMany(Role::class)
->withPivot('assigned_at'); // 带中间表字段
}
$user->roles()->attach($roleId, ['assigned_at' => now()]);
2.3 多态(morphTo)
一条评论既属于文章也属于视频——用一个 commentable_type + commentable_id:
// Comment 模型
public function commentable() { return $this->morphTo(); }
// Post 模型
public function comments() { return $this->morphMany(Comment::class, 'commentable'); }
记忆:一对多用 hasMany/belongsTo,多对多用 belongsToMany+中间表,多态用 morphTo/morphMany 一张表服务多模型。
3. Eager Loading:消灭 N+1 查询
3.1 N+1 问题的本质
// 糟糕:100 篇文章 → 1 次查文章 + 100 次查作者 = 101 次查询
foreach (Post::all() as $post) {
echo $post->user->name;
}
3.2 用 with() 预加载
// 预加载:1 次查文章 + 1 次查作者 = 2 次查询
$posts = Post::with('user')->get();
3.3 嵌套与条件预加载
// 嵌套预加载
Post::with(['user.profile', 'comments'])->get();
// 预加载时过滤/排序
Post::with(['comments' => fn ($q) => $q->latest()->limit(5)])->get();
记忆:N+1 = 集合里每条记录都触发一次关联查询;用 with() 预加载一步合并成两条查询,集合型查询务必预加载。
4. 延迟加载与预加载策略选择
4.1 何时用延迟加载
- 关联不总是用到(只偶尔带出来)
- 单条记录的关联(
$post->user单次查询可接受)
4.2 决策表
| 场景 | 策略 |
|---|---|
| 列表页带关联 | 必预加载 with() |
| 详情页单条 | 延迟加载可接受 |
| 嵌套集合 | 链式 with() |
| 大数据量 | 分页 + with() 只带必要关联 |
4.3 调试利器
DB::enableQueryLog();
// ... 执行
dd(DB::getQueryLog()); // 看实际执行了几条 SQL
记忆:列表页集合型查询必预加载;单条详情可延迟加载;用 query log 确认真实查询条数。
5. 分页与游标分页
5.1 传统分页
$posts = Post::with('user')->paginate(20);
// 返回 items + 元数据,前端可直接渲染
5.2 游标分页(大数据量)
深翻页(?page=100000)时 offset 会越来越慢;游标分页按 ID 边界跳:
$posts = Post::orderBy('id')->cursorPaginate(20);
// 用 nextCursor / prevCursor 翻页,O(log n)
5.3 性能对比
| 方式 | 深翻页性能 | 适合 |
|---|---|---|
| 传统 page | 随 offset 变慢 | 中小数据 |
| 游标 cursor | 恒定快 | 大表/无限滚动 |
记忆:小表用 paginate、大表用 cursorPaginate(深翻页不退化);游标按主键边界跳,恒定 O(log n)。
6. 查询构建器优化与索引
6.1 只取需要的列
Post::with('user:id,name') // 只预加载 id 和 name
->select('id', 'title', 'user_id')
->where('status', 'published')
->get();
6.2 常见慢查询点
| 问题 | 解法 |
|---|---|
| where 无索引 | 建复合索引 |
| orderBy 慢 | 排序列加索引 |
| LIKE ‘%xx%’ | 全文索引/ES |
| count 大表 | 物化统计/缓存 |
6.3 用 EXPLAIN 验证
EXPLAIN SELECT * FROM posts WHERE user_id = 1 AND status = 'published' ORDER BY created_at DESC;
-- 关注 type 是否 index/ref、是否走索引
// 常见复合索引:user_id + status + created_at
Schema::table('posts', function ($table) {
$table->index(['user_id', 'status', 'created_at']);
});
记忆:select 只取所需列、预加载也限列;慢查询先 EXPLAIN 看索引,排序/过滤列建复合索引。
7. 事务、锁与并发控制
7.1 事务保证一致性
DB::transaction(function () {
$order = Order::create([...]);
$order->items()->createMany([...]);
$user->balance -= $order->total;
$user->save();
}, 3); // 最多重试 3 次
7.2 锁防止并发冲突
// 悲观锁:SELECT ... FOR UPDATE
DB::transaction(function () {
$account = Account::where('id', $id)->lockForUpdate()->first();
$account->balance -= $amount;
$account->save();
});
// 乐观锁:version 字段
$user->balance -= $amount;
$user->saveOrFail(); // 版本冲突抛异常
记忆:多步写操作包进 DB::transaction;并发扣款用 lockForUpdate 悲观锁或 version 乐观锁。
8. Observers、Scopes 与 Accessors
8.1 Observers:模型生命周期钩子
class PostObserver
{
public function created(Post $post) { /* 发通知、清缓存 */ }
public function updated(Post $post) { Cache::forget('post_'.$post->id); }
}
// 注册
protected static function booted() { static::observe(PostObserver::class); }
8.2 Scopes:复用查询条件
public function scopePublished($query) { return $query->where('status', 'published'); }
public function scopeRecent($query) { return $query->orderBy('created_at', 'desc'); }
Post::published()->recent()->get();
8.3 Accessors/Mutators:字段读写转换
public function getFullNameAttribute() { return $this->first . ' ' . $this->last; }
public function setPasswordAttribute($v) { $this->attributes['password'] = bcrypt($v); }
记忆:Observer 管生命周期副作用(清缓存/通知)、Scope 复用条件、Accessor 做读取时转换——把业务逻辑收敛进模型。
9. 性能优化实战案例
9.1 案例:文章列表从 2s 到 80ms
① 加 with():消灭 N+1(101 次查询 → 2 次)
② select 限列:只取渲染需要的字段
③ 分页:limit 20
④ 加复合索引:user_id+status+created_at
⑤ 结果缓存:Cache::remember('posts_page_1', 300, ...)
9.2 常见「隐形」性能杀手
| 反模式 | 后果 |
|---|---|
| 循环里查库 | N+1 |
| 无条件 get() 全量 | 内存/带宽爆炸 |
| 序列化含全量关联 | 响应巨大 |
| 每请求重复查热点 | 缓存缺失 |
记忆:优化顺序——先杀 N+1、再限列、再索引、最后缓存;热点查询务必 Cache::remember。
10. 速查表与一句话记忆
| 场景 | 做法 |
|---|---|
| 关联定义 | hasMany/belongsToMany/morphTo |
| 列表预加载 | with(‘user’) |
| 深翻页 | cursorPaginate |
| 慢查询 | EXPLAIN + 复合索引 |
| 多步写 | DB::transaction |
| 并发扣款 | lockForUpdate / 乐观锁 |
| 生命周期 | Observer |
| 复用条件 | Scope |
| 热点缓存 | Cache::remember |
一句话记忆:Eloquent 高级实战 = 关联用约定(hasMany/belongsToMany/morphTo)+ 列表必 with() 预加载消灭 N+1 + 大表用 cursorPaginate + 慢查询 EXPLAIN 加复合索引 + 多步写 DB::transaction、并发扣款 lockForUpdate + 生命周期用 Observer、条件复用 Scope + 热点 Cache::remember——从「能用 ORM」到「ORM 用得好」,拼的就是查询条数、索引与缓存。
延伸阅读
- /php-laravel-internals/ — Laravel 内核与服务容器
- /php-mysql-database/ — PDO/Eloquent 与 MySQL 优化
- /php-api-design-rest/ — API 层与模型序列化
- /php-testing-practice/ — 模型层测试
- /php-performance-tuning/ — 全栈性能调优
- [[database]] — 数据库设计与管理
- Laravel Eloquent 文档
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。