Laravel列表每頁都查很多次資料:找出關聯N+1

列表只有十幾筆資料,資料庫卻被查了幾十次。常見原因是先取得文章,再在每一列讀作者、分類或其他關聯;畫面看起來只是在印一個名稱,實際上卻可能每次都發出新的 SQL。

這類問題稱為 N+1:一次取得 N 筆主資料,接著為每筆再查一次關聯。先看相同 SQL 如何隨列表數量增加,再針對畫面真正使用的關聯預先載入。不要一看到查詢多,就把所有資料全部塞進快取。

先找出迴圈中讀取的關聯

假設列表顯示書名和作者名,Book 透過 belongsTo 對應作者。下面先取得書籍,再讀每筆的 author:

$books = Book::orderBy('id')->get();

foreach ($books as $book) {
    echo $book->title;
    echo $book->author->name;
}

若作者關聯尚未載入,讀取 $book->author 會觸發關聯查詢。Blade 模板、API Resource 或模型 accessor 中的讀取也可能造成同樣情況,不一定寫在控制器的那個迴圈裡。追到真正需要作者名稱的輸出位置,才能找到多出來的查詢。

還要分清關聯屬性與關聯方法。$book->author 取已載入的關聯,$book->author() 則提供可繼續建立查詢的關聯物件;如果每列都在後面呼叫查詢方法,原本的預先載入未必能省掉那一筆。先看實際程式,不只在控制器加一個 with 就結束。

把主查詢與關聯查詢分開數

在開發環境記錄這段取資料與輸出的查詢,保留 SQL 和必要的測試條件。Laravel 可用查詢事件監聽,也可以在有界限的測試區塊查看 query log。記錄範圍要一致,不能把第一次建立資料表的 SQL 算進修改前,修改後卻只算一次讀取。

重點看這類重複形態:先查一次 books,接著多次按作者 ID 查 authors。不同書籍即使共用作者,也可能各自觸發關聯查詢;不要假定同樣的 ID 曾查過,就會自動替每一個獨立模型省掉查詢。

正式站的日誌可能含私人資料和敏感參數,不要把完整 bindings 公開貼出。先在合成資料環境重現;需要正式觀察時,由維護者安排適當的記錄範圍與保留方式,不要永久開啟無界限的完整查詢記錄。

本例六筆資料,從七次變成兩次

我們用 Laravel 13 的 Eloquent 連到另建的合成 SQLite,放入六本書、三位作者。先清空這次查詢記錄,再取得書籍與顯示作者。未預先載入時,讀取 books 一次,六個模型分別查作者,共七次。

改成 Book::with('author') 後,再以相同排序取得和顯示。查 books 一次,再用作者 ID 集合查 authors 一次,共兩次;兩份輸出的六個書名和作者名稱完全相同。

未預先載入:1 次 books + 6 次 authors = 7 次
預先載入:  1 次 books + 1 次 authors = 2 次
兩份列表:書名、順序、作者名稱相同

這裡數的是這段列表取得與顯示,不包含建立合成資料的 SQL。數字也不是所有關聯的固定公式:多個關聯、巢狀關聯、分頁總數查詢或多型關聯,都可能增加查詢。本例沒有啟用自動關聯預先載入,以便對照明確的 with 行為。

只預先載入畫面需要的資料

修改前後的列表應取得相同主資料,只改變關聯載入方式:

$books = Book::with('author')
    ->orderBy('id')
    ->get();

列表若同時需要作者和出版社,可明確列出兩個關聯;需要作者的另一層資料,才使用相應的巢狀關聯。不要為了減少查詢,把所有模型關聯都寫進每個列表。載入內容變多,也會增加記憶體及傳輸負擔。

想限制關聯欄位時,要保留 Eloquent 用來對應的主鍵與外鍵。只取名稱,卻漏掉模型匹配需要的 ID,可能使關聯不能正確組回去。先讓完整關聯結果正確,再按官方文件縮小欄位;每次縮小後,確認所有列仍顯示原本作者。

分頁列表應在原來的分頁查詢上加上需要的關聯,不要把 paginate 換成 get 來湊兩次。分頁可能另有總數查詢,應保留功能並用相同頁碼、每頁筆數比較。頁面數量和資料順序都要維持原用途。

只需要數量時,不必載入全部關聯

如果畫面只顯示每位作者有幾篇文章,考慮使用對應的關聯計數,而不是把每篇文章都取回再數。這是另一種資料需求:作者名稱需要關聯模型,文章數則只需要計數。先分清畫面要什麼,再選方法。

查詢次數少也不代表成本必然低。一筆巨大查詢、過多資料列或不合適的索引,仍可能拖慢頁面。這篇先確認 N+1 是否消失;若要判斷實際速度,再量相同條件下的耗時、資料量與記憶體,不把「7 次變 2 次」換算成固定提升倍數。

在開發環境提醒遺漏的關聯

Laravel 提供 Model::preventLazyLoading,可在適當環境協助發現未預先載入就讀取關聯的地方。官方示例依是否為正式環境設定;採用前要理解現有程式有哪些合法的延遲載入,不應突然在正式站開啟並讓頁面報錯。

Laravel 13 也有自動預先載入關聯的選項,需視應用程式是否啟用。排查時先查看既有設定,不能只按本文的七次結果推論你的網站一定相同。對重要列表明確寫出需要的關聯,仍有助讓資料需求容易理解與核對。

交付時保留同一份列表對照

選一組有共用作者、不同作者及可能缺少關聯的合成資料。修改前後保留排序、每頁筆數與顯示欄位,核對 SQL 數量和輸出內容。作者不存在時的顯示方式也要符合產品原規則,不要為了避錯而把所有作者名改成空白。

如果仍有重複 SQL,回查模板、accessor、序列化及關聯方法中的查詢。找到真正新增查詢的位置,再補需要的載入或調整資料取得方式。最後留下「哪個列表、哪些關聯、相同條件下幾次查詢」的紀錄,維護者才能知道這次解決了哪個問題。

參考資料

原文鏈接:https://wntheme.com/laravel-relationship-n-plus-one/,轉載請註明出處。
0

評論0

顯示驗證碼
沒有帳號?註冊  忘記密碼?