用 Claude Code 消滅 N+1 查詢:Legacy PHP 效能優化實戰


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.phpinclude 在第 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 1INNER 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 專案中特別普遍,因為這些系統通常:

  1. 沒有 ORM——手寫 SQL 散落各處,沒有 eager loading 機制
  2. 檔案很長——一個 PHP 檔案動輒上千行,迴圈和查詢距離很遠
  3. 逐步堆疊——功能一個一個加上去,沒人回頭看整體查詢次數

Claude Code 在這件事上的優勢很明確:它能一次讀完整個檔案,把所有查詢列出來,算出迴圈內的查詢次數。這不是人做不到,但在 1,600 行的檔案裡,你很容易因為「這段看起來沒問題」就跳過去。

AI 不會跳過任何一行。