Eloquent ORM 高级实战:关联、加载策略与性能优化

Eloquent ORM 高级实战:模型关联(一对多/多对多/多态)、Eager Loading 与 N+1 问题、分页与游标分页、查询构建器优化、索引与 EXPLAIN、缓存策略、事务与锁、Observers/Scopes/Accessors 设计、性能优化实战案例。

引言

Eloquent 是 Laravel 的 ORM,但它绝不只是「把表映射成类」。真正的大型 Laravel 项目里,Eloquent 用得好坏直接决定接口响应时间——一个 N+1 查询就能让 API 从 20ms 崩到 2s。本文覆盖 Eloquent 的高级用法:关联模型、Eager Loading、分页、查询优化、事务与锁、以及常见的性能优化实战。

前置:/php-laravel-internals/(Laravel 内核)、/php-mysql-database/(PDO/数据库访问)。本专题聚焦 ORM 层的模型设计与查询优化。


目录


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 文档

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「php」更多文章

  1. PHP 面向对象与设计模式:SOLID、常用模式与 Laravel 实践
  2. PHP 静态分析与代码质量:PHPStan、Psalm、Rector 与 CI 门禁
  3. PHP 部署运维实战:Nginx、PHP-FPM、Docker 与 CI/CD