避開效能暗礁:常見 SQL Anti-Patterns 解析與重構實踐
在資料庫驅動的應用系統中,隨著資料量的指數級增長,SQL 查詢往往會從「系統的核心驅動力」轉變為「整體效能的致命瓶頸」。許多在開發初期運作良好的查詢邏輯,本質上潛藏著未考慮資料庫底層執行機制(如索引遍歷、執行計畫生成、I/O 吞吐與記憶體緩衝區配置)的設計缺陷。
本文聚焦於效能層面,深入剖析 6 種最常見且具破壞力的 SQL Anti-Patterns,拆解其背後導致效能衰退的硬體與架構機制,並提供符合現代資料庫實踐的重構方案。
1. 濫用 SELECT *(過度擷取)
在開發或快速除錯時使用 SELECT * 相當直覺,但在生產環境中,這往往是造成無謂 I/O 與網路延遲的首要元兇。
效能問題機制
- 阻礙覆蓋索引(Covering Index): 若查詢僅需特定的幾個欄位,而這些欄位皆存在於某個複合索引中,資料庫引擎只需掃描 B-Tree 索引頁(Index-Only Scan),無需回表(Table Lookup / Bookmark Lookup)讀取主資料檔。使用
SELECT *會強制資料庫執行高成本的回表讀取。 - 增加 I/O 與 Buffer Pool 壓力: 讀取包含巨大文字(
TEXT、BLOB)或不必要的欄位會耗盡資料庫的快取記憶體(Buffer Cache),擠出其他高頻查詢所需的熱資料頁。 - 網路頻寬消耗: 傳輸序列化後的額外資料會直接拉長 API 的 TTFB(Time to First Byte)。
重構方式
明確宣告所需欄位,並為高頻讀取設計適當的覆蓋索引:
-- ❌ Anti-Pattern: 全欄位檢索,強制回表
SELECT *
FROM orders
WHERE customer_id = 1042 AND status = 'COMPLETED';
-- Best Practice: 僅讀取業務所需欄位,搭配複合索引 (customer_id, status, order_id, total_amount)
SELECT order_id, total_amount, created_at
FROM orders
WHERE customer_id = 1042 AND status = 'COMPLETED';
2. 條件欄位運算導致索引失效(Non-Sargable Queries)
SARGable(Search Argument Able)代表查詢條件能夠有效利用索引進行範圍掃描(Index Range Scan)或點查(Index Seek)。對條件欄位進行函數運算或型態轉換是開發中最常見的效能陷阱。
效能問題機制
B-Tree 索引是根據原始欄位值進行排序存儲的。一旦在 WHERE 條件中對索引欄位施加函數運算(如 YEAR(date))或字串比對(如 SUBSTRING(code, 1, 3)),查詢優化器(Query Optimizer)將無法預先得知函數計算後的排序關係,只能被迫退化為全表掃描(Full Table Scan)或全索引掃描(Index Full Scan),對每一筆記錄逐一計算。
重構方式
將計算邏輯移至常數端,保持欄位本身的純粹性:
-- ❌ Anti-Pattern: 對索引欄位施加函數,導致全表掃描
SELECT id, transaction_amount
FROM transactions
WHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 8;
-- Best Practice: 轉換為開閉區間的常數比對,觸發 Index Seek
SELECT id, transaction_amount
FROM transactions
WHERE created_at >= '2026-08-01 00:00:00'
AND created_at < '2026-09-01 00:00:00';
注意: 隱式型別轉換(Implicit Type Conversion,例如在字串型別的
phone欄位上使用數字比對WHERE phone = 0912345678)底層會自動包裹型別轉換函數,同樣會徹底破壞索引。
3. 前置模糊搜尋(Leading Wildcard Like)
在文字搜尋情境中,直接使用萬用字元 % 開頭進行比對是效能崩潰的經典來源。
效能問題機制
標準 B-Tree 索引按照字元的前綴字母順序構建。使用前置萬用字元(如 LIKE '%keyword' 或 LIKE '%keyword%')會使前綴資訊完全丟失,資料庫無法利用 B-Tree 的二分搜尋特性定位節點,僅能執行高成本的全表循序掃描。
重構方式
- 若業務需求僅為前綴匹配,移除開頭的萬用字元:
-- ❌ Anti-Pattern: 全表掃描
SELECT id, email FROM users WHERE email LIKE '%@example.com';
-- Best Practice (若支援後綴查詢情境):
-- 1. 改為精準前綴比對
SELECT id, username FROM users WHERE username LIKE 'dev_%';
-- 2. 或建立反轉字串索引(Reverse Index)優化後綴比對
SELECT id, email FROM users WHERE reversed_email LIKE 'moc.elpmaxe@%';
- 若必須支援任意子字串全文搜尋,應引入全文檢索(Full-Text Search, FTS)引擎(如 PostgreSQL GIN 索引、MySQL Full-Text,或外部分散式搜尋引擎 Elasticsearch / OpenSearch),而非依賴關聯式資料庫的
LIKE運算。
4. 深度分頁的 OFFSET 效能衰退(Deep Pagination)
傳統的分頁方式常採用 LIMIT offset, count 語法,但在巨量資料集下,頁數越深,查詢耗時會呈線性甚至指數級增長。
效能問題機制
當執行 LIMIT 1000000, 20 時,資料庫並非「跳過」前 100 萬筆資料,而是必須先掃描並依序讀取 1,000,020 筆記錄到記憶體中,排序後捨棄前 1,000,000 筆,僅返回最後 20 筆。這產生了極其龐大且完全無效的磁碟與記憶體 I/O。
重構方式:Seek Method / Keysize Pagination
採用游標分頁(Cursor-based Pagination),利用唯一且具遞增性的索引鍵(如 id 或時間戳記)作為錨點:
-- ❌ Anti-Pattern: 深度分頁造成大量無效掃描
SELECT id, title, created_at
FROM articles
ORDER BY id DESC
LIMIT 20 OFFSET 1000000;
-- Best Practice: 利用上一頁最後一筆 ID 進行條件篩選,直接由索引定位
SELECT id, title, created_at
FROM articles
WHERE id < 984520 -- 上一頁最後一筆記錄的 ID
ORDER BY id DESC
LIMIT 20;
5. 迴圈中的 N+1 查詢(N+1 Query Problem)
此問題常伴隨著 ORM(Object-Relational Mapping)的關聯讀取機制發生,例如在遍歷主資料表的每一行時,內部獨立觸發一次次要資料表的查詢。
效能問題機制
即使單次查詢極快(例如 1 毫秒),當主集合有 $N$ 筆記錄時,系統會額外執行 $N$ 次 SQL 呼叫。這會急遽放大網路往返延遲(Network Round-Trip Time, RTT)、連線池排隊時間與資料庫執行緒上下文切換(Context Switching)成本。
重構方式
利用單次關聯查詢(JOIN)或批次聚合(IN (...))取代多次往返:
-- ❌ Anti-Pattern (偽代碼): 1 次取列表 + N 次取關聯資料
-- Query 1: SELECT id FROM users WHERE role = 'ENGINEER';
-- Loop (N 次): SELECT * FROM user_profiles WHERE user_id = ?;
-- Best Practice 1: 單次 JOIN 聚合
SELECT u.id, u.email, p.bio, p.avatar_url
FROM users u
LEFT JOIN user_profiles p ON u.id = p.user_id
WHERE u.role = 'ENGINEER';
-- Best Practice 2: 批次 IN 檢索(ORM 常用的 Eager Loading 機制)
SELECT * FROM user_profiles WHERE user_id IN (101, 102, 103, ...);
6. 在子查詢中使用低效的 NOT IN
在進行集合排他性過濾時,NOT IN 語法不僅可讀性脆弱,在包含 NULL 值的資料集中更容易產生災難性的效能表現。
效能問題機制
- 三值邏輯(Three-Valued Logic)陷阱: SQL 中的 NULL 代表未知(Unknown)。若
NOT IN (Subquery)的子查詢結果集中包含任何一個NULL,整體比對結果將永遠評估為UNKNOWN/FALSE。 - 無法優化為 Anti-Join: 為了遵循 NULL 的語言規範,查詢優化器往往無法將
NOT IN轉換為高效的 Hash Anti-Join 或 Merge Anti-Join,而被迫退化為低效的相關子查詢嵌套循環(Correlated Nested Loop),複雜度高達 $O(M \times N)$。
重構方式
改用 NOT EXISTS 或 LEFT JOIN ... WHERE ... IS NULL,讓優化器能夠安全採用 Anti-Join 演算法:
-- ❌ Anti-Pattern: NOT IN 遇到 NULL 會產生語意問題且通常無法優化
SELECT id, name
FROM customers
WHERE id NOT IN (SELECT customer_id FROM blacklist);
-- Best Practice: 使用 NOT EXISTS(短路運算,且對 NULL 免疫)
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM blacklist b
WHERE b.customer_id = c.id
);
效能重構對照表
| Anti-Pattern | 根本問題 | 效能影響 | 推薦重構方案 |
|---|---|---|---|
SELECT * |
讀取無用欄位、破壞覆蓋索引 | 額外 I/O、記憶體快取被稀釋 | 明確宣告必要欄位,建立覆蓋索引 |
| Non-Sargable 條件 | 欄位被函數運算或隱式轉型包裹 | 索引失效,退化為全表/全索引掃描 | 將運算移至常數端,保持欄位純粹 |
**前置萬用字元 LIKE** |
缺乏前綴資訊,無法利用 B-Tree | 強制全表循序讀取 | 改用精準前綴、反轉索引或全文檢索 |
OFFSET 深度分頁 |
必須掃描並拋棄前面所有偏移記錄 | 高偏移量下 I/O 線性倍增 | 改用 Seek Method(游標錨點分頁) |
| N+1 查詢 | 應用程式層在迴圈中逐筆發送 SQL | 網路 RTT 爆炸、連線池阻塞 | 改用 JOIN 或批次 IN (...) 擷取 |
NOT IN (Subquery) |
NULL 語意限制導致無法走 Anti-Join | 演算法退化至嵌套迴圈比對 | 改用 NOT EXISTS 或 LEFT JOIN ... IS NULL |
結語
SQL 效能優化的核心原則,始終在於減少無效的資料塊讀取(Logical/Physical Reads)與充分利用索引的排序結構。在撰寫查詢時,多利用資料庫提供的 EXPLAIN ANALYZE 或執行計畫工具,檢視是否有非預期的 Seq Scan、Temporary Files 或過高的成本預估,從架構與語法源頭杜絕 Anti-Patterns,才能確保系統在高併發與海量資料下依舊維持穩定的低延遲回應。