雄辩的性能:Laravel N+1 查询问题的 4 个示例

Laravel

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);
    }
}
// 然后,在某些控制器中:
$books = Book::all();
foreach ($books as $book) {
    echo $book->author->name;
}

这里发生了什么? $book->author 部分将对每本书执行一个额外的 DB 查询,以获取其作者。

我已经创建了一个 小型演示项目 来模拟这个过程,并向每个作者分发了20本随机的书。看看查询的数量。

N+1 Query Example 1 - No Eager Loading

如你所见, 20 本书,有 21 个查询,正好是 N+1,其中 N = 20。

是的,你没看错:如果列表上有 100 本书,那么你将有 101 次对数据库的查询。 糟糕的表现,虽然代码看起来「无辜」。

修复方法是在 Controller 中立即加载关系,并使用我之前提到的预加载:

// 代替:
$books = Book::all();
// 应该这样做:
$books = Book::with('author')->get();

优化后只有 2 个查询:

N+1 Query Example 1 - Eager Loading

当您使用预加载时,Eloquent 将所有记录放入数组中,并对相关的 DB 表发起 ONE 查询,并从该数组中传递这些 ID。 然后,每当你调用$book->author时,它都会从已经在内存中的变量加载结果,无需再次查询数据库。

现在,等等,你想知道这个显示查询的工具是什么?

使用调试栏,并使用数据填充。

这个底部栏是 Laravel Debugbar

使用它所需要做的就是安装它:

composer require barryvdh/laravel-debugbar --dev

就是这样,它将在所有页面上显示底栏。 你只需要使用 .env 变量 APP_DEBUG=true 启用调试,这是本地环境的默认值。

安全注意事项:确保当你的项目上线时,你在该服务器上配置了 APP_DEBUG=false,否则网站的普通用户将看到调试栏和数据库查询,这是一个巨大的安全问题。

当然,我建议你在所有项目中都使用 Laravel Debugbar。 但是,除非在页面上有更多数据,否则此工具本身不会显示明显的问题。 因此,使用 Debugbar 只是建议的一部分。

此外,我还建议使用一些虚假数据的填充类。 最好是大量数据,因此如果你想象项目在未来几个月或几年内成功增长,你会看到你的项目在「现实生活中」的表现如何。

使用工厂类,然后为书籍/作者和其他模型生成 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 数据。

N+1 Query Example 2 - Bad Performance

但是等等,你会说我们正在使用预加载,Author::with('books'),那么为什么会有这么多查询发生呢?

因为,在 Blade 中,$author->books()->count() 实际上并没有从内存中加载这种关系。

  • $author->books() 表示关系的方法
  • $author->books 表示预加载到内存中数据

因此,关系方法将为每个作者查询数据库。 但是如果你加载数据,没有()符号,它会成功地使用预先加载的数据:

N+1 Query Example 2 - Good Performance

因此,请注意你到底在使用什么—— 关系方法数据

请注意,在这个特定示例中,有一个更好的解决方案。 如果你只需要关系的计算聚合数据,而不需要完整模型,那么可以加载聚合,例如 withCount:

// 控制器:
$authors = Author::withCount('books')->get();
// Blade 模板:
{{ $author->books_count }}

N+1 Query Example 2 - Best Performance

结果,对数据库的查询只有一个,甚至没有两个查询。 而且内存不会被关系数据「污染」,因此也节省了一些RAM。


案例 3:访问器中的「隐藏」关系。

让我们举一个类似的例子:作者列表,作者是否活跃的列:「是」或「否」。由作者是否至少拥有一本书来定义,它是作为 访问器 在作者模型中。

控制器代码:

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

Eloquent 模型中定义了「is_active」:

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 显示的内容:

N+1 Query Example 3 - Bad Performance

是的,我们可以通过在 Controller 中预先加载书籍来解决它。 但在这种情况下,我的总体建议是 避免在访问器中使用关系。 因为在显示数据时通常会使用访问器,并且将来,其他人可能会在其他 Blade 文件中使用此访问器,而你将无法控制控制器的代码格式。

换句话说,Accessor 应该是一种用于格式化数据的 可重用 方法,因此你无法控制何时/如何重用它。 在当前的情况下,你可能会避免 N+1 查询,但将来,其他人可能不会考虑它。


案例 4: 小心扩展包。

Laravel 有一个很棒的软件包生态系统,但有时「盲目地」使用它们的功能是很危险的。 如果不小心,你可能会遇到意外的 N+1 查询。

让我来展示一个非常流行的 spatie/laravel-medialibrary 包的示例。不要误会我的意思:包本身很棒,我不想将它显示为包中的缺陷,而是作为一个例子来说明调试底层发生的事情是多么重要。

Laravel-medialibrary 包在 “media” DB 表和模型之间使用 多态关系。在我们的例子中,将是与封面一起列出的书籍。

Book 模型:

use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Book extends Model implements HasMedia
{
    use HasFactory, InteractsWithMedia;
    // ...
}

Controller 代码:

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() 方法来自 软件包的官方文档

现在,如果我们加载页面并查看调试栏…

N+1 查询示例 4 - 性能不佳

20 本书,21 次数据库查询。 又是 N+1。

那么,这个包在性能方面做得不好? 不,因为官方文档正在告诉如何检索一个特定模型对象的媒体文件,一本书,而不是列表。 你需要自己弄清楚该列表部分。

如果我们再深入一点,在包的 trait InteractsWithMedia 中,我们会发现这种关系自动包含在所有模型中:

public function media(): MorphMany
{
    return $this->morphMany(config('media-library.media_model'), 'model');
}

因此,如果我们希望所有的媒体文件都与书籍一起预先加载,我们需要将with() 添加到我们的控制器中:

// 代替:
$books = Book::all();
// 应该这样做:
$books = Book::with('media')->get();

只有 2 个查询。

N+1 Query Example 4 - Good Performance

同样,这不是将这个包显示为坏包的示例,而是建议你始终检查数据库查询,无论它们来自你的代码还是外部包。


针对 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 查询问题的页面,您将看到一个错误页面,如下所示:

N+1 查询 - 防止懒加载

这将向您展示您可能想要修复和优化的确切「危险」代码。

请注意,此代码应仅在您的本地计算机或 测试/临时 服务器上执行,生产服务器上的实时用户不应看到此信息,因为这是一个安全问题。这就是为什么你需要添加一个条件,如 ! app()->isProduction(),这意味着您在 .env 文件中 APP_ENV 的值不是 「production」。

有趣的是,当我尝试使用媒体库的最后一个示例时,这种预防对我不起作用。不确定是因为它来自外部包,还是因为多态关系。所以,我的最终建议仍然有效: 使用 Laravel Debugbar 来监控查询的数量并进行相应的优化。

您可以在 Github 免费仓库 中找到所有 4 个示例,并尝试使用它们。

祝您在项目中拥有出色的速度表现!

本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://laravel-news.com/laravel-n1-quer...

译文地址:https://learnku.com/laravel/t/68247

本文为协同翻译文章,如您发现瑕疵请点击「改进」按钮提交优化建议
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 4
fatrbaby

标题的Eloquent直译为“雄辩”我觉得不错,有点双关的意思。

1年前 评论
诺墨 (楼主) 1年前

我觉得标题不如叫 ORM 性能优化)

1年前 评论
MArtian 1年前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!