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

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

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

介紹

在基本級別,ORM 是 “懶惰” 加載相關的模型數據。但是,ORM 應該如何知道你的意圖?在查詢模型後,您可能永遠不會真正使用相關模型的數據。不優化查詢被稱爲 “N + 1” 問題。當您使用對象來表示查詢時,您可能在不知情的情況下進行查詢。
想象一下,您收到了 100 個來自數據庫的對象,並且每條記錄都有 1 個關聯的模型(即 belongsTo)。使用 ORM 默認會產生 101 條查詢;對原始 100 條記錄 進行一次查詢,如果訪問了模型對象上的相關數據,則對每條記錄進行附加查詢。在僞代碼中,假設您要列出所有已發佈帖子的發佈作者。從一組帖子(每個帖子有一位作者),您可以得到一個作者姓名列表,如下所示:

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

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

我們並沒有告訴模型我們需要所有作者,因此每次從各個 Post模型實例中獲取作者姓名時都會發生單獨的查詢 。


預加載

正如我所提到的,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 文檔涵蓋了一些可以進一步幫助該想法產生共鳴的示例。


Laravel 的 Eloquent ORM

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

構建

讓我們構建一些 數據庫遷移, 模型, 和 數據庫種子 來體驗預加載。如果你想跟着操作,我假設你有權訪問數據庫並且可以完成了基本的 Laravel 安裝
(個人:這裏我附上的是Laravel6.x版本安裝鏈接,你可以去官網自行選擇版本查閱相關文檔)
使用 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);
    }
}

通過模型和遷移,你可以運行遷移並繼續嘗試使用一些種子模型數據進行預加載。
(個人:在你執行遷移命令的時候如果你的數據庫版本不夠,可能會報錯:Syntax error or access violation: 1071 Specified key was too long; max key length is 1000 bytes......,如果你不願意解決版本問題。請在:app\Providers\AppServiceProvider.php文件的boot方法中添加:

public function boot()
{
    Schema::defaultStringLength(191);
}

)

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

嘗試使用預加載

現在我們的前期工作終於已經完成了。 我個人認爲最好的可視化方式就是將查詢結果記錄到 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()方法後。


總結

希望你能瞭解到更多關於預加載模型的相關知識,並且瞭解它是如何在更加深入底層的工作方式。 預加載文檔 是非常全面的,我希望額外的一些代碼實現可以幫助您更好的優化關聯查詢。

個人:你在測試過程中可以不用像文章中一樣使用 laravel.log來記錄查看,不過這樣比較方便一點,另外關於文中的 artisan命令非常好用,建議可以練習使用。使用工廠配合thinker來模擬數據或者做測試都是非常方便的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章