同一個 Bug 修了一次,下個月在另一個模組又出現。修了,再下個月又出現在第三個地方。
這不是 Bug——是 反模式(Anti-pattern)。它散布在整個 codebase 中,每次只修一個點,永遠修不完。
這篇記錄用 Claude Code 對一個 Legacy PHP 系統做「反模式掃蕩」的完整過程:4 週內在 15+ 個檔案中清除 20+ 處 DATE_FORMAT 反模式,以及一個刻意保留不改的例外案例。
反模式:WHERE 中的 DATE_FORMAT
問題
MySQL 的索引在 WHERE 條件中對欄位做函式包裝時會失效:
-- ❌ DATE_FORMAT 包裝讓 RecordedTime 的索引失效
WHERE DATE_FORMAT(`RecordedTime`, '%Y-%m-%d') >= '2026-03-01'
AND DATE_FORMAT(`RecordedTime`, '%Y-%m-%d') <= '2026-03-06'
MySQL 無法對 DATE_FORMAT(RecordedTime, ...) 的結果使用索引——它必須逐行計算每一筆資料的 DATE_FORMAT 結果,再做比較。這就是 full table scan。
修正
-- ✅ 直接比較,索引生效
WHERE `RecordedTime` >= '2026-03-01 00:00:00'
AND `RecordedTime` <= '2026-03-06 23:59:59'
看起來很簡單?問題是這個反模式在一個 10 年的 codebase 中到處都是。
發現過程:從單點修復到全面掃蕩
第一個發現:生理徵象頁面
使用者回報某個頁面載入緩慢。用 EXPLAIN 分析:
type: ALL rows: 14,091 Extra: Using where
type: ALL = full table scan。14,091 行全部掃過。
原因就是 WHERE 中的 DATE_FORMAT:
WHERE DATE_FORMAT(`RecordedTime`, '%Y-%m-%d') >= '$date1'
AND DATE_FORMAT(`RecordedTime`, '%Y-%m-%d') <= '$date2'
修正後:
type: range rows: 956 Extra: Using index condition
掃描行數從 14,091 降至 956,減少 93%。
問 Claude Code:還有哪裡有同樣的問題?
> 這個 codebase 中還有哪些地方在 WHERE 條件中使用了
DATE_FORMAT、YEAR()、MONTH() 包裝日期欄位?
列出所有檔案和行號。
Claude Code 掃完整個專案,列出了 20+ 處相同的反模式,分散在 15+ 個 PHP 檔案中。
掃蕩時間線
| 週次 | 修正範圍 | 效果 |
|---|---|---|
| Week 0 | 索引基礎建設:23 ADD / 4 DROP | 鋪路 |
| Week 1 | 生理徵象 9 個頁面 | 掃描行 -93% |
| Week 1 | 審核作業頁面 4 處 | 查詢 17s → 3.4s |
| Week 2 | 結案統計 3 處 | 年度查詢走 range scan |
| Week 3 | 報表匯出 2 個檔案 | N+1 一併修正 |
| Week 4 | 服務明細匯出 2 個檔案 | N+1 一併修正 |
不是一次改完的——每週改一批,搭配當週的效能修復任務一起做。這是更安全的策略,因為每次修改都需要在對應的模組中驗證。
五種修正模式
模式一:日期範圍比較(最常見)
// ❌ Before
$sql = "WHERE DATE_FORMAT(`servicedate`, '%Y-%m-%d') >= '$start'
AND DATE_FORMAT(`servicedate`, '%Y-%m-%d') <= '$end'";
// ✅ After
$sql = "WHERE `servicedate` >= '$start'
AND `servicedate` <= '$end'";
大部分情況就是這麼簡單。日期欄位本身就是 DATE 或 DATETIME 型別,直接比較完全沒問題。
模式二:年份/月份比較
// ❌ Before
$sql = "WHERE DATE_FORMAT(`outdate`, '%Y') = '$year'";
// ✅ After:改為範圍比較
$startDate = $year . '0101';
$endDate = $year . '1231';
$sql = "WHERE `outdate` >= '$startDate' AND `outdate` <= '$endDate'";
DATE_FORMAT(outdate, '%Y') = '2026' 看起來很直覺,但它會掃全表。改成範圍比較就能用索引。
模式三:年月比較
// ❌ Before
$sql = "WHERE DATE_FORMAT(a.`outdate`, '%Y-%m') = '$y_m'";
// ✅ After:PHP 端計算月份首尾
$startDate = $y_m . '-01';
$endDate = date('Y-m-t', strtotime($startDate));
$sql = "WHERE a.`outdate` >= '$startDate' AND a.`outdate` <= '$endDate'";
模式四:搭配索引新增
有時候光移除 DATE_FORMAT 還不夠——目標欄位根本沒有索引:
-- 先加索引
CALL safe_add_index('service_detail_02', 'idx_hospno_servicedate',
'HospNo, servicedate');
-- 再修 SQL
-- 某些情況甚至需要 FORCE INDEX
SELECT * FROM service_detail_02
FORCE INDEX (idx_hospno_servicedate)
WHERE HospNo = ? AND servicedate >= ? AND servicedate <= ?
審核作業頁面的修正就是這個模式——移除 DATE_FORMAT + 新增複合索引 + FORCE INDEX,查詢時間從 17 秒降至 3.4 秒。
模式五:搭配 N+1 一併修正
很多有 DATE_FORMAT 問題的地方同時也有 N+1 問題:
// ❌ Before:DATE_FORMAT + N+1 雙重問題
foreach ($patients as $p) {
$sql = "SELECT COUNT(*) FROM service_detail_02
WHERE DATE_FORMAT(servicedate, '%Y-%m-%d') >= '$start'
AND DATE_FORMAT(servicedate, '%Y-%m-%d') <= '$end'
AND HospNo = '{$p['HospNo']}'";
}
// ✅ After:直接比較 + 批次查詢
$sql = "SELECT HospNo, COUNT(*) as cnt FROM service_detail_02
WHERE servicedate >= '$start' AND servicedate <= '$end'
AND HospNo IN ($hospNoList)
GROUP BY HospNo";
$countMap = [];
foreach ($db->query($sql) as $row) {
$countMap[$row['HospNo']] = $row['cnt'];
}
800 個個案 × 每人 5 次查詢 = 4,000 次 DB 查詢,改成 6-8 次批次查詢。
例外:刻意保留不改的 form_social_80/81
掃蕩過程中,Claude Code 也標記了 form_social_80 和 form_social_81 的 DATE_FORMAT 用法。但分析後決定刻意保留:
-- form_social_80 的查詢涉及跨資料庫 JOIN
FROM `form_social_80` a
INNER JOIN `$EXT_DB`.`form_social_08_act` e ON e.actID = a.actID
INNER JOIN `$EXT_DB`.`form_social_08` f ON f.actNo = a.actNo
WHERE DATE_FORMAT(a.date, '%Y-%m-%d') >= '$date1'
原因:
- 跨資料庫 JOIN——
$EXT_DB是外部系統的資料庫,優化空間有限 - 資料量不大——這兩張表的資料量遠小於其他表
- 改動風險高——跨 DB 查詢的行為難以在本地完整測試
Claude Code 標記了這個例外,並在修正 SQL 中加了註解:
-- NOTE: form_social_80/81 的 DATE_FORMAT 刻意保留
-- 原因:跨 DB JOIN ($EXT_DB),資料量小,改動風險高於效益
不是所有反模式都要修。 有些保留反而是正確的判斷。
新增反模式的反例
有趣的是,在掃蕩的同時,另一個功能修改中新增了 DATE_FORMAT:
-- 新需求:按月份隔離查詢
WHERE DATE_FORMAT(A17, '%Y-%m') = '$select_month'
這是業務邏輯需求(需要精確的月份匹配),且該表資料量很小。在這種情況下,DATE_FORMAT 不是反模式——它是合理的用法。
反模式不是絕對的。 判斷標準是:資料量大不大、有沒有索引需求、效能是否構成問題。
Claude Code 做反模式掃蕩的工作流
Step 1:識別反模式
> 幫我在整個 codebase 中搜尋 WHERE 條件裡使用
DATE_FORMAT、YEAR()、MONTH()、LOWER() 等函式包裝欄位的地方。
列出每個檔案、行號、完整的 SQL 語句。
Step 2:評估每一處的修正價值
> 這些地方中,哪些表的資料量大到需要修正?
哪些可以保留?標記原因。
Claude Code 會考慮資料量、是否有索引、是否有跨 DB JOIN 等因素。
Step 3:逐批修正
不要一次改完。按模組分批:
> 先修 vitalsigns 相關的 9 個檔案。
改完後幫我用 EXPLAIN 驗證索引是否生效。
Step 4:處理連帶問題
修 DATE_FORMAT 時經常會發現 N+1 問題。一起修:
> 這個檔案除了 DATE_FORMAT,還有 N+1 查詢嗎?
一併修正。
Step 5:記錄例外
> 列出所有決定不修的地方和原因,
加到程式碼註解中。
效能數據總結
| 指標 | Before | After | 改善 |
|---|---|---|---|
| EXPLAIN 掃描行數 | 14,091 | 956 | -93% |
| 審核作業查詢時間 | 17s | 3.4s | -80% |
| 報表匯出 DB 查詢次數(800人) | ~4,000 | ~6-8 | -99.8% |
| DATE_FORMAT WHERE 殘存數量 | 20+ | 3(刻意保留) | -85% |
反模式掃蕩清單
這個方法論不限於 DATE_FORMAT。任何在 codebase 中反覆出現的問題都可以用同樣的流程處理:
| 反模式 | 影響 | 修正方式 |
|---|---|---|
DATE_FORMAT in WHERE | 索引失效 | 直接比較 + 範圍查詢 |
YEAR() / MONTH() in WHERE | 索引失效 | PHP 端預算日期範圍 |
LOWER() in WHERE | 索引失效 | collation 設定或應用層處理 |
| N+1 查詢 | 查詢次數爆炸 | 批次 WHERE IN + GROUP BY |
SELECT * | 多餘資料傳輸 | 指定欄位 |
| 不必要的大表 JOIN | 掃描行數暴增 | 只查需要的欄位,移除不必要的 JOIN |
結語
反模式掃蕩的核心不是「把所有 DATE_FORMAT 刪掉」——是系統性地評估每一處,決定改或不改,然後分批安全執行。
一個 10 年的 codebase 中,同一個反模式可能出現 20 次。人工逐一搜尋、評估、修正,需要幾週。Claude Code 可以在幾分鐘內列出所有位置,搭配 EXPLAIN 驗證每一處的影響,然後按優先級分批修正。
但最有價值的不是「找到」和「修正」——是**「判斷哪些不該改」**。form_social_80/81 的例外就是最好的例子:不是所有反模式都是 Bug,有些是在特定限制條件下的合理妥協。Claude Code 能幫你找出來,但判斷要不要改,還是你的工作。