本文譯自《Query Planning》
概述
SQL最好的特性就是它是一種描述性語言,而不是過程式語言。不光是SQLite,在所有的SQL實現(xiàn)中它都是這樣。當你編寫SQL時,你告訴系統(tǒng)你想要得到的結(jié)果,而不是如何去計算它。決定“如何計算結(jié)果”這件事由數(shù)據(jù)庫引擎中的查詢計劃器來完成。
對于每一條給定的SQL語句,可能有成百上千種不同的算法可以實現(xiàn)它。盡管每種算法都能得出正確結(jié)果,但有些算法比其他的運行的更快。查詢計劃器是一個AI,它盡量為每條SQL語句找出最快最高效的算法。
大多數(shù)情況下,SQLite的查詢計劃器工作的很好。然而,查詢計劃器需要與索引配合,這些索引通常必須由程序員指定。查詢計劃器在個別時候也會得出次優(yōu)解,這時程序員可能希望提供額外的提示來指導查詢計劃器的工作。
這篇文檔提供了關(guān)于SQLite查詢計劃器和查詢引擎工作原理的背景知識。程序員可以使用這些信息創(chuàng)建出更有效的索引,或在必要時向查詢計劃器發(fā)出提示。
更多的信息請參考 SQLite query planner和next generation query planner等文檔。
1. 搜索
1.1 不加索引的表
SQLite中絕大多數(shù)表都包含一個unique列,如 rowid 或INTEGER PRIMARY KEY,有一類表是例外,那就是無ROWID的表。邏輯上講,各個行按rowid的升序存儲。本文以FruitsForSale
這個表為例,這個表記錄了各種水果的名稱,產(chǎn)地和市價。表的schema如下:
CREATE TABLE FruitsForSale(
Fruit TEXT,
State TEXT,
Price REAL
);
我們向表中插入一些數(shù)據(jù)(純屬虛構(gòu)),這張表的邏輯存儲結(jié)構(gòu)如圖1:
在這個例子中,rowid不是連續(xù)的,但是是有序的。SQLite通常從1開始創(chuàng)建rowid,并每次自增1。但如果行被刪除了,rowid之間可能出現(xiàn)空隙。應(yīng)用層可以選擇控制rowid的分配,所以新的行不一定會被添加到最底部。不過無論如何,rowid是唯一的,并保持升序。
假如你想要查詢peach的價格,那么SQL語句如下所示:
SELECT price FROM fruitsforsale WHERE fruit='Peach';
為了滿足這個查詢,SQLite讀取表中的每一行,檢查fruit
列是否為Peach
,如果是則輸出對應(yīng)的price。這個過程如圖2所示。
這個算法被稱為全表掃描,因為必須遍歷表才能找到感興趣的行。對于只有7行的表來說,全表掃描可以接受,但如果表包含7百萬行的話,全表掃描為了找出幾字節(jié)的數(shù)就要讀取幾M字節(jié)。因此,我們要盡力避免全表掃描。
1.2 按rowid查找
一種避免全表掃描的技術(shù)是按rowid查找(或者按 INTEGER PRIMARY KEY查找,它們等價)。為了查找peach的價格,我們查詢rowid為4的行:
SELECT price FROM fruitsforsale WHERE rowid=4;
因為信息按照rowid順序存儲在表中,SQLite可以用二分法找出正確的行。對于N行的表,可以用O(logN)的時間查出所需要的行,而全表掃描需要O(N)的時間。如果表包含一千萬條數(shù)據(jù),按rowid查找快了大約一百萬倍。
1.3 按索引查找
按rowid查找的問題就是,我們想查的是peach的價格,誰知道peach的rowid是不是4呢?
為了讓最初的查詢更有效率,我們可以為fruit
列增加索引:
CREATE INDEX Idx1 ON fruitsforsale(fruit);
索引是與原fruitsforsale
表相似的另一張表,但是被索引內(nèi)容(這個例子中是fruit
列)被放在rowid前邊,并且所有的行按被索引列排序。圖4給出了Idx1的邏輯視圖。
fruit
列是用來給元素排序的主鍵,而rowid
是在fruit
相同時給元素排序的次級鍵。注意,因為rowid
是唯一的,因此rowid
和fruit
組成的復合鍵也是唯一的。
新的索引可以被用作實現(xiàn)一個更快的查找peach價格的算法。
一開始,先對Idx1索引進行fruit=‘Peach’的二分查詢,SQLite能在Idex1上進行二分查詢,是因為Idex1是按照fruit列排序的。在Idx1查找到fruit=‘Peach’的行之后,數(shù)據(jù)庫引擎可以提取出rowid。然后數(shù)據(jù)庫引擎根據(jù)rowid,對原表再進行一次二分查找。最后根據(jù)原表的行,可以得到Peach對應(yīng)的price。這個過程如圖5所示:
使用上邊的方法,SQLite必須使用兩次二分查找來查出peach的價格。然而對于一個數(shù)據(jù)量大的表來說,這仍比全表掃描快很多。
1.4 多行結(jié)果
前一個查詢中,fruit=‘Peach’這個約束只有一行滿足。但是當結(jié)果為多行時,也可以使用相同的技術(shù)。假設(shè)我們現(xiàn)在查找Oranges的價格
SELECT price FROM fruitsforsale WHERE fruit='Orange'
在這種情況下,SQLite仍然使用一次二分查找來搜索第一個fruit=‘Orange’的行。然后它根據(jù)rowid回原表中查找價格。不過與原先不同,數(shù)據(jù)庫引擎讀取下一行,并將回原表查詢的操作重復一遍。讀取索引的下一行的開銷遠遠低于一次二分查找,以至于可以忽略不計,因為下一行往往與剛讀取的行在相同的頁中。所以我們估計總開銷為3次二分查找。如果有K行結(jié)果,那么查詢的開銷為(K+1)*logN。
1.5 多個AND連接的WHERE子句
接下來,假設(shè)你不管要查Orange的價格,而且要求Orange的產(chǎn)地是California。SQL語句如下所示:
SELECT price FROM fruitsforsale WHERE fruit='Orange' AND state='CA'
一種可選方案是先找出所有Orange行,然后再過濾產(chǎn)地不為CA的行,這個過程如圖7所示
大多數(shù)情況下,這是個很合理的方案。不過數(shù)據(jù)庫引擎必須為Florida的Orange做額外的二分查找,所以可能沒有我們期望的那么快,盡管對多數(shù)應(yīng)用來說,這足夠快了。
假設(shè)我們再為state創(chuàng)建索引:
CREATE INDEX Idx2 ON fruitsforsale(state);
state
索引和fruit
索引的原理相同,只不過主鍵變成了state。在我們的例子中,state列的取值比較少,因此索引中每個state對應(yīng)的行較多。
使用新的Idx2索引,SQLite有了更多選擇:查出所有產(chǎn)自CA的水果,然后從其中過濾出Orange。
使用Idx2代替Idx1導致SQLite得出了不同的行集,但是最終結(jié)果是相同的。這非常重要,索引不改變查詢結(jié)果,只是加快查詢速度。這里使用Idx1和使用Idx2的查詢時間相同,所以Idx2并沒有提升性能。
最終兩個查詢花費了相同的時間,那么SQLite最終會選擇哪個索引呢?如果ANALYZE命令在數(shù)據(jù)庫上執(zhí)行,那么SQLite會獲得一個收集索引數(shù)據(jù)的機會,然后SQLite會知道Idx1通常將查詢范圍縮小到1行,而Idx2通常將查詢范圍縮小到2行。所以,如果沒有其他條件,SQLite將會選擇Idx1,因為它通常會讓搜索范圍變得更小。
這個選擇不是確定的,因為它依賴ANALYZE提供的數(shù)據(jù)。如果ANALYZE沒有運行,那這個選擇就是隨意的。
1.6 多列索引
為了實現(xiàn)多個AND連接的WHERE子句的最大性能,你十分需要一個多列索引來覆蓋每一個AND子句。這種情況下,我們創(chuàng)建在fruit
列和state
列上創(chuàng)建一個新的索引。
CREATE INDEX Idx3 ON FruitsForSale(fruit, state);
多列索引和單列索引的形式相同,都是rowid跟在被索引列后邊。唯一的區(qū)別是現(xiàn)在被索引列有兩個。最左邊的列是用來給整個索引排序的主鍵。因為rowid確保是唯一的,所以即使所有除rowid的列都相同,也能保證索引行的唯一性。
使用新的多列索引Idx3,SQLite有可能只用兩次二分查找就能找出CA的Orange。
因為Idx3包含了所有WHERE子句中要查找的列,所以SQLite可以用一次二分查找搜索出一個rowid,然后回原表再做一次二分查找。這里沒有無效的查找,因此效率更高。
注意,這里Idx3包含了Idx1的所有信息,所以在Idx3存在的情況下,我們不再需要Idx1。如果要查找Peach的價格,我們只需要使用Idx3,并忽略它的“state”列。
SELECT price FROM fruitsforsale WHERE fruit='Peach';
由此可見,數(shù)據(jù)庫設(shè)計的一條原則是,schema不應(yīng)該包含兩個這樣的索引,其中一個索引是另一個索引的前綴。刪除其中列較少的索引,SQLite仍能高效地查找。
1.7 覆蓋索引
通過雙列索引,查詢California Orange的價格變得更快。但是如果創(chuàng)建一個包含price的三列索引,SQLite可以做的更快。
CREATE INDEX Idx4 ON FruitsForSale(fruit, state, price);
新的索引包含了查詢所要用到的所有列,WHERE子句中的和要輸出的。我們稱這種索引為覆蓋索引。使用這種索引時,SQLite不需要回原表查詢。
SELECT price FROM fruitsforsale WHERE fruit='Orange' AND state='CA';
由此可見,將被輸出的列也加入索引,我們可以避免回原表查詢的操作,來讓二分查找的次數(shù)減半。這可以讓查詢速度獲得大概兩倍的提升。但是另一方面,這只是一個細微的優(yōu)化,兩倍的提升并不像剛加索引時百萬倍的提升那樣戲劇性。對大多數(shù)查詢來說,1ms和2ms的開銷沒什么差別。
1.8 多個OR連接的WHERE子句
多列索引只在約束條件用AND連接時起作用。所以Idx3和Idx4只在搜索既是Orange,又生長在California的水果時有用。如果我們想要查找Orange,或者生長在California的水果,這兩個索引則起不到作用。
SELECT price FROM FruitsForSale WHERE fruit='Orange' OR state='CA';
當處理OR連接的WHERE子句時,SQLite檢查每個被OR連接的條件,并且盡力對每個子句都使用索引。然后對各個條件查出的rowid取并集。這個過程如下圖所示:
上圖顯示了SQLite先計算各個組的rowid,然后對它們?nèi)〔⒓詈蠡卦聿樵儭嶋H上rowid查找和求并集是交替進行的。SQLite一次使用一個索引來查找rowid,同時將它們標記為已查過,來避免之后出現(xiàn)重復。當然,這只是一個實現(xiàn)細節(jié)。上圖很好地概述了算法過程,雖然不是完全精確。
為了讓上邊的取并集技術(shù)(OR-by-UNION)奏效,每個被OR連接的WHERE子句必須要有一個索引。即使只有一個子句沒被索引,那么為了找出這個子句對應(yīng)的rowid,SQLite必須進行全表掃描。如果要進行全表掃描,那么之前那些并集,二分查找操作也就么必要了,直接在全表掃描時全都查出來就行了。
我們可以發(fā)現(xiàn),通過取并集實現(xiàn)OR(OR-by-UNION)的技術(shù)可以被推廣到AND。被AND連接的WHERE子句可以使用多個索引查詢rowid,然后取交集。許多數(shù)據(jù)庫引擎就是這么做的。但是這比只是用一個索引的性能提升很小,所以SQLite目前沒有實現(xiàn)這種技術(shù)。然而,SQLite未來版本可能會支持取交集實現(xiàn)AND(AND-by-INTERSECT)。
2. 排序
除了加快查找,SQLite以及其他的SQL數(shù)據(jù)庫引擎也可以使用索引來完成ORDER BY子句。換句話說,索引除了能加快搜索,也能加快排序。
當沒有合適的索引可以用時,一個帶ORDER BY的查詢必須分步驟進行排序。考慮如下的查詢:
SELECT * FROM fruitsforsale ORDER BY fruit;
SQLite先查出所有輸出,然后通過sorter對結(jié)果進行排序。
如果輸出行數(shù)為K,排序的復雜度為K*logK。如果K很小,那么排序的時間無關(guān)緊要,但如果像上邊這樣,K等于表的總行數(shù),那么排序花費的時間會遠大于全表掃描的時間。此外,整個輸出的中間結(jié)果被存在臨時存儲中(可能是硬盤,也可能是內(nèi)存,取決于許多編譯時和運行時的設(shè)置),這意味著查詢將消耗大量臨時存儲空間。
2.1 按rowid排序
因為排序操作開銷很大,SQLite會努力避免排序。如果SQLite能確定輸出結(jié)果已經(jīng)按照指定的方式排序,那么它不會在進行排序操作。所以,比如你希望輸出按照rowid的順序,那么排序操作就不會執(zhí)行。
SELECT * FROM fruitsforsale ORDER BY rowid;
你也可以要求按rowid的逆序輸出,像這樣:
SELECT * FROM fruitsforsale ORDER BY rowid DESC;
SQLite仍會忽略排序操作。不過為了逆序輸出,SQLite將會從后向前掃描表,而不是像圖17那樣從前到后。
2.2 按索引排序
當然,將輸出按rowid排序通常沒什么用。通常我們希望結(jié)果能按照其他的某一列排序。
如果ORDER BY制定的列上有索引,那么索引可以用來排序。考慮如下的查詢:
SELECT * FROM fruitsforsale ORDER BY fruit;
為了查出按fruit排序的rowid,Idx1索引被從前到后掃描(如果有DESC,那么就是從后到前掃描)。對于每一個rowid,都需要用一次二分查詢來查出對應(yīng)的行。通過這種方式,不需要額外的排序步驟就能使結(jié)果按順序輸出。
但這真的節(jié)省了時間么?如果我們不使用索引,全表排序的復雜度為NlogN,因為他要為N個數(shù)據(jù)排序。然而當我們使用Idx1時,我們必須要做N次復雜度為logN的二分查找,所以復雜度仍然是NlogN。
SQLite使用基于開銷的查詢計劃器。當有兩個或更多可選方案時,SQLite試圖分析各個方案的總開銷,然后選擇開銷最低的方案。開銷的計算主要考慮運行時間,它可能根據(jù)表的大小和WHERE子句的情況而變化。但通常來講,索引排序可能會被選擇。因為它不需要將結(jié)果集存進臨時存儲中,因而空間開銷更小。
2.3 使用覆蓋索引排序
如果能使用覆蓋索引,那么多次根據(jù)rowid回原表查詢的操作就能避免,開銷會產(chǎn)生戲劇性的降低。
通過一個覆蓋索引,SQLite可以直接將索引從頭到尾遍歷一遍,然后輸出結(jié)果,這只需要O(N)的開銷,也不需要大量的額外存儲。