避免 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 的結果如下。
第一筆查詢是取得所有用戶 (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,可以發現查詢次數大幅度減少到只有兩次。
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 問題,程式就會直接噴出錯誤。