避免 Laravel ORM 的 N+1 問題

目前大多框架都會使用 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
貼心提醒你有地方該優化囉

 

參考影片

 

參考資料
Find N+1 problems instantly by disabling lazy loading
Rails Guide - Active Record 基礎

sharkHead 後端工程師,稍微擅長 Laravel、Python 與 Google
對於前端有興趣,無奈沒什麼慧根