Legacy PHP 專案的效能問題,十之八九出在資料庫查詢。不是查太多,就是查錯方式。而其中最經典的,就是 N+1 查詢。
這篇文章分享五個從真實專案中挖出來的效能案例,全部都是用 Claude Code 分析程式碼後找到的。有些問題藏了好幾年,直到使用者抱怨「這頁怎麼這麼慢」才被發現。
什麼是 N+1 查詢?
簡單說:你先查了一次取得 N 筆資料(1 次查詢),然後對每筆資料再各查一次(N 次查詢),總共 N+1 次。
// ❌ 典型的 N+1:100 個住戶就跑 100 次查詢
$residents = $db->query("SELECT * FROM residents");
foreach ($residents as $r) {
$detail = $db->query("SELECT COUNT(*) FROM daily_records WHERE resident_id = '{$r['id']}'");
// ...
}
在資料量小的時候感覺不出來,但當住戶變成 300、500、800 人,頁面就會從「有點慢」變成「完全不能用」。
案例一:首頁載入——迴圈內藏了雙層 N+1
問題
系統首頁需要顯示每個住戶的「今日點名狀態」和「表單填寫提醒」。原始寫法是這樣的:
// ❌ 每個住戶查一次點名明細
foreach ($residents as $r) {
$sql = "SELECT * FROM form_social_80_detail
WHERE form_social_80_id = '{$r['formId']}'";
$detail80 = $db->query($sql);
$sql = "SELECT * FROM form_social_81_detail
WHERE form_social_81_id = '{$r['formId2']}'";
$detail81 = $db->query($sql);
}
更糟的是,表單提醒的部分還有七種表單要檢查最後填寫日期,每種都在迴圈裡各查一次:
// ❌ 雙層 N+1:外層跑住戶,內層跑表單類型
foreach ($residents as $r) {
foreach ($formTypes as $type) {
$sql = "SELECT MAX(assess_date) FROM {$type}
WHERE HospNo = '{$r['HospNo']}'";
$lastDate = $db->query($sql);
}
}
300 個住戶 × 7 種表單 = 2,100 次查詢,光是表單提醒這一塊。
Claude Code 怎麼找到的
> 分析 module_a/home1.php 的效能問題,特別注意迴圈內的資料庫查詢
Claude Code 讀完檔案後直接指出了兩層 N+1,並給出批次查詢的改法。
修復
點名狀態改用 GROUP BY 一次查完:
// ✅ 一次查詢取得所有點名統計
$sql = "SELECT form_social_80_id, COUNT(*) as cnt
FROM form_social_80_detail
WHERE Q2 != 0
GROUP BY form_social_80_id";
$rollcallMap = [];
foreach ($db->query($sql) as $row) {
$rollcallMap[$row['form_social_80_id']] = $row['cnt'];
}
// 使用時直接查 map
foreach ($residents as $r) {
$count = $rollcallMap[$r['formId']] ?? 0;
}
表單提醒改用 7 次批次查詢取代 N×7 次:
// ✅ 每種表單只查一次,用 MAX + GROUP BY
$formTables = [
'plan_assess' => 'assess_date',
'diag_assess' => 'assess_date',
'form_assess_02k' => 'date',
// ... 共 7 種
];
$reminders = [];
foreach ($formTables as $table => $dateCol) {
$sql = "SELECT HospNo, MAX({$dateCol}) as last_date
FROM {$table} GROUP BY HospNo";
foreach ($db->query($sql) as $row) {
$reminders[$row['HospNo']][$table] = $row['last_date'];
}
}
效果:2,100+ 次查詢 → 9 次查詢。
案例二:匯出報表——800 人 × 5 次查詢的迴圈
問題
統計名冊匯出 Excel,迴圈內對每個人查 5 次 DB:
// ❌ 每個人查 5 次
foreach ($patientList as $pid) {
$patient = $db->query("SELECT * FROM patient WHERE patientID = '$pid'");
$bed = $db->query("SELECT * FROM bedinfo WHERE bedID = '{$patient['bedID']}'");
$latestPlan = $db->query("SELECT * FROM care_plan WHERE HospNo = '{$patient['HospNo']}' ORDER BY date DESC LIMIT 1");
$employer = $db->query("SELECT * FROM employer_cg WHERE patientID = '$pid'");
$closedCase = $db->query("SELECT * FROM closed_case WHERE patientID = '$pid'");
}
800 人 × 5 = 4,000 次查詢,匯出一次 Excel 要等三分鐘。
修復
Claude Code 建議的策略:批次預查 + PHP array lookup。
// ✅ 分批查詢(每批 500 筆避免 IN 子句過長)
$chunks = array_chunk($patientList, 500);
$patientMap = [];
foreach ($chunks as $chunk) {
$ids = "'" . implode("','", $chunk) . "'";
$sql = "SELECT patientID, HospNo, Gender_1, bedID
FROM patient WHERE patientID IN ($ids)";
foreach ($db->query($sql) as $row) {
$patientMap[$row['patientID']] = $row;
}
}
// ✅ 最新紀錄用 subquery 取代 ORDER BY LIMIT 1
$sql = "SELECT a.* FROM care_plan a
INNER JOIN (
SELECT HospNo, MAX(date) as max_date
FROM care_plan GROUP BY HospNo
) b ON a.HospNo = b.HospNo AND a.date = b.max_date";
$latestPlans = [];
foreach ($db->query($sql) as $row) {
$latestPlans[$row['HospNo']] = $row;
}
// 使用時直接從 map 取
foreach ($patientList as $pid) {
$patient = $patientMap[$pid] ?? null;
$plan = $latestPlans[$patient['HospNo']] ?? null;
}
效果:4,000 次查詢 → 6-8 次批次查詢。匯出時間從三分鐘降到幾秒。
案例三:DATE_FORMAT 反模式——讓索引完全失效
這不是 N+1,但同樣致命。
問題
// ❌ DATE_FORMAT 包住欄位,索引失效
$sql = "SELECT * FROM vital_signs
WHERE DATE_FORMAT(RecordedTime, '%Y-%m-%d') >= '$startDate'
AND DATE_FORMAT(RecordedTime, '%Y-%m-%d') <= '$endDate'";
DATE_FORMAT() 對每一列做函式轉換,MySQL 無法使用 RecordedTime 上的索引,只能 full table scan。EXPLAIN 顯示掃描了 14,091 列。
Claude Code 怎麼找到的
> 這頁生理數據載入很慢,幫我看看 SQL 有沒有問題
Claude Code 立刻指出 DATE_FORMAT 在 WHERE 中會阻止索引使用,建議改用範圍比較。
修復
// ✅ 直接用範圍比較,索引生效
$sql = "SELECT * FROM vital_signs
WHERE RecordedTime >= '{$startDate} 00:00:00'
AND RecordedTime <= '{$endDate} 23:59:59'";
效果:掃描列數從 14,091 → 956(減少 93%)。同樣的修法套用到 9 個檔案。
延伸:WHERE 中的函式都要注意
這個反模式不只 DATE_FORMAT,任何在 WHERE 中對欄位做函式轉換都會讓索引失效:
-- ❌ 這些都會阻止索引
WHERE YEAR(created_at) = 2026
WHERE LOWER(username) = 'admin'
WHERE LEFT(code, 3) = 'ABC'
-- ✅ 改成範圍比較或使用 generated column
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'
案例四:重複查詢——同一份資料查了兩次
問題
首頁的 getData.php 已經把住戶、床位、入住資訊全部查好放在陣列裡了。但 home1.php 又用三個獨立 SQL 重新查了一次,只為了算「男/女人數」和「空床數」:
// ❌ getData.php 已經載入了這些資料,這裡又查一次
$sql1 = "SELECT * FROM inpatient_info
INNER JOIN bed_info ON ...
INNER JOIN patient ON ...";
$maleCount = 0;
foreach ($db->query($sql1) as $row) {
if ($row['Gender_1'] == 1) $maleCount++;
}
修復
Claude Code 指出這段跟 getData.php 完全重複,直接用已載入的陣列計算:
// ✅ 用已載入的資料計算
$maleCount = 0;
$femaleCount = 0;
foreach ($patient_arrayinfo as $p) {
if ($p['Gender_1'] == 1) $maleCount++;
else $femaleCount++;
}
看起來很明顯?但在一個 1,600 行的檔案裡,getData.php 的 include 在第 30 行,重複查詢在第 800 行,中間隔了一大堆 HTML 和 JavaScript,人眼很難發現這兩段在做同一件事。
案例五:表單開啟要 30 分鐘
問題
一個綜合評估表單頁面,開啟需要 30 分鐘。使用者回報以為系統當掉了。
根本原因:頁面載入時一次 include 所有歷史表單的所有分頁。每個分頁都建立新的 DB 連線物件,每個都查一次使用者名稱。
// ❌ 每個 tab 都 new 一個 DB
foreach ($historyForms as $form) {
include("tab_{$form['type']}.php");
// 每個 tab 裡面:
// $db = new DB; ← 每次都建新連線
// $name = checkusername(); ← 每次都查一次 DB
}
修復
Claude Code 提出三個改法,全部採用:
1. AJAX Lazy Loading——不再一次載入所有歷史分頁:
// ✅ 只渲染 placeholder,使用者點擊時才載入
foreach ($historyForms as $i => $form) {
if ($i < 6) {
echo "<div class='tab-pane' id='form-{$form['id']}'
data-ajax-url='formloader.php?id={$form['id']}'
data-loaded='false'>
<p>載入中...</p></div>";
}
}
2. DB 連線重用:
// ✅ 共用連線物件
if (!isset($db)) $db = new DB;
3. 查詢結果快取:
// ✅ memoized 版本,同一個使用者只查一次
function checkusername_cached($userId) {
static $cache = [];
if (!isset($cache[$userId])) {
$cache[$userId] = checkusername($userId);
}
return $cache[$userId];
}
效果:從 30 分鐘降到幾秒內開啟。歷史分頁限制最多顯示 6 筆,點擊才載入。
用 Claude Code 找 N+1 的工作流
經過這些案例,我整理出一套固定流程:
1. 讓 Claude Code 掃描整個檔案
> 分析這個檔案的所有資料庫查詢,找出在迴圈內執行的查詢
Claude Code 會列出每一個在 foreach / while / for 裡面的 SQL,標注哪些是 N+1。
2. 針對慢頁面做效能分析
> 這個頁面載入很慢,幫我分析可能的效能瓶頸
Claude Code 不只看 N+1,還會看重複查詢、不必要的 SELECT *、WHERE 中的函式呼叫等。
3. 請 Claude Code 產生修復方案
> 把這些 N+1 查詢改成批次查詢,保持業務邏輯不變
關鍵是「保持業務邏輯不變」——效能優化最怕改出新 bug,Claude Code 通常會保留原始邏輯,只改查詢方式。
4. 驗證索引使用
> 幫我看這些 SQL 有沒有用到索引,需不需要加新索引
搭配 EXPLAIN 結果一起給 Claude Code,它能判斷索引是否生效。
常見的 N+1 改法速查表
| 原始模式 | 改法 | 適用場景 |
|---|---|---|
迴圈內 SELECT WHERE id = ? | WHERE id IN (...) + PHP map | 最常見 |
迴圈內 ORDER BY date DESC LIMIT 1 | INNER JOIN (SELECT MAX(date) GROUP BY key) | 取最新一筆 |
迴圈內 COUNT(*) | GROUP BY + PHP lookup | 計數統計 |
迴圈內 new DB | 提到迴圈外重用 | DB 連線開銷 |
迴圈內 checkusername() | static $cache memoize | 重複函式呼叫 |
DATE_FORMAT() 在 WHERE | 改範圍比較 | 索引失效 |
| 一次載入所有歷史 | AJAX lazy load | 記憶體 / 查詢爆量 |
結語
N+1 查詢在 Legacy PHP 專案中特別普遍,因為這些系統通常:
- 沒有 ORM——手寫 SQL 散落各處,沒有 eager loading 機制
- 檔案很長——一個 PHP 檔案動輒上千行,迴圈和查詢距離很遠
- 逐步堆疊——功能一個一個加上去,沒人回頭看整體查詢次數
Claude Code 在這件事上的優勢很明確:它能一次讀完整個檔案,把所有查詢列出來,算出迴圈內的查詢次數。這不是人做不到,但在 1,600 行的檔案裡,你很容易因為「這段看起來沒問題」就跳過去。
AI 不會跳過任何一行。