DATE_FORMAT 反模式掃蕩:用 Claude Code 做 Codebase-wide 的 SQL 模式修正


同一個 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'";

大部分情況就是這麼簡單。日期欄位本身就是 DATEDATETIME 型別,直接比較完全沒問題。

模式二:年份/月份比較

// ❌ 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_80form_social_81DATE_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'

原因:

  1. 跨資料庫 JOIN——$EXT_DB 是外部系統的資料庫,優化空間有限
  2. 資料量不大——這兩張表的資料量遠小於其他表
  3. 改動風險高——跨 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:記錄例外

> 列出所有決定不修的地方和原因,
  加到程式碼註解中。

效能數據總結

指標BeforeAfter改善
EXPLAIN 掃描行數14,091956-93%
審核作業查詢時間17s3.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 能幫你找出來,但判斷要不要改,還是你的工作。