避免 Laravel ORM 的 N+1 問題

程式技術
sharkHead

目前大多框架都會使用 ORM (Object Relational Mapping,物件關係對映) 的方式與資料庫進行互動,ORM 的用途,是將關聯式資料庫的資料表,對應到應用程式中的物件,對資料庫的操作都會使用複雜的物件包裝好並模組化,讓你在撈取資料庫資料時,可以不用寫 SQL 查詢語法。

但模組化卻衍生另外一個常見的效能問題,也就是 N+1 問題,或稱為 Lazy Loading,如果沒有處理 N+1 問題,就會因為對資料庫進行大量查詢而導致效能降低。

什麼是 N+1 問題?

資料表之間可能會有關連關係,以論壇的使用者與文章為例。

  • 一名使用者 (User) 可以發布多篇文章 (Post)。
  • 一篇文章只屬於一名使用者。

可以清楚知道使用者文章的關係為一對多,所以我們可以在 User Model 中使用 hasMany() 定義與 Post Model 的關聯。

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

假設一種情況,我們需要取得多名使用者,同時還要取得這些使用者們,過去所有發布的文章,我們用下面的 Laravel 程式來舉例,並開啟 Query Log 查看一下這樣的操作會對資料庫進行幾次查詢。

use App\Models\User;
use Illuminate\Support\Facades\Route;

Route::get('lazy-loding', function () {
    // 開啟 Query Log
    DB::enableQueryLog();

    // 取得所有使用者
    $users = User::get();

    // 使用迴圈取得每一位使用者所發布的文章
    foreach ($users as $user) {
        $posts = $user->posts;
        dump($posts->toArray());
    }

    // dump 對資料庫的查詢語法
    dump(DB::getQueryLog());
});

可以看到 Query Log 的結果如下。

2021_07_14_19_43_17_60eecdd582e71.png
這就是 N+1 問題

第一筆查詢是取得所有用戶 (N+1 中的 1)

// 取得所有使用者
$users = User::get();

接下來的每一筆是取得各用戶發布的文章 (N+1 中的 N)

// 使用迴圈取得每一位使用者所發布的文章
foreach ($users as $user) {
    $posts = $user->posts;
}

範例只有 10 位用戶看起來好像還好,但試想如果現在有上萬、十萬甚至是百萬位用戶,那個 N 就會嚴重拖累資料庫的效能。

解決 N+1 問題

要解決 N+1 問題,並優化對資料庫的查詢,我們可以使用 Eager Loading
在 Laravel 中可以使用 with() 來達成 Eager Loading

Route::get('lazy-loding', function () {
    // 開啟 Query Log
    DB::enableQueryLog();

    // 取得所有使用者,並預先加載 Post 的資料
    // 這裡的 'posts' 對應到 User Model 中的 posts()
    $users = User::with('posts')->get();

    // 使用迴圈取得每一位使用者所發布的文章
    foreach ($users as $user) {
        $posts = $user->posts;
        dump($posts->toArray());
    }

    // dump 對資料庫的查詢語法
    dump(DB::getQueryLog());
});

此時再次查看 Query Log,可以發現查詢次數大幅度減少到只有兩次。

2021_07_14_19_43_25_60eecdddd1690.png
查詢次數大幅度減少到只有兩次

Laravel 的 Eager Loading 還有很多種用法,你可以在 User Model 中直接設定屬性 $with
如下方程式碼所示,這樣每次對 User Model 的操作都會預先加載 Post 的資料。

...

class User extends Model
{
	...

	public $with = ['posts'];

	...
}

如果不需要的 Post 資料的話,可以使用 without() 方法。

$users = User::without('posts')->get();

或是只需要其他資料的話,可以使用 withOnly() 方法。

$users = User::withOnly('loginRecords')->get();

上述的方式都需要工程師仔細檢查來避免 N+1 問題,但人總會有失手的時候,有可能因為不小心沒檢查到而導致 N+1 問題發生,在 Laravel 8 中,提供了一個新的大絕招避免 N+1 問題。

// app/Providers/AppServiceProvider.php

use Illuminate\Database\Eloquent\Model;

public function boot()
{
	// 在測試環境中強制關閉 Lazy Loading
    Model::preventLazyLoading(!app()->isProduction());
}

設定好之後,如果因為沒注意而不小心發生 N+1 問題,程式就會直接噴出錯誤。

2021_07_14_19_43_36_60eecde837356.png
貼心提醒你有地方該優化囉

參考資料

sharkHead
written by
sharkHead

後端打工仔,在下班後喜歡研究各種不同的技術。稍微擅長 PHP,並偶爾涉獵前端開發。個性就像動態語言般隨興,但渴望做事能像囉嗦的靜態語言那樣嚴謹。

0 則留言