用 Claude Code 做資料庫索引分析:一次 23 ADD / 4 DROP 的實戰經驗


索引優化是資料庫效能調校的基本功,但在 Legacy PHP 系統中,它有幾個特別難搞的地方:

  1. 沒有 ORM——沒有 migrate 指令,索引要手寫 SQL
  2. 多租戶架構——幾十個資料庫要同步加索引
  3. 歷史包袱——十年來不同人加的索引,有些重複、有些缺失
  4. TEXT 欄位——MySQL 5.7 對 TEXT 欄位建索引有前綴長度限制

這篇記錄一次大規模索引優化的過程:一次性 23 個 ADD + 4 個 DROP,以及後續修正的踩坑經驗。


背景:為什麼突然要大規模加索引

起因是多個頁面的效能投訴。用 Claude Code 逐一分析慢查詢時,發現很多查詢的 EXPLAIN 結果都是 full table scan——不是查詢寫得差,是根本沒有對應的索引。

> 幫我分析這幾個慢查詢的 EXPLAIN 結果,找出缺少索引的地方

Claude Code 讀完所有 EXPLAIN 結果後,列出了 23 個需要新增的索引和 4 個可以移除的冗餘索引。


安全的索引 Migration:safe_add_index

在多租戶架構下,你不能假設每個資料庫的 schema 都一樣。有些資料庫可能已經有某個索引(手動加過),有些可能連目標表都不存在。

Claude Code 產生的 migration SQL 用了一個安全的 wrapper:

-- 安全新增索引:表不存在不報錯、索引已存在不報錯
DELIMITER //
DROP PROCEDURE IF EXISTS safe_add_index//
CREATE PROCEDURE safe_add_index(
    IN p_table VARCHAR(64),
    IN p_index VARCHAR(64), 
    IN p_columns VARCHAR(255)
)
BEGIN
    -- 1146: Table doesn't exist
    -- 1061: Duplicate key name
    DECLARE CONTINUE HANDLER FOR 1146 BEGIN END;
    DECLARE CONTINUE HANDLER FOR 1061 BEGIN END;
    
    SET @sql = CONCAT('ALTER TABLE `', p_table, 
                      '` ADD INDEX `', p_index, 
                      '` (', p_columns, ')');
    PREPARE stmt FROM @sql;
    EXECUTE stmt;
    DEALLOCATE PREPARE stmt;
END//
DELIMITER ;

同樣也有 safe_drop_index

DELIMITER //
DROP PROCEDURE IF EXISTS safe_drop_index//
CREATE PROCEDURE safe_drop_index(
    IN p_table VARCHAR(64),
    IN p_index VARCHAR(64)
)
BEGIN
    -- 1146: Table doesn't exist
    -- 1091: Can't drop index that doesn't exist
    DECLARE CONTINUE HANDLER FOR 1146 BEGIN END;
    DECLARE CONTINUE HANDLER FOR 1091 BEGIN END;
    
    SET @sql = CONCAT('ALTER TABLE `', p_table, 
                      '` DROP INDEX `', p_index, '`');
    PREPARE stmt FROM @sql;
    EXECUTE stmt;
    DEALLOCATE PREPARE stmt;
END//
DELIMITER ;

這樣在多租戶環境下,同一份 SQL 可以安全地跑在所有資料庫上,不用擔心某個資料庫少了某張表或已經有某個索引。


23 個新增索引的分類

Claude Code 把索引按影響程度分成三個優先級:

A 級:直接影響使用者體驗的慢查詢

-- 排班頁面:每次載入都要掃全表
CALL safe_add_index('employer_shift', 'idx_date_empid', 
    'ShiftDate, EmployerID');

-- 費用建立紀錄:依收據查詢
CALL safe_add_index('feecreate_item', 'idx_receipt', 
    'receiptID, feeType');

-- 服務明細:按個案+日期查詢(17s → 3.4s)
CALL safe_add_index('service_detail_02', 'idx_hospno_servicedate', 
    'HospNo, servicedate');

B 級:報表查詢效能

-- 保險申報紀錄
CALL safe_add_index('service_record_01', 'idx_hospno_admin', 
    'HospNo, adminID');

-- 費用明細:依項目名稱查詢
CALL safe_add_index('allfee', 'idx_parent', 
    'parentName, parentID');

-- 個人 I/O 紀錄
CALL safe_add_index('general_io', 'idx_person_date', 
    'PersonID, RecordDate');

C 級:背景作業和定期報表

-- 表單提交紀錄
CALL safe_add_index('form_order_detail', 'idx_patient_form', 
    'patientID, formType');

-- 薪資計算用代碼對照
CALL safe_add_index('salary_code', 'idx_code_org', 
    'code, OrgID');

4 個移除的冗餘索引

-- feecreate 的 idx_receiptID 跟 PRIMARY KEY 完全重複
CALL safe_drop_index('feecreate', 'idx_receiptID');

-- form_order 的 id UNIQUE INDEX 跟 PRIMARY KEY 重複
CALL safe_drop_index('form_order', 'id');

-- pinfolog 的 patientID 是 PK 前綴的子集
CALL safe_drop_index('pinfolog', 'patientID');

-- form_assess_17 的 Qmedicine 被新的複合索引取代
CALL safe_drop_index('form_assess_17', 'Qmedicine');

Claude Code 怎麼判斷索引是冗餘的

> 列出所有表的索引,找出冗餘的(跟 PK 重複或被其他索引涵蓋的)

Claude Code 比對每張表的所有索引定義,找出前綴重複的情況。例如 PK 是 (id),又有一個 UNIQUE INDEX (id) —— 完全重複,浪費寫入效能和磁碟空間。


坑一:TEXT 欄位忘了加前綴長度

問題

Migration SQL 跑在大部分資料庫上都成功,但某幾個報了錯:

ERROR 1170 (42000): BLOB/TEXT column 'Qmedicine' used in key 
specification without a key length

根因

Qmedicine 欄位是 TEXT 型別。MySQL 5.7 不允許對整個 TEXT 欄位建索引,必須指定前綴長度

-- ❌ TEXT 欄位不能直接建索引
CALL safe_add_index('form_assess_17', 'idx_med_date', 
    'Qmedicine, cDate');

-- ✅ 需要加前綴長度
CALL safe_add_index('form_assess_17', 'idx_med_date', 
    'Qmedicine(100), cDate');

(100) 表示只索引前 100 個字元。這個長度需要根據實際資料分佈決定——太短會降低索引選擇性,太長會浪費空間。

同樣的坑再踩一次

修完 Qmedicine 後,另一個 issue 也踩到了同樣的問題:HospNo 在某些表中也是 TEXT 型別:

-- ❌ 又忘了
CALL safe_add_index('case_report_40', 'idx_hospno_date', 
    'HospNo, date');

-- ✅ 加前綴
CALL safe_add_index('case_report_40', 'idx_hospno_date', 
    'HospNo(20), date');

教訓:在加索引之前,先確認欄位型別。VARCHAR 可以直接建索引,TEXT/BLOB 必須加前綴。


坑二:多租戶環境下的 DB 防護

問題

Migration SQL 在母版資料庫(demo-org-1,存放共用設定)上執行時,意外修改了不該改的表。

修復

在 stored procedure 中加入資料庫名稱檢查:

CREATE PROCEDURE safe_add_index(...)
BEGIN
    -- 只在租戶資料庫上執行,跳過母版
    IF DATABASE() NOT LIKE 'demo-org-%' 
       OR DATABASE() = 'demo-org-1' THEN
        -- 跳過
        SELECT 'Skipped: not a tenant DB' AS msg;
    ELSE
        -- 執行 ALTER TABLE
    END IF;
END//

教訓:多租戶 migration 一定要有「這個 SQL 該跑在哪些 DB」的防護。


坑三:加了索引但沒用——FORCE INDEX

問題

加完 service_detail_02(HospNo, servicedate) 複合索引後,某個頁面的查詢時間從 17 秒降到 3.4 秒。但另一個類似的查詢完全沒改善。

根因

MySQL 的查詢最佳化器在某些情況下不會選擇你期望的索引。特別是當表有多個索引、且 WHERE 條件涉及多個欄位時,最佳化器可能選了一個更「通用」但更慢的索引。

修復

// ✅ 強制使用特定索引
$sql = "SELECT * FROM service_detail_02 
        FORCE INDEX (idx_hospno_servicedate)
        WHERE HospNo = '{$hospNo}' 
        AND servicedate >= '{$startDate}'";

FORCE INDEX 應該是最後手段,但在 Legacy 系統中,有時候它是唯一可靠的方式——因為你不確定 MySQL 最佳化器會怎麼決定,而且你沒辦法升級 MySQL 版本來獲得更好的最佳化器。


搭配 PHP 端優化的完整效果

索引只是一半。另一半是 PHP 端的查詢改法。這個案例中,Claude Code 同時做了兩件事:

移除 DATE_FORMAT 反模式

// ❌ DATE_FORMAT 讓索引失效
$sql = "WHERE date_format(servicedate, '%Y-%m-%d') >= '{$start}'";

// ✅ 直接比較,索引生效
$sql = "WHERE servicedate >= '{$start}'";

批次預查取代迴圈查詢

// ❌ 迴圈內逐筆查詢服務代碼使用次數
foreach ($patients as $p) {
    $sql = "SELECT code, COUNT(*) FROM service_detail_02 
            WHERE HospNo = '{$p['HospNo']}' GROUP BY code";
}

// ✅ 一次批次查完
$sql = "SELECT HospNo, code, COUNT(*) as cnt 
        FROM service_detail_02 
        WHERE HospNo IN ('{$hospNos}') 
        GROUP BY HospNo, code";
$usageMap = [];
foreach ($db->query($sql) as $row) {
    $usageMap[$row['HospNo']][$row['code']] = $row['cnt'];
}

加索引 + 改 PHP 查詢的綜合效果:審核作業頁面從「打不開」到「3 秒內載入」。


用 Claude Code 做索引分析的工作流

Step 1:收集慢查詢

> 找出這個模組中所有 SQL 查詢,用 EXPLAIN 分析哪些是 full table scan

Step 2:請 Claude Code 建議索引

> 根據這些 EXPLAIN 結果,建議需要新增的索引。
  注意 TEXT 欄位需要前綴長度,注意不要建冗餘索引。

Step 3:產生安全的 migration SQL

> 用 safe_add_index 的方式產生 migration SQL,
  加入多租戶 DB 防護,按優先級排序。

Step 4:找出可以移除的索引

> 列出所有表的索引定義,找出跟 PK 重複或被新索引取代的。

索引優化清單

檢查項目說明
EXPLAIN 是否 full table scantype: ALL 就是全表掃描
TEXT/BLOB 欄位是否加前綴column(N) 語法
是否有冗餘索引PK 前綴重複、被其他索引涵蓋
WHERE 中是否有函式包欄位DATE_FORMATYEAR()LOWER()
複合索引的欄位順序最常用的篩選條件放前面
Migration 是否有 DB 防護多租戶環境必要
Migration 是否冪等safe_add_index 模式

結語

索引優化這件事,單獨看每一個都不難——就是看 EXPLAIN、加索引。難的是在一個十年的系統中,找出所有該加沒加、該刪沒刪的索引

Claude Code 的價值在於它可以一次讀完整個模組的所有 SQL,交叉比對表結構和查詢模式,產出一份完整的索引建議。人工做這件事需要幾天,Claude Code 幾分鐘就列完了。

而且它會記住 TEXT 前綴長度的規則——雖然我已經踩過兩次坑了。