等值運算子(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_NAMELAST_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

一般而言,資料庫能在搜尋前導(最左)欄位時使用串接索引。一個三欄索引可用於:只搜尋第一欄、搜尋前兩欄、或搜尋全部三欄。

因此定義串接索引時,最重要的考量就是欄位順序如何選,才能被越多查詢用到越好

雖然「兩個索引」的方案也能提供很好的查詢效能,但單一索引方案更佳:不但省下儲存空間,也省下第二個索引的維護開銷。一張表的索引越少,insertdeleteupdate 的效能就越好。

為什麼最佳索引只有開發者定得出來#

要定義最佳索引,你必須懂的不只是索引原理,還要知道應用程式如何查詢資料——也就是 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 SCANEMPLOYEES_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 的效率可能在極大的區間內浮動——尤其當它後面跟著資料表存取時。

用到索引,不代表這段敘述已用最好的方式執行。