什麼是 N+1 問題,以及如何解決 Laravel 的 N+1 問題?

文章轉發自專業的Laravel開發者社區,原始連接: https://learnku.com/laravel/t...

對象關係映射(ORM)使得處理數據驚人地簡單。因爲以面向對象的方式定義數據之間關係使得查詢關聯模型數據變得容易,開發者不太須要關注數據底層調用。php

ORM 的標準數據優化是渴望式加載相關數據。咱們將創建一些示例關係,而後逐步瞭解查詢隨着渴望式加載和非渴望式加載變化。我喜歡直接使用代碼來試驗一些東西,並經過一些示例來講明渴望式加載是如何工做的,這將進一步幫助你理解如何優化查詢。html

介紹

在基本級別,ORM 是 「懶惰」 加載相關的模型數據。可是,ORM 應該如何知道你的意圖?在查詢模型後,您可能永遠不會真正使用相關模型的數據。不優化查詢被稱爲 「N + 1」 問題。當您使用對象來表示查詢時,您可能在不知情的狀況下進行查詢。laravel

想象一下,您收到了100個來自數據庫的對象,而且每條記錄都有1個關聯的模型(即belongsTo)。使用ORM默認會產生101條查詢; 對原始100條記錄 進行一次查詢,若是訪問了模型對象上的相關數據,則對每條記錄進行附加查詢。在僞代碼中,假設您要列出全部已發佈帖子的發佈做者。從一組帖子(每一個帖子有一位做者),您能夠獲得一個做者姓名列表,以下所示:web

$posts = Post::published()->get(); // 一次查詢

$authors = array_map(function($post) {
    // 生成對做者模型的查詢
    return $post->author->name;
}, $posts);

咱們並無告訴模型咱們須要全部做者,所以每次從各個Post 模型實例中獲取做者姓名時都會發生單獨的查詢 。sql

預加載

正如我所提到的,ORM 是 "懶惰" 加載關聯。若是您打算使用關聯的模型數據,則可使用預加載將 101 次查詢縮減爲 2 次查詢。您只須要告訴模型你渴望它加載什麼。數據庫

如下是使用預加載的 Rails Active Record guide 中的示例.。正如您所看到的,這個概念與 Laravel's eager loading 概念很是類似。數組

# Rails
posts = Post.includes(:author).limit(100)

# Laravel
$posts = Post::with('author')->limit(100)->get();

經過從更廣闊的視角探索,我發現我得到了更好的理解。Active Record 文檔涵蓋了一些能夠進一步幫助該想法產生共鳴的示例。ruby

Laravel 的 Eloquent ORM

Laravel 的 ORM,叫做 Eloquent, 能夠很輕鬆的預加載模型,甚至預加載嵌套關聯模型。讓咱們以Post模型爲例,學習如何在 Laravel 項目中使用預先加載。
咱們將使用這個項目構建,而後更深刻地瀏覽一些預加載示例以進行總結。app

構建

讓咱們構建一些 數據庫遷移, 模型, 和  數據庫種子 來體驗預加載。若是你想跟着操做,我假設你有權訪問數據庫而且能夠完成了基本的 Laravel 安裝dom

使用 Laravel 安裝器, 新建項目:

laravel new blog-example

根據你的數據庫和選擇編輯 .env 文件。

接下來,咱們將建立三個模型,以便您能夠嘗試預加載嵌套關係。這個例子很簡單,因此咱們能夠專一於預加載,並且我省略了你可能會使用的東西,如索引和外鍵約束。

php artisan make:model -m Post
php artisan make:model -m Author
php artisan make:model -m Profile

-m 標誌建立一個遷移,以與將用於建立表模式的模型一塊兒使用。

數據模型將具備如下關聯:

Post -> belongsTo -> Author
Author -> hasMany -> Post
Author -> hasOne -> Profile

遷移

讓咱們爲每一個數據表建立一個簡表結構。我只添加了 up() 方法,由於 Laravel 將會爲新的數據表自動添加 down() 方法。這些遷移文件放在了 database/migrations/ 目錄中:

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreatePostsTable extends Migration
{
    /**
     * 執行遷移
     *
     * @return void
     */
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->increments('id');
            $table->unsignedInteger('author_id');
            $table->string('title');
            $table->text('body');
            $table->timestamps();
        });
    }

    /**
     * 回滾遷移
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('posts');
    }
}
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateAuthorsTable extends Migration
{
    /**
     * 執行遷移
     *
     * @return void
     */
    public function up()
    {
        Schema::create('authors', function (Blueprint $table) {
            $table->increments('id');
            $table->string('name');
            $table->text('bio');
            $table->timestamps();
        });
    }

    /**
     * 回滾遷移
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('authors');
    }
}
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateProfilesTable extends Migration
{
    /**
     * 執行遷移
     *
     * @return void
     */
    public function up()
    {
        Schema::create('profiles', function (Blueprint $table) {
            $table->increments('id');
            $table->unsignedInteger('author_id');
            $table->date('birthday');
            $table->string('city');
            $table->string('state');
            $table->string('website');
            $table->timestamps();
        });
    }

    /**
     * 回滾遷移
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('profiles');
    }
}

模型

你須要定義模型關聯並經過預先加載來進行更多的實驗。當你運行 php artisan make:model 命令的時候,它將爲你建立模型文件。

第一個模型爲 app/Post.php

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    public function author()
    {
        return $this->belongsTo(Author::class);
    }
}

接下來, app\Author.php 模型有兩個關聯關係:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Author extends Model
{
    public function profile()
    {
        return $this->hasOne(Profile::class);
    }

    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}

經過模型和遷移,你能夠運行遷移並繼續嘗試使用一些種子模型數據進行預加載。

php artisan migrate
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table
Migrating: 2017_08_04_042509_create_posts_table
Migrated:  2017_08_04_042509_create_posts_table
Migrating: 2017_08_04_042516_create_authors_table
Migrated:  2017_08_04_042516_create_authors_table
Migrating: 2017_08_04_044554_create_profiles_table
Migrated:  2017_08_04_044554_create_profiles_table

若是你查看下數據庫,你就會看到全部已經建立好的數據表!

工廠模型

爲了讓咱們能夠運行查詢語句,咱們須要建立一些假數據來提供查詢,讓咱們添加一些工廠模型,使用這些模型來爲數據庫提供測試數據。

打開 database/factories/ModelFactory.php 文件而且將以下三個工廠模型添加到現有的 User 工廠模型文件中:

/** @var \Illuminate\Database\Eloquent\Factory $factory */
$factory->define(App\Post::class, function (Faker\Generator $faker) {
    return [
        'title' => $faker->sentence,
        'author_id' => function () {
            return factory(App\Author::class)->create()->id;
        },
        'body' => $faker->paragraphs(rand(3,10), true),
    ];
});

/** @var \Illuminate\Database\Eloquent\Factory $factory */
$factory->define(App\Author::class, function (Faker\Generator $faker) {
    return [
        'name' => $faker->name,
        'bio' => $faker->paragraph,
    ];
});

$factory->define(App\Profile::class, function (Faker\Generator $faker) {
    return [
        'birthday' => $faker->dateTimeBetween('-100 years', '-18 years'),
        'author_id' => function () {
            return factory(App\Author::class)->create()->id;
        },
        'city' => $faker->city,
        'state' => $faker->state,
        'website' => $faker->domainName,
    ];
});

這些工廠模型能夠很容易的填充一些咱們能夠用來查詢的數據;咱們也可使用它們來建立並生成關聯模型所需的數據。

打開 database/seeds/DatabaseSeeder.php 文件將如下內容添加到 DatabaseSeeder::run() 方法中:

public function run()
{
    $authors = factory(App\Author::class, 5)->create();
    $authors->each(function ($author) {
        $author
            ->profile()
            ->save(factory(App\Profile::class)->make());
        $author
            ->posts()
            ->saveMany(
                factory(App\Post::class, rand(20,30))->make()
            );
    });
}

你建立了五個 author 並遍歷循環每個 author ,建立和保存了每一個 author 相關聯的 profileposts (每一個 authorposts 的數量在 20 和 30 個之間)。

咱們已經完成了遷移、模型、工廠模型和數據庫填充的建立工做,將它們組合起來能夠以重複的方式從新運行遷移和數據庫填充:

php artisan migrate:refresh
php artisan db:seed

你如今應該有一些已經填充的數據,能夠在下一章節使用它們。注意在 Laravel 5.5 版本中包含一個 migrate:fresh 命令,它會刪除表,而不是回滾遷移並從新應用它們。

嘗試使用預加載

如今咱們的前期工做終於已經完成了。 我我的認爲最好的可視化方式就是將查詢結果記錄到 storage/logs/laravel.log 文件當中查看。

要把查詢結果記錄到日誌中,有兩種方式。第一種,能夠開啓 MySQL 的日誌文件,第二種,則是使用 Eloquent 的數據庫調用來實現。經過 Eloquent 來實現記錄查詢語句的話,能夠將下面的代碼添加到 app/Providers/AppServiceProvider.php boot() 方法當中:

namespace App\Providers;

use DB;
use Log;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        DB::listen(function($query) {
            Log::info(
                $query->sql,
                $query->bindings,
                $query->time
            );
        });
    }

    // ...
}

我喜歡把這個監聽器封裝在配置檢查的時候,以即可以控制記錄查詢日誌的開關。你也能夠從 Laravel Debugbar 獲取到更多相關的信息。

首先,嘗試一下在不使用預加載模型的時候,會發生什麼狀況。清除你的 storage/log/laravel.log 文件當中的內容而後運行 "tinker" 命令:

php artisan tinker

>>> $posts = App\Post::all();
>>> $posts->map(function ($post) {
...     return $post->author;
... });
>>> ...

這個時候檢查你的 laravel.log 文件,你會發現一堆查詢做者的查詢語句:

[2017-08-04 06:21:58] local.INFO: select * from `posts`
[2017-08-04 06:22:06] local.INFO: select * from `authors` where `authors`.`id` = ? limit 1 [1]
[2017-08-04 06:22:06] local.INFO: select * from `authors` where `authors`.`id` = ? limit 1 [1]
[2017-08-04 06:22:06] local.INFO: select * from `authors` where `authors`.`id` = ? limit 1 [1]
....

而後,再次清空 laravel.log 文件,, 此次使用 with() 方法來用預加載查詢做者信息:

php artisan tinker

>>> $posts = App\Post::with('author')->get();
>>> $posts->map(function ($post) {
...     return $post->author;
... });
...

此次你應該看到了,只有兩條查詢語句。一條是對全部帖子進行查詢,以及對帖子所關聯的做者進行查詢:

[2017-08-04 07:18:02] local.INFO: select * from `posts`
[2017-08-04 07:18:02] local.INFO: select * from `authors` where `authors`.`id` in (?, ?, ?, ?, ?) [1,2,3,4,5]

若是你有多個關聯的模型,你可使用一個數組進行預加載的實現:

$posts = App\Post::with(['author', 'comments'])->get();

在 Eloquent 中嵌套預加載

嵌套預加載來作相同的工做。在咱們的例子中,每一個做者的 model 都有一個關聯的我的簡介。所以,咱們將針對每一個我的簡介來進行查詢。

清空 laravel.log 文件,來作一次嘗試:

php artisan tinker

>>> $posts = App\Post::with('author')->get();
>>> $posts->map(function ($post) {
...     return $post->author->profile;
... });
...

你如今能夠看到七個查詢語句,前兩個是預加載的結果。而後,咱們每次獲取一個新的我的簡介時,就須要來查詢全部做者的我的簡介。

經過預加載,咱們能夠避免嵌套在模型關聯中的額外的查詢。最後一次清空 laravel.log 文件並運行一下命令:

>>> $posts = App\Post::with('author.profile')->get();
>>> $posts->map(function ($post) {
...     return $post->author->profile;
... });

如今,總共有三個查詢語句:

[2017-08-04 07:27:27] local.INFO: select * from `posts`
[2017-08-04 07:27:27] local.INFO: select * from `authors` where `authors`.`id` in (?, ?, ?, ?, ?) [1,2,3,4,5]
[2017-08-04 07:27:27] local.INFO: select * from `profiles` where `profiles`.`author_id` in (?, ?, ?, ?, ?) [1,2,3,4,5]

懶人預加載

你可能只須要收集關聯模型的一些基礎的條件。在這種狀況下,能夠懶惰地調用關聯數據的一些其餘查詢:

php artisan tinker

>>> $posts = App\Post::all();
...
>>> $posts->load('author.profile');
>>> $posts->first()->author->profile;
...

你應該只能看到三條查詢,而且是在調用 $posts->load() 方法後。

總結

但願你能瞭解到更多關於預加載模型的相關知識,而且瞭解它是如何在更加深刻底層的工做方式。 預加載文檔 是很是全面的,我但願額外的一些代碼實現能夠幫助您更好的優化關聯查詢。

相關文章
相關標籤/搜索