Eloquent 性能:N+1 查询问题的 4 个示例

发表于 作者:

Eloquent Performance: 4 Examples of N+1 Query Problems image

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 个例子,并随意尝试它们。

祝你的项目拥有出色的速度性能!

PovilasKorop photo

Laravel Daily 的课程和教程创建者 Laravel Daily

Cube

Laravel 新闻

加入 40,000 多名其他开发者,绝不错过任何新提示、教程等。

Laravel Forge logo

Laravel Forge

轻松创建和管理你的服务器,并在几秒钟内部署你的 Laravel 应用程序。

Laravel Forge
Tinkerwell logo

Tinkerwell

Laravel 开发人员必备的代码运行器。使用 AI、自动完成和对本地和生产环境的即时反馈进行调试。

Tinkerwell
No Compromises logo

无妥协

Joel 和 Aaron,来自 No Compromises 播客的两位经验丰富的开发人员,现在可以为你的 Laravel 项目提供服务。 ⬧ 固定价格 7500 美元/月。 ⬧ 无需漫长的销售流程。 ⬧ 无需合同。 ⬧ 100% 返款保证。

无妥协
Kirschbaum logo

Kirschbaum

提供创新和稳定性,确保你的 Web 应用程序取得成功。

Kirschbaum
Shift logo

Shift

运行的是旧版 Laravel?即时、自动化的 Laravel 升级和代码现代化,让你的应用程序保持新鲜感。

Shift
Bacancy logo

Bacancy

只需 2500 美元/月,就能获得一位经验丰富的 Laravel 开发人员(4-6 年经验),为你的项目增光添彩。获得 160 小时的专业知识和 15 天的免费试用期。立即预约电话!

Bacancy
Lucky Media logo

Lucky Media

现在就来体验 Lucky - Laravel 开发的理想之选,拥有十多年的经验!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar:Laravel 电子商务

Laravel 的电子商务。一个开源包,将现代无头电子商务功能的力量带入 Laravel。

Lunar:Laravel 电子商务
LaraJobs logo

LaraJobs

官方 Laravel 职位板

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit:Laravel SaaS 启动工具包

SaaSykit 是一个 Laravel SaaS 启动工具包,包含运行现代 SaaS 所需的所有功能。支付、精美结账、管理面板、用户仪表板、身份验证、即用型组件、统计、博客、文档等。

SaaSykit:Laravel SaaS 启动工具包
Rector logo

Rector

你无缝升级 Laravel、降低成本和加速创新,以确保公司成功的合作伙伴

Rector
MongoDB logo

MongoDB

通过 MongoDB 和 Laravel 的强大集成来增强你的 PHP 应用程序,使开发人员能够轻松高效地构建应用程序。在使用熟悉的 Eloquent API 的同时,支持事务、搜索、分析和移动用例。了解灵活、现代的 MongoDB 数据库如何改变你的 Laravel 应用程序。

MongoDB
Maska is a Simple Zero-dependency Input Mask Library image

Maska 是一个简单的零依赖输入遮罩库

阅读文章
Add Swagger UI to Your Laravel Application image

在你的 Laravel 应用程序中添加 Swagger UI

阅读文章
Assert the Exact JSON Structure of a Response in Laravel 11.19 image

在 Laravel 11.19 中断言响应的精确 JSON 结构

阅读文章
Build SSH Apps with PHP and Laravel Prompts image

使用 PHP 和 Laravel 提示构建 SSH 应用程序

阅读文章
Building fast, fuzzy site search with Laravel and Typesense image

使用 Laravel 和 Typesense 构建快速、模糊的网站搜索

阅读文章
Add Comments to your Laravel Application with the Commenter Package image

使用 Commenter 包在你的 Laravel 应用程序中添加评论

阅读文章