Day 21:誰登入看到誰的儀表板,Widget 排版與權限呼應 結尾留下一句話,資料變多之後,表格為什麼開始變慢,是接下來要處理的內容。今天要處理的正是這句話,也是第七階段「效能、測試與部署前準備」的第一天。
這套後台走到今天,功能、驗證、權限、儀表板都已經齊備。商品可以上架與編輯,訂單走得完狀態流程,倉管、業務、財務三個角色各自看得到該看的畫面,台北、高雄兩個據點的資料也彼此隔離。
但回頭看看過去二十一天示範操作時用的資料筆數,商品可能只有幾十筆,訂單也只有幾十筆,每張訂單底下掛的訂單明細更是寥寥幾行。這種規模不管表格背後的查詢寫得好不好,畫面幾乎都是點下去瞬間就跑出來,過去二十一天完全感受不到任何延遲。
這正是問題所在。二十個人的辦公室不需要門禁系統,隨便一個人喊一聲就能確認訪客身分。
但兩千個人的辦公室,如果不裝門禁系統,早上九點的大門口天天塞成一團。
示範資料量之所以看不出問題,不是因為系統寫得夠好,而是規模本身把問題蓋住了。一旦商品、訂單、客戶的資料量真正成長到貼近實際批發商的營運規模,這套系統的操作是否還能維持順暢,是還沒有被檢驗過的問題。
今天的任務,是把商品、訂單這幾個既有模組的資料量刻意灌大,逼近真實批發商累積數年營運下來的規模,實際觀察 Day 10:表格裡顯示關聯資料,一眼看出這張訂單屬於哪個客戶 定案的列表頁在這種規模下的行為變化,找出變慢的具體成因,並示範幾個常見、對症下藥的處理方向。這篇不會窮舉所有效能優化手法,也不會引入新的模組或新的業務情境,全篇緊扣商品、訂單、訂單明細這幾個已經串接完成的既有模組,用灌入大量測試資料的方式,讓問題自己浮現出來。
Laravel 內建的資料填充機制,是產生大量測試資料最直接的工具。這幾天示範用的 Seeder 頂多建立十幾筆資料,今天要用 Factory 搭配迴圈或批次寫入,把商品灌到五千筆,訂單灌到一萬筆,每張訂單再各自關聯三到五筆訂單明細,累積起來的訂單明細總數落在三到五萬筆之間,這樣的量級才貼近一間經營數年的批發商實際會累積出來的資料規模。
namespace Database\Seeders;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use Illuminate\Database\Seeder;
class PerformanceTestSeeder extends Seeder
{
public function run(): void
{
$customers = Customer::factory()->count(200)->create();
$products = Product::factory()->count(5000)->create();
Order::factory()
->count(10000)
->recycle($customers)
->create()
->each(function (Order $order) use ($products) {
OrderItem::factory()
->count(random_int(3, 5))
->for($order)
->create([
'product_id' => fn () => $products->random()->id,
]);
});
}
}
這裡的 recycle() 讓一萬筆訂單重複使用同一批客戶,而不是每張訂單都建立一個全新客戶,這樣灌出來的資料分佈才貼近真實情況,一個客戶下多張訂單,而不是每個客戶都只下過一張訂單。灌入過程本身在幾千筆的規模下,用一般的迴圈寫法就會花上不少時間,OrderItem 那段迴圈裡如果逐筆呼叫 create(),一萬次外層迴圈加上每次三到五次的內層寫入,累積下來是四萬筆上下的個別寫入動作,實務上會希望改用批次寫入或分批 insert() 縮短灌入時間,但這件事本身跟今天要示範的列表頁效能問題無關,這裡不深入展開,只需要耐心等灌完即可。
資料灌完之後,回頭打開既有的訂單列表頁,感受會立刻不一樣。過去二十一天點開這個頁面幾乎是瞬間載入,現在同一個頁面明顯出現遲滯,尤其是顯示客戶名稱這一欄,Day 10:表格裡顯示關聯資料,一眼看出這張訂單屬於哪個客戶 當時定案的做法,是直接在表格欄位裡透過關聯取出客戶名稱,這類欄位在資料量變大之後,往往是感受最明顯的變慢來源。商品列表頁如果有顯示分類名稱這類關聯欄位,也會出現類似的現象。
感受到變慢還不夠,得確認變慢的具體成因,不能急著亂猜、亂調整。
最直接的做法是打開 Laravel Debugbar 或用 DB::enableQueryLog() 手動記錄,觀察載入這頁列表實際執行了幾條查詢語句:
use Illuminate\Support\Facades\DB;
DB::enableQueryLog();
// 觸發一次訂單列表頁的查詢
$orders = \App\Models\Order::query()->paginate(10);
foreach ($orders as $order) {
$order->customer->name;
}
dd(DB::getQueryLog());
getQueryLog() 印出來的結果,如果只是一頁十筆訂單,卻看到十幾條幾乎長得一樣、只差在 where id = ? 這個條件不同的查詢,這是查詢次數過多的訊號,成因通常是關聯沒有預先載入。如果查詢語句本身數量不多,但單一條 SELECT 執行時間拉得很長,這就不是次數的問題,得回頭檢查這條查詢用到的欄位在資料庫層級有沒有建立索引。這個排查順序,先看查詢次數,再看單一查詢的執行時間,是今天示範聚焦的兩種成因對應的兩條路徑。
第一種成因,出在表格顯示關聯資料的方式。Filament 的 Resource 列表頁如果沒有特別處理,每一列在渲染客戶名稱這個欄位時,都會各自對資料庫發出一次查詢去抓對應的客戶記錄,這正是一句話問一次就要重新打一通電話的做法,一頁十筆訂單看起來只多了十次查詢還不明顯,但實際列表頁往往一次載入十幾筆到幾十筆,換算下來就是十幾到幾十條額外查詢,分頁筆數調高之後更是等比例放大。
處理方式是在 Resource 的表格定義裡,透過 modifyQueryUsing() 讓查詢一次把需要的關聯資料都帶上,而不是等到每一列渲染時才臨時去問:
namespace App\Filament\Resources;
use Illuminate\Database\Eloquent\Builder;
class OrderResource extends Resource
{
public static function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with(['customer']))
->columns([
// 欄位定義沿用既有內容,不重複列出
]);
}
}
改完之後重新用同樣的方式檢查查詢紀錄,一頁十筆訂單原本要發出十幾條查詢,現在變成固定的兩條,一條抓訂單本身,一條把這十筆訂單對應的客戶一次抓齊。這就是一次把要問的都問完,跟每問一句就重打一通電話的差別,查詢次數不再隨著這一頁顯示的筆數等比例增加。商品列表頁如果同樣顯示了分類名稱這類關聯欄位,用同樣的方式加上 with(['category']) 即可,道理完全相通。
第二種成因,出在資料庫層級缺乏索引。訂單列表頁常見的操作,是依訂單狀態篩選、依訂單日期排序,這兩個欄位正是 Day 07:讓大量資料可用,篩選、排序與搜尋 定案的既有機制實際運作時會頻繁用到的查詢條件。資料筆數只有幾十筆的時候,資料庫不管有沒有索引,逐筆掃描整張表的成本都低到感覺不出差異,但資料量來到一萬筆之後,沒有索引的查詢就得逐筆掃描過整張訂單資料表才能找出符合條件的記錄,篩選跟排序的反應速度會明顯變慢。
處理方式是替這兩個常用欄位補上資料庫索引,透過一個新的 Migration 完成,不需要更動既有的資料表結構定義:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->index('status');
$table->index('order_date');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropIndex(['status']);
$table->dropIndex(['order_date']);
});
}
};
執行完這個 Migration 之後,回到列表頁實際操作依狀態篩選、依日期排序,反應速度的改善會比前一項調整更直觀,尤其是篩選符合條件的記錄只佔整張表一小部分的情境,索引帶來的差異最明顯。
這兩種調整加起來,關聯預先載入解決的是查詢次數過多的問題,資料庫索引解決的是單一查詢本身緩慢的問題,兩者處理的是完全不同層次的成因,不能用同一帖藥治兩種病。這裡得誠實補一句,這兩種是最常見、也最容易上手處理的成因,但不代表能解決所有效能問題。情境更複雜的多層關聯查詢、極端資料量下的分頁效能,可能需要更進一步的資料庫層級調校,不在今天的示範範圍內。
回顧今天處理的兩種效能成因,關聯預先載入解決了查詢次數過多的問題,資料庫索引解決了單一查詢緩慢的問題。商品、訂單列表頁在灌入貼近真實批發商規模的測試資料之後,操作反應明顯改善,這是今天實際驗證出來的結果,不是紙上談兵的推論。
但改善歸改善,一個新的落差也在這個過程裡浮現出來。資料量一旦成長到這個規模,管理者不會滿足於一筆一筆手動輸入新商品,五千筆商品資料如果全靠表單一筆一筆點進去建立,這件事本身就不切實際。管理者勢必需要透過某種方式,一次把幾百筆商品資料灌進系統,而這件事目前示範專案完全沒有處理過。剛才用來灌測試資料的填充機制,是開發階段才能操作的工具,管理者在正式環境裡沒有終端機可以下指令跑 Seeder,這條路對他們來說根本不存在。
檔案上傳與資料匯入匯出,正式環境常踩的坑,是接下來要處理的內容。