Eloquent 性能:N+1 查询问题的 4 个示例
发表于 作者: PovilasKorop
Eloquent 性能通常是 Laravel 项目速度慢的主要原因。其中很大一部分是所谓的“N+1 查询问题”。在这篇文章中,我将展示一些需要注意的示例,包括问题在代码中意外位置“隐藏”的情况。
什么是 N+1 查询问题
简而言之,就是 Laravel 代码执行了太多数据库查询。出现这种情况是因为 Eloquent 允许开发者使用模型编写易读的语法,而不必深入了解幕后发生的“魔法”。
这不仅是 Eloquent 甚至 Laravel 的问题:它是开发行业中众所周知的问题。为什么它被称为“N+1”?因为在 Eloquent 的情况下,它会从数据库中查询一行,然后为每个相关记录执行一次额外的查询。因此,N 次查询加上记录本身,总计 N+1 次。
要解决这个问题,我们需要预先查询相关记录,Eloquent 允许我们轻松地做到这一点,使用所谓的 预加载。但在我们了解解决方案之前,让我们讨论一下问题。我将向您展示 4 个不同的案例。
案例 1. “常规”N+1 查询。
这个例子可以直接从 Laravel 官方文档中获取
// app/Models/Book.php:class Book extends Model{ public function author() { return $this->belongsTo(Author::class); }} // Then, in some Controller:$books = Book::all(); foreach ($books as $book) { echo $book->author->name;}
这里发生了什么?$book->author
部分将为每本书执行一次额外的数据库查询,以获取其作者。
我创建了一个 小型演示项目 来模拟这种情况,并用其作者填充了 20 本虚假书籍。看看查询的数量。
如您所见,对于 20 本书,有 21 次查询,正好是 N+1,其中 N = 20。
是的,您猜对了:如果列表中有 100 本书,您将有 101 次查询到数据库。糟糕的性能,尽管代码看起来“无辜”,对吧。
解决方法是在控制器中使用预加载,在控制器中立即加载关系
// Instead of:$books = Book::all(); // You should do:$books = Book::with('author')->get();
结果好多了——只有 2 次查询
当您使用预加载时,Eloquent 会将所有记录都放到数组中,并向相关的数据库表发起一次查询,将来自该数组的那些 ID 传递过去。然后,每当您调用 $book->author
时,它都会从内存中已有的变量加载结果,无需再次查询数据库。
现在,等等,您可能想知道这个显示查询的工具是什么?
始终使用 Debugbar。并填充虚假数据。
此底部栏是一个软件包 Laravel Debugbar。您要使用它,只需安装它即可
composer require barryvdh/laravel-debugbar --dev
就这样,它将在所有页面上显示底部栏。您只需使用 .env
变量 APP_DEBUG=true
启用调试功能,这是本地环境的默认值。
安全提示:确保项目上线时,服务器上设置了 APP_DEBUG=false
,否则您的网站的普通用户将看到 debugbar 和您的数据库查询,这是一个巨大的安全问题。
当然,我建议您在所有项目中使用 Laravel Debugbar。但是,此工具本身在您拥有页面上更多数据之前,不会显示明显的问题。因此,使用 Debugbar 只是建议的一部分。
此外,我还建议您拥有 seeder 类来生成一些虚假数据。最好是大量数据,这样您就能看到项目在“实际生活中”的性能表现,假设项目在未来几个月或几年内成功发展。
使用 Factory 类,然后为书籍/作者和其他模型生成 10,000 多条记录
class BookSeeder extends Seeder{ public function run() { Book::factory(10000)->create(); }}
然后,浏览网站并查看 Debugbar 向您展示了什么。
除了 Laravel Debugbar 之外,还有其他选择
案例 2. 两个重要符号。
假设您在作者和书籍之间拥有相同的 hasMany 关系,并且需要列出作者,以及每个作者的书籍数量。
控制器代码可能是
public function index(){ $authors = Author::with('books')->get(); return view('authors.index', compact('authors'));}
然后,在 Blade 文件中,您对表格执行 foreach 循环
@foreach($authors as $author) <tr> <td>{{ $author->name }}</td> <td>{{ $author->books()->count() }}</td> </tr>@endforeach
看起来很合理,对吧?并且它可以正常工作。但是看看下面的 Debugbar 数据。
但是,等等,您可能会说我们正在使用预加载,Author::with('books')
,那么为什么会有这么多查询发生呢?
因为在 Blade 中,$author->books()->count()
实际上并没有从内存中加载该关系。
-
$author->books()
表示关系的 **方法** -
$author->books
表示预加载到内存中的 **数据**
因此,关系的方法将为每个作者查询数据库。但是,如果您加载数据,没有 ()
符号,它将成功地使用预加载的数据
因此,请注意您到底在使用什么——关系方法还是数据。
请注意,在这个特定示例中,还有一个更好的解决方案。如果您只需要关系的计算聚合数据,而不需要完整的模型,那么您应该只加载聚合,例如 withCount
// Controller:$authors = Author::withCount('books')->get(); // Blade:{{ $author->books_count }}
因此,将只执行一次数据库查询,甚至不是两次查询。而且内存也不会被关系数据“污染”,因此也节省了一些内存。
案例 3. 访问器中“隐藏”的关系。
让我们来看一个类似的示例:一个作者列表,以及作者是否活跃的列:“是”或“否”。活动是通过作者是否至少有一本书来定义的,它是 作为访问器 在 Author 模型中计算的。
控制器代码可能是
public function index(){ $authors = Author::all(); return view('authors.index', compact('authors'));}
Blade 文件
@foreach($authors as $author) <tr> <td>{{ $author->name }}</td> <td>{{ $author->is_active ? 'Yes' : 'No' }}</td> </tr>@endforeach
该“is_active”在 Eloquent 模型中定义
use Illuminate\Database\Eloquent\Casts\Attribute; class Author extends Model{ public function isActive(): Attribute { return Attribute::make( get: fn () => $this->books->count() > 0, ); }}
注意:这是 Laravel 访问器的新语法,在 Laravel 9 中采用。您也可以使用 “旧”语法 来定义 getIsActiveAttribute()
方法,它在最新的 Laravel 版本中也将起作用。
因此,我们拥有作者列表并已加载,再次看看 Debugbar 显示了什么
是的,我们可以通过在控制器中预加载书籍来解决这个问题。但在这种情况下,我的总体建议是 **避免在访问器中使用关系**。因为访问器通常用于显示数据,将来其他人可能会在其他 Blade 文件中使用此访问器,您将无法控制该控制器的样子。
换句话说,访问器应该是一种用于格式化数据的 **可重用** 方法,因此您无法控制何时/如何重用它。在您当前的情况下,您可能会避免 N+1 查询,但将来其他人可能不会考虑这个问题。
案例 4. 小心使用包。
Laravel 有一个很棒的包生态系统,但有时“盲目”地使用它们的特性很危险。如果你不小心,可能会遇到意想不到的 N+1 查询。
让我用一个非常流行的 spatie/laravel-medialibrary 包来举个例子。不要误会我的意思:这个包本身很棒,我不想把它当作包的缺陷来展示,而是想把它作为如何调试底层代码发生的事情的一个例子。
Laravel-medialibrary 包使用 多态关系 在“media”数据库表和你的模型之间。在本例中,它将是带有封面的书籍。
Book 模型
use Spatie\MediaLibrary\HasMedia;use Spatie\MediaLibrary\InteractsWithMedia; class Book extends Model implements HasMedia{ use HasFactory, InteractsWithMedia; // ...}
控制器代码
public function index(){ $books = Book::all(); return view('books.index', compact('books'));}
Blade 代码
@foreach($books as $book) <tr> <td> {{ $book->title }} </td> <td> <img src="{{ $book->getFirstMediaUrl() }}" /> </td> </tr>@endforeach
该 getFirstMediaUrl()
方法来自 包的官方文档.
现在,如果我们加载页面并查看 Debugbar...
20 本书,21 个数据库查询。再次精确地是 N+1。
所以,这个包在性能方面做得不好吗?不,因为官方文档说明了如何检索一个特定模型对象(一本书)的媒体文件,而不是检索列表。你需要自己找出列表部分。
如果我们深入挖掘,在包的 trait InteractsWithMedia
中,我们可以找到这个自动包含在所有模型中的关系。
public function media(): MorphMany{ return $this->morphMany(config('media-library.media_model'), 'model');}
所以,如果我们希望所有媒体文件都与书籍一起预加载,我们需要将 with()
添加到我们的控制器中。
// Instead of:$books = Book::all(); // You should do:$books = Book::with('media')->get();
这是可视化的结果,只有 2 个查询。
再次强调,这个例子不是为了说明这个包不好,而是为了建议你需要始终检查数据库查询,无论它们来自你的代码还是外部包。
针对 N+1 查询的内置解决方案
现在,在介绍完所有 4 个例子之后,我将给你最后一个提示:从 Laravel 8.43 开始,框架 有一个内置的 N+1 查询检测器!
除了 Laravel Debugbar 用于检查之外,你还可以添加代码来防止这个问题。
你需要将两行代码添加到 app/Providers/AppServiceProvider.php
中。
use Illuminate\Database\Eloquent\Model; class AppServiceProvider extends ServiceProvider{ public function boot() { Model::preventLazyLoading(! app()->isProduction()); }}
现在,如果你启动任何包含 N+1 查询问题的页面,你将看到一个错误页面,如下所示。
这将显示你可能想要修复和优化的确切“危险”代码。
请注意,此代码应仅在你的本地机器或测试/暂存服务器上执行,生产服务器上的实时用户不应该看到此消息,因为这将是一个安全问题。这就是为什么你需要添加一个像 ! app()->isProduction()
这样的条件,这意味着你的 .env
文件中的 APP_ENV
值不是“production”。
有趣的是,当我尝试使用媒体库的最后一个例子时,这种预防措施对我无效。我不确定是由于它来自外部包,还是由于多态关系。所以,我最终的建议仍然是:使用 Laravel Debugbar 来监控查询数量并进行相应的优化。
你可以在 GitHub 上的免费仓库 中找到所有 4 个例子,并随意尝试它们。
祝你的项目拥有出色的速度性能!