SQLlite查詢計劃

本文譯自《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 plannernext generation query planner等文檔。

1. 搜索

1.1 不加索引的表

SQLite中絕大多數(shù)表都包含一個unique列,如 rowidINTEGER 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:

圖1:FruitsForSale表的邏輯結(jié)構(gòu)

在這個例子中,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所示。

圖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查找快了大約一百萬倍。

image.png

1.3 按索引查找

按rowid查找的問題就是,我們想查的是peach的價格,誰知道peach的rowid是不是4呢?

為了讓最初的查詢更有效率,我們可以為fruit列增加索引:

CREATE INDEX Idx1 ON fruitsforsale(fruit);

索引是與原fruitsforsale表相似的另一張表,但是被索引內(nèi)容(這個例子中是fruit列)被放在rowid前邊,并且所有的行按被索引列排序。圖4給出了Idx1的邏輯視圖。

圖4:fruit列的索引

fruit列是用來給元素排序的主鍵,而rowid是在fruit相同時給元素排序的次級鍵。注意,因為rowid是唯一的,因此rowidfruit組成的復合鍵也是唯一的。

新的索引可以被用作實現(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所示:

圖5:使用索引查找Peach的價格

使用上邊的方法,SQLite必須使用兩次二分查找來查出peach的價格。然而對于一個數(shù)據(jù)量大的表來說,這仍比全表掃描快很多。

1.4 多行結(jié)果

前一個查詢中,fruit=‘Peach’這個約束只有一行滿足。但是當結(jié)果為多行時,也可以使用相同的技術(shù)。假設(shè)我們現(xiàn)在查找Oranges的價格

SELECT price FROM fruitsforsale WHERE fruit='Orange'
圖6:使用索引查找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所示

圖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。

圖9

使用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);
圖10:一個兩列索引

多列索引和單列索引的形式相同,都是rowid跟在被索引列后邊。唯一的區(qū)別是現(xiàn)在被索引列有兩個。最左邊的列是用來給整個索引排序的主鍵。因為rowid確保是唯一的,所以即使所有除rowid的列都相同,也能保證索引行的唯一性。

使用新的多列索引Idx3,SQLite有可能只用兩次二分查找就能找出CA的Orange。

圖11

因為Idx3包含了所有WHERE子句中要查找的列,所以SQLite可以用一次二分查找搜索出一個rowid,然后回原表再做一次二分查找。這里沒有無效的查找,因此效率更高。

注意,這里Idx3包含了Idx1的所有信息,所以在Idx3存在的情況下,我們不再需要Idx1。如果要查找Peach的價格,我們只需要使用Idx3,并忽略它的“state”列。

SELECT price FROM fruitsforsale WHERE fruit='Peach';
圖12

由此可見,數(shù)據(jù)庫設(shè)計的一條原則是,schema不應(yīng)該包含兩個這樣的索引,其中一個索引是另一個索引的前綴。刪除其中列較少的索引,SQLite仍能高效地查找。

1.7 覆蓋索引

通過雙列索引,查詢California Orange的價格變得更快。但是如果創(chuàng)建一個包含price的三列索引,SQLite可以做的更快。

CREATE INDEX Idx4 ON FruitsForSale(fruit, state, price);
圖13

新的索引包含了查詢所要用到的所有列,WHERE子句中的和要輸出的。我們稱這種索引為覆蓋索引。使用這種索引時,SQLite不需要回原表查詢。

SELECT price FROM fruitsforsale WHERE fruit='Orange' AND state='CA';
圖14

由此可見,將被輸出的列也加入索引,我們可以避免回原表查詢的操作,來讓二分查找的次數(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取并集。這個過程如下圖所示:

圖15

上圖顯示了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é)果進行排序。

圖16

如果輸出行數(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;
圖17

你也可以要求按rowid的逆序輸出,像這樣:

SELECT * FROM fruitsforsale ORDER BY rowid DESC;

SQLite仍會忽略排序操作。不過為了逆序輸出,SQLite將會從后向前掃描表,而不是像圖17那樣從前到后。

2.2 按索引排序

當然,將輸出按rowid排序通常沒什么用。通常我們希望結(jié)果能按照其他的某一列排序。

如果ORDER BY制定的列上有索引,那么索引可以用來排序。考慮如下的查詢:

SELECT * FROM fruitsforsale ORDER BY fruit;
圖18

為了查出按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)生戲劇性的降低。

圖19

通過一個覆蓋索引,SQLite可以直接將索引從頭到尾遍歷一遍,然后輸出結(jié)果,這只需要O(N)的開銷,也不需要大量的額外存儲。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,362評論 6 544
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,577評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,486評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,852評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,600評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,944評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,944評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 43,108評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,652評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 41,385評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,616評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,111評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,798評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,205評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,537評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,334評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,570評論 2 379

推薦閱讀更多精彩內(nèi)容