等值運算子(equality operator)既是最瑣碎、也是最常用的 SQL 運算子。即使如此,影響效能的索引錯誤依然非常普遍,而組合多個條件的 where 子句尤其脆弱。
本節說明如何驗證索引的使用情況、串接索引(concatenated index)如何優化組合條件,並實際剖析一個慢查詢,看看第 1 章談的成因在現實中的衝擊。
主鍵查找#
先從最簡單也最常見的 where 子句開始:主鍵查找。本章範例都使用以下 EMPLOYEES 資料表:
CREATE TABLE employees (
employee_id NUMBER NOT NULL,
first_name VARCHAR2(1000) NOT NULL,
last_name VARCHAR2(1000) NOT NULL,
date_of_birth DATE NOT NULL,
phone_number VARCHAR2(1000) NOT NULL,
CONSTRAINT employees_pk PRIMARY KEY (employee_id)
);資料庫會自動為主鍵建立索引——即使沒有任何 create index 敘述,EMPLOYEE_ID 欄位上仍然有索引。
SELECT first_name, last_name
FROM employees
WHERE employee_id = 123由於主鍵約束保證 EMPLOYEE_ID 唯一,where 子句不可能匹配多列。資料庫不需要沿葉節點鏈前進,只要走訪索引樹就夠了。可以用執行計畫(execution plan)驗證:
---------------------------------------------------------------
|Id |Operation | Name | Rows | Cost |
---------------------------------------------------------------
| 0 |SELECT STATEMENT | | 1 | 2 |
| 1 | TABLE ACCESS BY INDEX ROWID| EMPLOYEES | 1 | 2 |
|*2 | INDEX UNIQUE SCAN | EMPLOYEES_PK | 1 | 1 |
---------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
2 - access("EMPLOYEE_ID"=123)執行計畫顯示 INDEX UNIQUE SCAN——只走訪索引樹的操作。它完整發揮索引的對數擴展性,幾乎與資料表大小無關地迅速找到目標。
執行計畫(有時稱 explain plan 或 query plan)呈現資料庫執行一段 SQL 所採取的步驟。附錄 A 說明如何在其他資料庫取得與閱讀執行計畫。
存取索引之後,資料庫還得多做一步,從資料表儲存中抓出查詢的欄位(FIRST_NAME、LAST_NAME),也就是 TABLE ACCESS BY INDEX ROWID 操作。
這個操作可能成為效能瓶頸(見「慢索引」),但搭配
INDEX UNIQUE SCAN時毫無此風險:該操作最多只產出一筆條目,因此最多只觸發一次資料表存取。慢查詢的成分在此並不存在。
延伸:沒有唯一索引的主鍵
主鍵不一定要用唯一索引,也可以用非唯一索引。這種情況下 Oracle 不會使用 INDEX UNIQUE SCAN,而是改用 INDEX RANGE SCAN。不過約束依然維持鍵值的唯一性,所以索引查找最多仍只會產出一筆條目。
為主鍵使用非唯一索引的原因之一,是可延遲約束(deferrable constraint):一般約束在敘述執行期間就驗證,可延遲約束則會延後到交易 commit 時才驗證。要對具有循環相依(circular dependency)的資料表插入資料時,就需要延遲約束。
串接索引:欄位順序決定一切#
即使資料庫會自動為主鍵建立索引,當鍵由多個欄位組成時,仍有手動調校的空間。此時資料庫會在所有主鍵欄位上建立一個索引——即所謂的串接索引(concatenated index,也稱多欄、複合或組合索引)。
串接索引的欄位順序對它的可用性影響極大,必須謹慎選擇。
情境:公司合併#
假設發生一起公司合併,對方公司的員工併入我們的 EMPLOYEES 表,規模變成十倍。問題是 EMPLOYEE_ID 在兩家公司之間並不唯一,於是我們用一個額外識別碼(子公司 ID)擴充主鍵:
CREATE UNIQUE INDEX employee_pk
ON employees (employee_id, subsidiary_id);查詢特定員工時必須帶上完整主鍵:
SELECT first_name, last_name
FROM employees
WHERE employee_id = 123
AND subsidiary_id = 30;---------------------------------------------------------------
|Id |Operation | Name | Rows | Cost |
---------------------------------------------------------------
| 0 |SELECT STATEMENT | | 1 | 2 |
| 1 | TABLE ACCESS BY INDEX ROWID| EMPLOYEES | 1 | 2 |
|*2 | INDEX UNIQUE SCAN | EMPLOYEES_PK | 1 | 1 |
---------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
2 - access("EMPLOYEE_ID"=123 AND "SUBSIDIARY_ID"=30)只要查詢用上完整主鍵,無論索引有幾個欄位,資料庫都能使用 INDEX UNIQUE SCAN。
只用其中一個鍵欄位會怎樣?#
但如果只用其中一個鍵欄位呢?例如搜尋某子公司的所有員工:
SELECT first_name, last_name
FROM employees
WHERE subsidiary_id = 20;----------------------------------------------------
| Id | Operation | Name | Rows | Cost |
----------------------------------------------------
| 0 | SELECT STATEMENT | | 106 | 478 |
|* 1 | TABLE ACCESS FULL| EMPLOYEES | 106 | 478 |
----------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - filter("SUBSIDIARY_ID"=20)執行計畫揭露資料庫根本沒用索引,而是做了全表掃描(FULL TABLE SCAN):讀取整張表,逐列比對 where 子句。
全表掃描的執行時間隨資料表大小成長:表大十倍,掃描就慢十倍。它的危險在於——在小型開發環境往往夠快,到了正式環境卻造成嚴重效能問題。
延伸:全表掃描未必是壞事
TABLE ACCESS FULL 在某些情況下反而是最有效率的操作,尤其是要取回資料表的一大部分時。原因有二:
- 它省去了索引查找本身的額外開銷。
- 更關鍵的是讀取模式:索引查找是一個區塊接一個區塊地讀,因為在處理完當前區塊前,資料庫並不知道下一個該讀哪一塊。而全表掃描反正要取得整張表,於是可以一次讀取較大的區塊(multi block read)。雖然讀進更多資料,卻可能執行更少次讀取操作。
為什麼索引用不上:像電話簿一樣排序#
資料庫用不上索引,是因為它無法任意使用串接索引中的單一欄位。
串接索引就是一般的 B-tree 索引,把被索引資料維持成一份排序清單。資料庫依照欄位在索引定義中的位置來排序:第一欄是主要排序條件,只有當兩筆條目的第一欄相同時,第二欄才決定順序,依此類推。
串接索引是跨多個欄位的一個索引,不是多個索引。
因此雙欄索引的排序就像電話簿:先按姓排,再按名排。這意味著雙欄索引不支援單獨以第二欄搜尋——那等於要在電話簿裡用名字找人。

圖 2.1:串接索引
圖中可見,SUBSIDIARY_ID = 20 的條目並非相鄰存放;樹中根本不存在 SUBSIDIARY_ID = 20 的條目(儘管葉節點裡有)。這棵樹對此查詢毫無用處。
SELECT <INDEX COLUMN LIST> FROM <TABLE> ORDER BY <INDEX COLUMN LIST> FETCH FIRST 100 ROWS ONLY;把索引定義與表名代入,就能得到索引的取樣。接著問自己:所要的列是否叢集在某個集中處?若不是,索引樹就幫不上忙。
反轉欄位順序,一個索引解決兩種查詢#
當然可以再加一個 SUBSIDIARY_ID 索引,但有更好的解法——前提是「單獨以 EMPLOYEE_ID 搜尋」並無意義。
我們可以利用「索引的第一欄永遠可用於搜尋」這個事實。這同樣像電話簿:不需要知道名字也能用姓氏查找。訣竅就是反轉索引欄位順序,把 SUBSIDIARY_ID 放到第一位:
CREATE UNIQUE INDEX EMPLOYEES_PK
ON EMPLOYEES (SUBSIDIARY_ID, EMPLOYEE_ID);兩欄合起來依然唯一,所以帶完整主鍵的查詢仍可使用 INDEX UNIQUE SCAN;但索引條目的順序徹底不同了——SUBSIDIARY_ID 成為主要排序條件,同一子公司的所有條目在索引中連續存放,資料庫因而能用 B-tree 找到它們的位置。
--------------------------------------------------------------
|Id |Operation | Name | Rows | Cost |
--------------------------------------------------------------
| 0 |SELECT STATEMENT | | 106 | 75 |
| 1 | TABLE ACCESS BY INDEX ROWID| EMPLOYEES | 106 | 75 |
|*2 | INDEX RANGE SCAN | EMPLOYEE_PK | 106 | 2 |
--------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
2 - access("SUBSIDIARY_ID"=20)SUBSIDIARY_ID 單獨已不唯一,資料庫必須沿葉節點尋找所有匹配條目,因此使用 INDEX RANGE SCAN。
一般而言,資料庫能在搜尋前導(最左)欄位時使用串接索引。一個三欄索引可用於:只搜尋第一欄、搜尋前兩欄、或搜尋全部三欄。
因此定義串接索引時,最重要的考量就是欄位順序如何選,才能被越多查詢用到越好。
雖然「兩個索引」的方案也能提供很好的查詢效能,但單一索引方案更佳:不但省下儲存空間,也省下第二個索引的維護開銷。一張表的索引越少,insert、delete、update 的效能就越好。
為什麼最佳索引只有開發者定得出來#
要定義最佳索引,你必須懂的不只是索引原理,還要知道應用程式如何查詢資料——也就是 where 子句中出現的欄位組合。
- 外部顧問很難做到,因為他們沒有應用程式存取路徑的全貌,通常一次只能考慮單一查詢,無法挖掘該索引對其他查詢的額外效益。
- 資料庫管理員處境類似:他們或許熟悉 schema,卻缺乏對存取路徑的深入洞察。
技術上的資料庫知識與業務領域的功能知識,唯一交會的地方是開發部門。開發者對資料有感覺、也知道存取路徑,能不費太多力氣就為整體應用建出效益最好的索引。
慢索引(Part II):改索引的副作用#
前面說明了如何藉由改變欄位順序,從既有索引榨出額外效益——但那個例子只考慮了兩段 SQL。改動索引可能影響該表上的所有查詢。本節說明資料庫如何挑選索引,並示範改動既有索引可能的副作用。
調整後的 EMPLOYEE_PK 索引,對所有依 SUBSIDIARY_ID 搜尋的查詢都可用——不管有沒有額外的搜尋條件。這代表它也會被「原本使用另一個索引、匹配 where 子句另一部分」的查詢盯上。當有多條存取路徑可選時,挑出最好的那條就是最佳化工具的職責。
延伸:查詢最佳化工具(query optimizer)
查詢最佳化工具(或稱 query planner)是把 SQL 敘述轉換成執行計畫的資料庫元件,這個過程也叫編譯(compiling)或剖析(parsing)。有兩種類型:
- 成本式最佳化工具(CBO, cost-based optimizer):產生多種執行計畫變體,並為每個計畫計算成本值。成本計算依據所用的操作與估計的列數,最後以成本值為基準挑出「最好」的計畫。
- 規則式最佳化工具(RBO, rule-based optimizer):用一組寫死的規則產生執行計畫。彈性較差,今日已罕用。
案例:合併後變慢的電話簿應用#
改索引也可能帶來不愉快的副作用。在我們的例子中,內部電話簿應用在合併後變得極慢,初步分析指出禍首是這段查詢:
SELECT first_name, last_name, subsidiary_id, phone_number
FROM employees
WHERE last_name = 'WINAND'
AND subsidiary_id = 30;範例 2.1:使用修訂後主鍵索引的執行計畫
---------------------------------------------------------------
|Id |Operation | Name | Rows | Cost |
---------------------------------------------------------------
| 0 |SELECT STATEMENT | | 1 | 30 |
|*1 | TABLE ACCESS BY INDEX ROWID| EMPLOYEES | 1 | 30 |
|*2 | INDEX RANGE SCAN | EMPLOYEES_PK | 40 | 2 |
---------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - filter("LAST_NAME"='WINAND')
2 - access("SUBSIDIARY_ID"=30)它用了索引,總成本 30,看似無妨。但可疑之處在於:它用的正是我們剛改過的索引。舊索引以 EMPLOYEE_ID 開頭,而該欄根本不在 where 子句裡——這段查詢以前根本用不到那個索引。
用 Oracle 的 optimizer hint 禁用該索引,就能還原改動前的計畫:
SELECT /*+ NO_INDEX(EMPLOYEES EMPLOYEE_PK) */
first_name, last_name, subsidiary_id, phone_number
FROM employees
WHERE last_name = 'WINAND'
AND subsidiary_id = 30;----------------------------------------------------
| Id | Operation | Name | Rows | Cost |
----------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 477 |
|* 1 | TABLE ACCESS FULL| EMPLOYEES | 1 | 477 |
----------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - filter("LAST_NAME"='WINAND' AND "SUBSIDIARY_ID"=30)儘管全表掃描必須讀完並處理整張表,在此卻比用索引更快。這特別反常,因為查詢只匹配一列——用索引找單一列理應遠快於全表掃描。
逐步拆解:慢在哪裡#
- 第一步
INDEX RANGE SCAN:EMPLOYEES_PK索引不涵蓋LAST_NAME,因此只能套用SUBSIDIARY_ID這個篩選(見「Predicate Information」的條目 2)。它走訪索引樹找到SUBSIDIARY_ID = 30的第一筆,再沿葉節點鏈找出該子公司的其餘條目,產出一串 ROWID——依子公司規模,可能只有幾筆,也可能好幾百筆。 - 第二步
TABLE ACCESS BY INDEX ROWID:用上一步的 ROWID 逐一從表中抓出整列。唯有拿到LAST_NAME之後,資料庫才能評估 where 子句的其餘部分。
這段敘述的回應時間不取決於結果集大小,而取決於該子公司的員工數。查詢之所以慢,是因為索引查找回傳了大量 ROWID(原公司的每位員工各一筆),而資料庫必須逐筆抓取——這正是讓索引變慢的兩個成分的完美組合:讀取大範圍索引 + 逐列抓取大量資料。
子公司規模小時,INDEX RANGE SCAN 效能較佳;子公司龐大時,全表掃描反而更快,因為它能一口氣讀取表的一大塊。
統計資訊的角色#
挑選最佳執行計畫還取決於資料表的資料分布,因此最佳化工具會使用資料庫內容的統計資訊。本例用到的是「員工在各子公司的分布直方圖」,讓最佳化工具能估算索引查找會回傳幾列,再據此計算成本。
延伸:最佳化工具使用哪些統計資訊
成本式最佳化工具使用關於資料表、欄位與索引的統計資訊。多數統計蒐集在欄位層級:
- 相異值的數量
- 最小值與最大值(資料範圍)
NULL出現次數- 欄位直方圖(資料分布)
資料表最重要的統計值是其大小(列數與區塊數)。索引最重要的統計值則是樹的深度、葉節點數、相異鍵數,以及叢集因子(clustering factor,見第 5 章「資料叢集化」)。
最佳化工具用這些值來估算 where 子句述詞的選擇性(selectivity)。
若統計資訊不存在(例如被刪除),最佳化工具會改用預設值。Oracle 的預設統計假設「小索引、中等選擇性」,因而估出 INDEX RANGE SCAN 只回傳 40 列(見範例 2.1 的 Rows 欄)——這顯然嚴重低估,實際上該子公司有 1000 名員工。
提供正確統計後,最佳化工具表現好得多:
---------------------------------------------------------------
|Id |Operation | Name | Rows | Cost |
---------------------------------------------------------------
| 0 |SELECT STATEMENT | | 1 | 680 |
|*1 | TABLE ACCESS BY INDEX ROWID| EMPLOYEES | 1 | 680 |
|*2 | INDEX RANGE SCAN | EMPLOYEES_PK | 1000 | 4 |
---------------------------------------------------------------成本 680 高於全表掃描的 477,最佳化工具因此會自動改用全表掃描。
但正確的解法仍是建對索引#
這個慢索引的案例不該掩蓋一個事實:適當的索引才是最好的解法。以姓氏搜尋,最好的支援當然是 LAST_NAME 上的索引:
CREATE INDEX emp_name ON employees (last_name);範例 2.2:使用專屬索引的執行計畫
--------------------------------------------------------------
| Id | Operation | Name | Rows | Cost |
--------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 3 |
|* 1 | TABLE ACCESS BY INDEX ROWID | EMPLOYEES | 1 | 3 |
|* 2 | INDEX RANGE SCAN | EMP_NAME | 1 | 1 |
--------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - filter("SUBSIDIARY_ID"=30)
2 - access("LAST_NAME"='WINAND')成本降到 3:索引存取只產出一列,資料庫只需抓取那一列,絕對比全表掃描快。
範例 2.1 與範例 2.2 的執行計畫幾乎一模一樣:相同的操作、相近的成本值,但第二個快得多。
INDEX RANGE SCAN的效率可能在極大的區間內浮動——尤其當它後面跟著資料表存取時。用到索引,不代表這段敘述已用最好的方式執行。