過時文件警告:此文件說明 SQLite 版本 2.8.0 中使用的虛擬機器。SQLite 版本 3.0 和 3.1 中的虛擬機器在概念上類似,但現在是基於暫存器而非基於堆疊,每個操作碼有五個運算元而非三個,且有一組與以下所示不同的操作碼。請參閱 虛擬機器指令 文件以取得目前的 VDBE 操作碼組,以及 VDBE 操作方式的簡要概述。此文件保留作為歷史參考。
如果您想了解 SQLite 函式庫的內部運作方式,您需要先對虛擬資料庫引擎或 VDBE 有深入的了解。VDBE 出現在處理串流的正中央(請參閱 架構圖),因此它似乎會觸及函式庫的大部分。即使不直接與 VDBE 互動的程式碼部分通常也扮演支援角色。VDBE 確實是 SQLite 的核心。
本文簡要介紹 VDBE 的運作方式,特別是說明各種 VDBE 指令(在此 說明)如何協同運作,以利用資料庫執行有用的工作。本文採用教學風格,從簡單的任務開始,逐步解決更複雜的問題。在此過程中,我們將拜訪 SQLite 函式庫中的大多數子模組。完成本教學課程後,您應能相當了解 SQLite 的運作方式,並準備好開始研究實際的原始程式碼。
VDBE 實作一個虛擬電腦,在虛擬機器語言中執行程式。每個程式的目標都是查詢或變更資料庫。為此,VDBE 實作的機器語言特別設計用於搜尋、讀取和修改資料庫。
VDBE 語言的每個指令包含一個操作碼和三個標記為 P1、P2 和 P3 的運算元。運算元 P1 是任意整數。P2 是非負整數。P3 是指向資料結構或以零終止的字串的指標,可能為空值。只有少數 VDBE 指令使用所有三個運算元。許多指令只使用一個或兩個運算元。大量的指令根本不使用任何運算元,而是取得其資料並將其結果儲存在執行堆疊中。每個指令執行的詳細資料和它使用的運算元說明在個別的 操作碼說明 文件中說明。
VDBE 程式從指令 0 開始執行,並繼續執行後續指令,直到它 (1) 遇到致命錯誤,(2) 執行暫停指令,或 (3) 將程式計數器推進到程式的最後一個指令之後。當 VDBE 完成執行時,所有開啟的資料庫游標都會關閉,所有記憶體都會釋放,而且所有內容都會從堆疊中彈出。因此,永遠不用擔心記憶體外洩或未配置資源。
如果您曾進行過任何組合語言程式設計或曾使用過任何抽象機器,所有這些詳細資料都應該很熟悉。因此,讓我們直接開始查看一些程式碼。
我們從一個問題開始,這個問題可以使用只有幾個指令長的 VDBE 程式解決。假設我們有一個 SQL 表格,它是這樣建立的
CREATE TABLE examp(one text, two int);
換句話說,我們有一個名為「examp」的資料庫表格,它有兩個名為「one」和「two」的資料欄。現在假設我們要將單一記錄插入這個表格中。像這樣
INSERT INTO examp VALUES('Hello, World!',99);
我們可以使用 sqlite 命令列工具來查看 SQLite 用於實作此 INSERT 的 VDBE 程式。首先在一個新的空資料庫上啟動 sqlite,然後建立資料表。接著,將 sqlite 的輸出格式變更為適合 VDBE 程式傾印的格式,方法是輸入「.explain」指令。最後,輸入上面顯示的 [INSERT] 陳述式,但要在 [INSERT] 之前加上特殊關鍵字 [EXPLAIN]。[EXPLAIN] 關鍵字會導致 sqlite 列印 VDBE 程式,而不是執行它。我們有
$ sqlite test_database_1
sqlite> CREATE TABLE examp(one text, two int);
sqlite> .explain
sqlite> EXPLAIN INSERT INTO examp VALUES('Hello, World!',99);
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 Transaction 0 0
1 VerifyCookie 0 81
2 Transaction 1 0
3 Integer 0 0
4 OpenWrite 0 3 examp
5 NewRecno 0 0
6 String 0 0 Hello, World!
7 Integer 99 0 99
8 MakeRecord 2 0
9 PutIntKey 0 1
10 Close 0 0
11 Commit 0 0
12 Halt 0 0
如您在上方所見,我們的簡單插入陳述式在 12 個指令中實作。前 3 個和最後 2 個指令是標準的序幕和尾聲,因此實際的工作是在中間的 7 個指令中完成。沒有跳躍,因此程式從頭到尾執行一次。現在讓我們詳細檢視每個指令。
0 Transaction 0 0
1 VerifyCookie 0 81
2 交易 1 0
指令 交易 開始一筆交易。當遇到提交或回滾指令碼時,交易結束。P1 是開始交易的資料庫檔案索引。索引 0 是主資料庫檔案。開始交易時,會取得資料庫檔案的寫入鎖定。在交易進行時,其他程序無法讀取或寫入檔案。開始交易也會建立回滾日誌。在對資料庫進行任何變更之前,必須先開始交易。
指令 驗證 Cookie 檢查 Cookie 0(資料庫架構版本),以確保它等於 P2(上次讀取資料庫架構時取得的值)。P1 是資料庫編號(主資料庫為 0)。這樣做是為了確保資料庫架構未被其他執行緒變更,否則必須重新讀取。
第二個 交易 指令開始一筆交易,並為暫時資料表所使用的資料庫 1 開始一個回滾日誌。
3 整數 0 0
4 開啟寫入 0 3 範例
指令 整數 將整數值 P1 (0) 推入堆疊中。這裡 0 是在下列開啟寫入指令中要使用的資料庫編號。如果 P3 不為 NULL,則它是同一個整數的字串表示法。之後,堆疊看起來像這樣
(整數) 0
指令 OpenWrite 在根頁面為 P2 (3,在此資料庫檔案中) 的「examp」表格上開啟一個新的讀取/寫入游標,其處理序為 P1 (此例中為 0)。游標處理序可以是任何非負整數。但 VDBE 會在陣列中配置游標,陣列大小會比最大的游標大一。因此,為了節省記憶體,最好從 0 開始使用處理序,並連續向上使用。在此,P3 («examp») 是要開啟的表格名稱,但這沒有使用,而且只產生出來以讓程式碼更容易閱讀。此指令會從堆疊頂端彈出要使用的資料庫號碼 (0,主資料庫),因此之後堆疊會再次清空。
5 NewRecno 0 0
指令 NewRecno 為游標 P1 指向的表格建立新的整數記錄號碼。記錄號碼是目前未用於表格中作為金鑰的號碼。新的記錄號碼會推入堆疊。之後堆疊會如下所示
(整數) 新記錄金鑰
6 String 0 0 Hello, World!
指令 String 會將其 P3 運算元推入堆疊。之後堆疊會如下所示
(字串) "Hello, World!" (整數) 新記錄金鑰
7 Integer 99 0 99
指令 Integer 會將其 P1 運算元 (99) 推入堆疊。之後堆疊會如下所示
(整數) 99 (字串) "Hello, World!" (整數) 新記錄金鑰
8 MakeRecord 2 0
指令 MakeRecord 會將堆疊頂端的 P1 元素彈出(此範例中為 2 個),並將它們轉換成資料庫檔案中用於儲存記錄的二進位格式。(有關詳細資訊,請參閱 檔案格式 說明。)MakeRecord 指令產生的新記錄會推回堆疊。之後,堆疊會如下所示
(record) "Hello, World!", 99 (整數) 新記錄金鑰
9 PutIntKey 0 1
指令 PutIntKey 會使用堆疊頂端的 2 個項目,將項目寫入游標 P1 指向的資料表。如果項目不存在,則會建立新項目,或覆寫現有項目的資料。記錄資料是堆疊頂端的項目,而金鑰是下一個項目。此指令會將堆疊彈出兩次。由於運算元 P2 為 1,因此會增加列變更次數,並儲存 rowid 以供 sqlite_last_insert_rowid() 函數後續傳回。如果 P2 為 0,則列變更次數不會變更。此指令是實際執行插入的指令。
10 Close 0 0
指令 Close 會關閉先前以 P1 開啟的游標(0,唯一開啟的游標)。如果 P1 目前未開啟,則此指令為空指令。
11 Commit 0 0
指令 Commit 會讓自上次 Transaction 以來對資料庫所做的所有修改實際生效。在開始另一個交易之前,不允許進行其他修改。如果仍有游標開啟,則 Commit 指令會刪除記錄檔,並釋放資料庫的寫入鎖定。系統會繼續持有讀取鎖定。
12 Halt 0 0
指令 Halt 會導致 VDBE 引擎立即退出。所有開啟的光標、清單、排序等都會自動關閉。P1 是 sqlite_exec() 傳回的結果代碼。對於正常的暫停,這應該是 SQLITE_OK (0)。對於錯誤,它可以是其他值。操作數 P2 僅在有錯誤時使用。在每個程式的結尾都有隱含的「Halt 0 0 0」指令,VDBE 在準備程式執行時會附加該指令。
如果 SQLite 函式庫是在沒有 NDEBUG 預處理器巨集的情況下編譯,則 PRAGMA vdbe_trace 會導致 VDBE 追蹤程式的執行。雖然此功能最初是為了測試和除錯而設計,但它也可以用於瞭解 VDBE 的運作方式。使用「PRAGMA vdbe_trace=ON;」開啟追蹤,並使用「PRAGMA vdbe_trace=OFF」關閉追蹤。像這樣
sqlite> PRAGMA vdbe_trace=ON;
0 Halt 0 0
sqlite> INSERT INTO examp VALUES('Hello, World!',99);
0 Transaction 0 0
1 VerifyCookie 0 81
2 Transaction 1 0
3 Integer 0 0
堆疊:i:0
4 OpenWrite 0 3 examp
5 NewRecno 0 0
堆疊:i:2
6 String 0 0 Hello, World!
堆疊:t[Hello,.World!] i:2
7 整數 99 0 99
堆疊: si:99 t[Hello,.World!] i:2
8 建立記錄 2 0
堆疊: s[...Hello,.World!.99] i:2
9 放入整數鍵 0 1
10 關閉 0 0
11 提交 0 0
12 暫停 0 0
在追蹤模式開啟時,VDBE 會在執行每條指令前先印出該指令。在指令執行後,堆疊中頂端的幾個項目會顯示出來。如果堆疊是空的,則會省略堆疊顯示。
在堆疊顯示中,大多數條目會顯示一個前綴,說明該堆疊條目的資料類型。整數以「i:」開頭。浮點值以「r:」開頭。(「r」代表「實數」)。字串以「s:」、「t:」、「e:」或「z:」開頭。字串前綴之間的差異是由於其記憶體配置方式造成的。z: 字串儲存在從 malloc() 取得的記憶體中。t: 字串是靜態配置的。e: 字串是短暫的。所有其他字串都有 s: 前綴。這對您這個觀察者來說沒有任何差別,但對 VDBE 來說至關重要,因為 z: 字串在彈出時需要傳遞給 free() 以避免記憶體外洩。請注意,只會顯示字串值的頭 10 個字元,而二進位值(例如 MakeRecord 指令的結果)會被視為字串。可以在 VDBE 堆疊上儲存的唯一其他資料類型是 NULL,它會顯示為「NULL」,沒有前綴。如果整數已同時作為整數和字串放置在堆疊上,其前綴為「si:」。
在這個時候,您應該了解 VDBE 如何寫入資料庫的基礎知識。現在讓我們看看它是如何執行查詢的。我們將使用以下簡單的 SELECT 陳述作為範例
SELECT * FROM examp;
為這個 SQL 陳述產生的 VDBE 程式如下
sqlite> EXPLAIN SELECT * FROM examp;
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 ColumnName 0 0 one
1 ColumnName 1 0 two
2 Integer 0 0
3 OpenRead 0 3 examp
4 VerifyCookie 0 81
5 Rewind 0 10
6 Column 0 0
7 Column 0 1
8 Callback 2 0
9 Next 0 6
10 Close 0 0
11 Halt 0 0
在我們開始探討這個問題之前,讓我們先簡要地回顧一下 SQLite 中查詢是如何運作的,這樣我們才能知道我們要達成什麼目標。對於查詢結果中的每一列,SQLite 會呼叫具有以下雛形的回呼函數
int Callback(void *pUserData, int nColumn, char *azData[], char *azColumnName[]);
SQLite 函式庫會提供 VDBE 一個指向回呼函式的指標和 pUserData 指標。(回呼和使用者資料最初都是以引數傳遞給 sqlite_exec() API 函式的。)VDBE 的工作是提出 nColumn、azData[] 和 azColumnName[] 的值。當然,nColumn 是結果中的欄位數。azColumnName[] 是字串陣列,其中每個字串都是結果欄位之一的名稱。azData[] 是字串陣列,其中包含實際資料。
0 ColumnName 0 0 one
1 ColumnName 1 0 two
針對我們的查詢,VDBE 程式中的前兩個指令與設定 azColumn 的值有關。ColumnName 指令會告訴 VDBE 要為 azColumnName[] 陣列的每個元素填入哪些值。每個查詢都會從結果中每個欄位的 ColumnName 指令開始,並且稍後在查詢中會有一個對應的 Column 指令。
2 Integer 0 0
3 OpenRead 0 3 examp
4 VerifyCookie 0 81
指令 2 和 3 會在要查詢的資料庫表格上開啟一個讀取游標。這與 INSERT 範例中的 OpenWrite 指令相同,只是這次游標是開啟來讀取,而不是寫入。指令 4 會驗證資料庫結構,就像 INSERT 範例中一樣。
5 Rewind 0 10
Rewind 指令會初始化一個迴圈,這個迴圈會反覆執行「examp」表格。它會將游標 P1 倒回表格中的第一個項目。Column 和 Next 指令需要這樣做,它們會使用游標來反覆執行表格。如果表格是空的,就會跳到 P2(10),這是迴圈後的指令。如果表格不為空,就會執行到下一個指令(6),這是迴圈主體的開頭。
6 Column 0 0
7 Column 0 1
8 Callback 2 0
第 6 至 8 行的指令形成迴圈主體,將針對資料庫檔案中的每筆記錄執行一次。位址 6 和 7 的 Column 指令各從第 P1 個游標取得第 P2 個欄位,並將其推入堆疊。在此範例中,第一個 Column 指令將「one」欄位的數值推入堆疊,而第二個 Column 指令將「two」欄位的數值推入堆疊。位址 8 的 Callback 指令呼叫 callback() 函式。Callback 的 P1 參數會變成 nColumn 的數值。Callback 指令會從堆疊中彈出 P1 個數值,並使用它們來填入 azData[] 陣列。
9 Next 0 6
位址 9 的指令實作迴圈的分支部分。它與位址 5 的 Rewind 一起形成迴圈邏輯。這是您應該密切注意的一個關鍵概念。Next 指令將游標 P1 移至下一筆記錄。如果游標移至成功,則立即跳至 P2 (6,迴圈主體的開頭)。如果游標已在結尾,則會執行後續指令,結束迴圈。
10 Close 0 0
11 Halt 0 0
程式結尾的 Close 指令會關閉指向「examp」資料表的游標。在此呼叫 Close 其實並非必要,因為所有游標都會在程式暫停時自動由 VDBE 關閉。但是我們需要 Rewind 可以跳轉的指令,因此我們不妨讓該指令執行一些有用的動作。Halt 指令會結束 VDBE 程式。
請注意,此 SELECT 查詢的程式不包含 INSERT 範例中使用的 Transaction 和 Commit 指令。由於 SELECT 是讀取作業,不會變更資料庫,因此不需要交易。
前一個範例的重點在於使用 Callback 指令呼叫回呼函式,以及使用 Next 指令實作資料庫檔案中所有記錄的迴圈。此範例試著透過示範一個稍微複雜一點的查詢來說明這些概念,這個查詢包含更多輸出的欄位,其中一些是計算值,以及一個限制哪些記錄實際會傳遞到回呼函式的 WHERE 子句。考慮這個查詢
SELECT one, two, one || two AS 'both' FROM examp WHERE one LIKE 'H%'
這個查詢可能有點牽強,但確實能說明我們的重點。結果會有三個欄位,名稱為「one」、「two」和「both」。前兩個欄位是表格中兩個欄位的直接拷貝,而第三個結果欄位是一個字串,由串接表格的第一個和第二個欄位所組成。最後,WHERE 子句表示我們只會選擇「one」欄位以「H」開頭的結果列。以下是這個查詢的 VDBE 程式碼
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 ColumnName 0 0 one
1 ColumnName 1 0 two
2 ColumnName 2 0 both
3 Integer 0 0
4 OpenRead 0 3 examp
5 VerifyCookie 0 81
6 Rewind 0 18
7 String 0 0 H%
8 Column 0 0
9 函數 2 0 ptr(0x7f1ac0)
10 IfNot 1 17
11 欄位 0 0
12 欄位 0 1
13 欄位 0 0
14 欄位 0 1
15 串接 2 0
16 呼叫回函 3 0
17 Next 0 7
18 關閉 0 0
19 暫停 0 0
除了 WHERE 子句之外,此範例的程式結構與前一個範例非常類似,只多了一欄。現在有 3 欄,而不是之前的 2 欄,並且有三個 ColumnName 指令。使用 OpenRead 指令開啟游標,就像前一個範例一樣。位址 6 的 Rewind 指令和位址 17 的 Next 形成一個迴圈,遍歷資料表的全部記錄。結尾的 Close 指令用於在 Rewind 指令完成時提供跳轉目標。這一切都與第一個查詢示範相同。
此範例中的 Callback 指令必須為三個結果欄位產生資料,而不是兩個,但其他部分與第一個查詢相同。當呼叫 Callback 指令時,結果的最左欄位應該是堆疊中的最低欄位,最右欄位應該是堆疊的頂端。我們可以在位址 11 到 15 看見堆疊以這種方式設定。位址 11 和 12 的 Column 指令會將前兩個欄位的值推入結果中。位址 13 和 14 的兩個 Column 指令會擷取計算第三個結果欄位所需的值,而位址 15 的 Concat 指令會將它們串接成堆疊上的單一項目。
目前範例中唯一真正新的部分是 WHERE 子句,它是由位址 7 到 10 的指令實作。位址 7 和 8 的指令會將表格中「one」欄位的值和字串常數「H%」推入堆疊。Function 指令會在位址 9 將這兩個值從堆疊中彈出,並將 LIKE() 函式的結果推回堆疊。IfNot 指令會彈出堆疊頂端的值,如果頂端的值為 false(不不類似於字串常數「H%」),則會立即向前跳到 Next 指令。採取這個跳躍動作會有效略過回呼,這正是 WHERE 子句的重點。如果比較結果為 true,則不會執行跳躍,而會控制傳遞到下方的 Callback 指令。
注意 LIKE 算子是如何實作的。它是 SQLite 中的使用者定義函數,因此其函數定義的位址指定在 P3 中。運算元 P1 是它從堆疊中取用的函數引數數量。在此情況下,LIKE() 函數採用 2 個引數。引數從堆疊中以反向順序 (從右至左) 取出,因此要比對的模式是堆疊頂端元素,而下一個元素是要比較資料。傳回值會推入堆疊中。
前兩個查詢範例說明每個 SELECT 程式都會遵循的一種範本。基本上,我們有
當我們考慮其他複雜情況(例如聯結、複合選取、使用索引加快搜尋、排序以及帶有或不帶有 GROUP BY 和 HAVING 子句的聚合函數)時,此範本將會大幅擴充。但相同的基本概念將持續適用。
UPDATE 和 DELETE 陳述式使用與 SELECT 陳述式範本非常類似的範本編碼。主要差異當然在於最終動作是修改資料庫,而不是呼叫回呼函數。由於它會修改資料庫,因此也會使用交易。讓我們先來看看 DELETE 陳述式
DELETE FROM examp WHERE two<50;
此 DELETE 陳述式將移除「examp」資料表中「two」欄位小於 50 的每筆記錄。為此產生的程式碼如下
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 Transaction 1 0
1 Transaction 0 0
2 VerifyCookie 0 178
3 Integer 0 0
4 OpenRead 0 3 examp
5 Rewind 0 12
6 Column 0 1
7 Integer 50 0 50
8 Ge 1 11
9 Recno 0 0
10 ListWrite 0 0
11 Next 0 6
12 Close 0 0
13 ListRewind 0 0
14 Integer 0 0
15 OpenWrite 0 3
16 ListRead 0 20
17 NotExists 0 19
18 Delete 0 1
19 Goto 0 16
20 ListReset 0 0
21 Close 0 0
22 Commit 0 0
23 Halt 0 0
以下是程式必須執行的動作。首先,它必須找出「examp」資料表中所有要刪除的記錄。這會使用一個迴圈來完成,這個迴圈很類似於上面 SELECT 範例中使用的迴圈。一旦找出所有記錄後,我們就可以回頭逐一刪除它們。請注意,我們無法在找到每個記錄後就立即刪除它們。我們必須先找出所有記錄,然後再回頭刪除它們。這是因為 SQLite 資料庫後端可能會在刪除操作後變更掃描順序。如果掃描順序在掃描過程中變更,有些記錄可能會被拜訪超過一次,而其他記錄可能根本不會被拜訪。
所以 DELETE 的實作實際上在兩個迴圈中。第一個迴圈(指令 5 到 11)找出要刪除的記錄,並將其金鑰儲存到暫時清單中,而第二個迴圈(指令 16 到 19)使用金鑰清單逐一刪除記錄。
0 Transaction 1 0
1 Transaction 0 0
2 VerifyCookie 0 178
3 Integer 0 0
4 OpenRead 0 3 examp
指令 0 到 4 與 INSERT 範例中相同。它們為主要和暫時資料庫啟動交易,驗證主要資料庫的資料庫架構,並在「examp」表格上開啟讀取游標。請注意,游標是開啟用於讀取,而非寫入。在程式這個階段,我們只會掃描表格,不會變更它。我們會在稍後指令 15 重新開啟同一個表格用於寫入。
5 Rewind 0 12
與 SELECT 範例中相同,Rewind 指令將游標倒回表格開頭,準備在迴圈主體中使用。
6 Column 0 1
7 Integer 50 0 50
8 Ge 1 11
WHERE 子句由指令 6 到 8 實作。WHERE 子句的工作是在 WHERE 條件為假時略過 ListWrite。為此,如果「two」欄(由 Column 指令擷取)大於或等於 50,它會跳到下一個指令。
與之前相同,Column 指令使用游標 P1,並將欄 P2(1,「two」欄)中的資料記錄推入堆疊。Integer 指令將值 50 推入堆疊頂端。在這些兩個指令後,堆疊看起來像
(整數) 50 (記錄) 「two」欄的目前記錄
Ge 算子比較堆疊上的前兩個元素,將它們彈出,然後根據比較結果進行分支。如果第二個元素大於或等於頂端元素,則跳到位址 P2(迴圈結束處的 Next 指令)。因為 P1 為真,所以如果任一運算元為 NULL(因此結果為 NULL),則進行跳躍。如果我們不跳躍,則只要前進到下一個指令即可。
9 Recno 0 0
10 ListWrite 0 0
「Recno」指令會將整數推入堆疊中,該整數為游標 P1 指向的表格中,順序掃描的目前條目的金鑰前 4 個位元組。「ListWrite」指令會將堆疊頂端的整數寫入暫時儲存清單中,並移除頂端元素。這是此迴圈的重要工作,即儲存要刪除的記錄金鑰,以便我們可以在第二個迴圈中刪除它們。在此 ListWrite 指令之後,堆疊會再次清空。
11 Next 0 6
12 Close 0 0
「Next」指令會遞增游標,以指向游標 P0 指向的表格中的下一個元素,如果成功,則會分支到 P2(6,迴圈主體的開頭)。「Close」指令會關閉游標 P1。它不會影響暫時儲存清單,因為它與游標 P1 無關;它是一個全域工作清單(可以使用 ListPush 儲存)。
13 ListRewind 0 0
「ListRewind」指令會將暫時儲存清單倒回到開頭。這會準備好在第二個迴圈中使用它。
14 Integer 0 0
15 OpenWrite 0 3
與 INSERT 範例一樣,我們將資料庫號碼 P1(0,主資料庫)推入堆疊中,並使用 OpenWrite 在表格 P2(基本頁面 3,「examp」)上開啟游標 P1 以進行修改。
16 ListRead 0 20
17 NotExists 0 19
18 Delete 0 1
19 Goto 0 16
此迴圈會執行實際刪除。它的組織方式與 UPDATE 範例中的迴圈不同。ListRead 指令扮演的角色與 INSERT 迴圈中的 Next 相同,但由於它會在失敗時跳到 P2,而 Next 會在成功時跳轉,因此我們將它放在迴圈開頭,而不是結尾。這表示我們必須在迴圈結尾放置一個 Goto,才能跳回迴圈開頭的迴圈測試。因此,此迴圈的格式為 C while(){...} 迴圈,而 INSERT 範例中的迴圈格式為 do{...}while() 迴圈。Delete 指令扮演的角色與先前範例中的回呼函式相同。
「ListRead」指令會讀取暫時儲存清單中的元素,並將其推入堆疊中。如果成功,它會繼續執行下一個指令。如果失敗是因為清單為空,它會分支到 P2,也就是迴圈後面的指令。之後堆疊會如下所示
(整數) 目前記錄的鍵值
請注意 ListRead 和 Next 指令之間的相似性。這兩個操作都根據以下規則運作
將下一個「東西」推入堆疊中,並根據是否有下一個「東西」要推入而繼續執行或跳到 P2。
Next 和 ListRead 之間的一個差異在於它們對「東西」的定義。Next 指令的「東西」是資料庫檔案中的記錄。ListRead 的「東西」是清單中的整數鍵值。另一個差異在於如果沒有下一個「東西」時是要跳躍還是繼續執行。在這種情況下,Next 會繼續執行,而 ListRead 會跳躍。稍後,我們將看到其他使用相同原則運作的迴圈指令(NextIdx 和 SortNext)。
「NotExists」指令會彈出堆疊頂端的元素,並將其用作整數鍵值。如果表格 P1 中不存在具有該鍵值的記錄,則跳到 P2。如果記錄存在,則繼續執行下一個指令。在這種情況下,P2 會帶我們到迴圈結束處的 Goto,它會跳回迴圈開頭的 ListRead。這可以用 P2 為 16 來編碼,迴圈開頭的 ListRead 為 16,但產生此程式碼的 SQLite 解析器並未進行此最佳化。
「刪除」執行此迴圈的工作;它將堆疊中的整數鍵彈出(由前一個 ListRead 放置),並刪除具有該鍵的指標 P1 的記錄。由於 P2 為真,因此列變更計數器會遞增。
「轉至」會跳回迴圈的開頭。這是迴圈的結尾。
20 ListReset 0 0
21 Close 0 0
22 Commit 0 0
23 Halt 0 0
這段指令會清除 VDBE 程式。其中三個指令並非真正需要,但由 SQLite 解析器從其程式範本產生,這些範本設計用於處理更複雜的情況。
「ListReset」指令會清空暫時儲存清單。此清單會在 VDBE 程式終止時自動清空,因此在此情況下並非必要。「關閉」指令會關閉指標 P1。同樣地,當 VDBE 引擎完成執行此程式時,也會執行此動作。「提交」會成功結束目前的交易,並將此交易中發生的所有變更儲存至資料庫。最後的「暫停」也並非必要,因為它會在準備執行時加入每個 VDBE 程式。
「UPDATE」陳述式的工作方式與「DELETE」陳述式非常類似,只不過它們並非刪除記錄,而是用新的記錄取代它。請考慮以下範例
UPDATE examp SET one= '(' || one || ')' WHERE two < 50;
此陳述式並未刪除「two」欄位小於 50 的記錄,而只是將「one」欄位放在括號中。要實作此陳述式的 VDBE 程式如下
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 交易 1 0
1 交易 0 0
2 驗證Cookie 0 178
3 Integer 0 0
4 開啟讀取 0 3 範例
5 倒帶 0 12
6 欄位 0 1
7 整數 50 0 50
8 Ge 1 11
9 記錄編號 0 0
10 寫入清單 0 0
11 下一步 0 6
12 關閉 0 0
13 整數 0 0
14 開啟寫入 0 3
15 清單倒轉 0 0
16 清單讀取 0 28
17 複製 0 0
18 不存在 0 16
19 字串 0 0 (
20 欄位 0 0
21 串接 2 0
22 字串 0 0 )
23 串接 2 0
24 欄位 0 1
25 建立記錄 2 0
26 放入整數金鑰 0 1
27 前往 0 16
28 重設清單 0 0
29 關閉 0 0
30 提交 0 0
31 Halt 0 0
這個程式基本上與 DELETE 程式相同,除了第二個迴圈的主體已被取代為一連串指令(在位址 17 到 26),用於更新記錄而非刪除記錄。這段指令序列的大部分您應該已經很熟悉,但有些微小的變化,因此我們將簡要說明。另請注意,第 2 個迴圈前後某些指令的順序已變更。這只是 SQLite 解析器選擇使用不同範本來輸出程式碼的方式。
當我們進入第二個迴圈的內部(在指令 17)時,堆疊包含一個整數,它是我們要修改的記錄的鍵。我們需要使用這個鍵兩次:一次用來擷取記錄的舊值,第二次用來寫回已修改的記錄。因此,第一個指令是 Dup,用於在堆疊頂端複製鍵。Dup 指令會複製堆疊的任何元素,不只是頂端元素。您可以使用 P1 操作數指定要複製哪個元素。當 P1 為 0 時,會複製堆疊頂端。當 P1 為 1 時,會複製堆疊中下一個元素。以此類推。
複製鍵後,下一個指令 NotExists 會彈出堆疊一次,並使用彈出的值作為鍵,檢查資料庫檔案中是否存在記錄。如果沒有這個鍵的記錄,它會跳回 ListRead 以取得另一個鍵。
指令 19 到 25 建立一個新的資料庫記錄,用於取代現有的記錄。這與我們在 INSERT 說明中看到的程式碼類型相同,不再進一步說明。在指令 25 執行後,堆疊如下所示
(記錄)新資料記錄 (整數)金鑰
PutIntKey 指令(也於 INSERT 討論中描述)會寫入一筆資料至資料庫檔案,其資料為堆疊頂端,其金鑰為堆疊中下一筆,然後將堆疊彈出兩次。PutIntKey 指令會覆寫具有相同金鑰的現有記錄資料,這正是我們在此處所要的。覆寫並非 INSERT 的問題,因為在 INSERT 中,金鑰是由 NewRecno 指令產生,而此指令保證提供先前未曾使用過的金鑰。
使用 CREATE 或 DROP 來建立或刪除資料表或索引實際上與從特殊「sqlite_master」資料表執行 INSERT 或 DELETE 相同,至少從 VDBE 的觀點來看是如此。sqlite_master 資料表是自動為每個 SQLite 資料庫建立的特殊資料表。它看起來像這樣
CREATE TABLE sqlite_master ( type TEXT, -- either "table" or "index" name TEXT, -- name of this table or index tbl_name TEXT, -- for indices: name of associated table sql TEXT -- SQL text of the original CREATE statement )
SQLite 資料庫中的每個資料表(除了「sqlite_master」資料表本身)和每個命名索引在 sqlite_master 資料表中都有個項目。您可以使用 SELECT 陳述式查詢此資料表,就像其他資料表一樣。但是,您不得使用 UPDATE、INSERT 或 DELETE 直接變更資料表。對 sqlite_master 的變更必須使用 CREATE 和 DROP 指令,因為 SQLite 在新增或刪除資料表和索引時也必須更新其部分內部資料結構。
但是,從 VDBE 的觀點來看,CREATE 的運作方式很像 INSERT,而 DROP 的運作方式則像 DELETE。當 SQLite 函式庫開啟現有資料庫時,它執行的第一件事是 SELECT,以從 sqlite_master 資料表的所有項目中讀取「sql」欄位。「sql」欄位包含最初產生索引或資料表的 CREATE 陳述式的完整 SQL 文字。此文字會回饋至 SQLite 剖析器,並用於重建描述索引或資料表的內部資料結構。
在上述範例查詢中,查詢的資料表中每一列都必須從磁碟載入並檢查,即使最後只有少數幾列會出現在結果中。對於大型資料表來說,這可能會花費很長的時間。為了加快速度,SQLite 可以使用索引。
SQLite 檔案會將金鑰與某些資料關聯。對於 SQLite 資料表,資料庫檔案會設定成金鑰為整數,而資料為資料表中某一列的資訊。SQLite 中的索引會反轉此安排。索引金鑰為儲存的資訊(部分),而索引資料為整數。若要存取具有特定內容的資料表列,我們會先在索引資料表中查詢內容以找出其整數索引,然後使用該整數在資料表中查詢完整記錄。
請注意,SQLite 使用 b-tree,這是一種已排序的資料結構,因此當 SELECT 陳述式的 WHERE 子句包含等於或不等於的測試時,可以使用索引。如果可用,下列類型的查詢可以使用索引
SELECT * FROM examp WHERE two==50; SELECT * FROM examp WHERE two<50; SELECT * FROM examp WHERE two IN (50, 100);
如果存在將「examp」資料表的「two」欄位對應到整數的索引,則 SQLite 會使用該索引找出 examp 中所有在 two 欄位中具有值 50 的列的整數金鑰,或所有小於 50 的列等。但下列查詢無法使用索引
SELECT * FROM examp WHERE two%50 == 10; SELECT * FROM examp WHERE two&127 == 3;
請注意,即使有可能使用索引,SQLite 剖析器也不一定會產生使用索引的程式碼。下列查詢目前不會使用索引
SELECT * FROM examp WHERE two+10 == 50; SELECT * FROM examp WHERE two==50 OR two==100;
若要更了解索引如何運作,讓我們先了解它們是如何建立的。讓我們繼續在 examp 資料表的 two 欄位上建立索引。我們有
CREATE INDEX examp_idx1 ON examp(two);
上述陳述式產生的 VDBE 程式碼如下所示
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 交易 1 0
1 交易 0 0
2 驗證Cookie 0 178
3 Integer 0 0
4 OpenWrite 0 2
5 NewRecno 0 0
6 字串 0 0 索引
7 字串 0 0 examp_idx1
8 字串 0 0 examp
9 建立索引 0 0 ptr(0x791380)
10 複製 0 0
11 整數 0 0
12 開啟寫入 1 0
13 字串 0 0 CREATE INDEX examp_idx1 ON examp(tw
14 建立記錄 5 0
15 PutIntKey 0 0
16 Integer 0 0
17 OpenRead 2 3 examp
18 Rewind 2 24
19 Recno 2 0
20 Column 2 1
21 MakeIdxKey 1 0 n
22 IdxPut 1 0 索引欄位不唯一
23 Next 2 19
24 Close 2 0
25 關閉 1 0
26 整數 333 0
27 設定 Cookie 0 0
28 關閉 0 0
29 提交 0 0
30 暫停 0 0
請記住,每個表格(sqlite_master 除外)和每個命名索引在 sqlite_master 表格中都有個項目。由於我們正在建立新的索引,我們必須在 sqlite_master 中新增一個項目。這由指令 3 到 15 處理。在 sqlite_master 中新增一個項目就像任何其他 INSERT 陳述式,所以我們在此不再多加說明。在此範例中,我們要專注於使用有效資料填入新索引,這發生在指令 16 到 23。
16 Integer 0 0
17 開啟讀取 2 3 範例
首先發生的事情是我們開啟要索引的表格以進行讀取。為了建立表格的索引,我們必須知道表格中有哪些內容。索引已使用游標 0 由指令 3 和 4 開啟以進行寫入。
18 Rewind 2 24
19 Recno 2 0
20 Column 2 1
21 MakeIdxKey 1 0 n
22 IdxPut 1 0 索引欄位不唯一
23 下一個 2 19
指令 18 至 23 實作一個迴圈,針對要建立索引的表格的每一列進行迭代。對於每一列表格,我們會先在指令 19 中使用 Recno 萃取該列的整數鍵,然後在指令 20 中使用 Column 取得「two」欄位的數值。指令 21 中的 MakeIdxKey 指令會將「two」欄位的資料(位於堆疊頂端)轉換成有效的索引鍵。對於單一欄位的索引,這基本上沒有作用。但是,如果 MakeIdxKey 的 P1 操作數大於 1,則會從堆疊中彈出多個項目,並將其轉換成單一索引鍵。指令 22 中的 IdxPut 指令會實際建立索引項目。IdxPut 會從堆疊中彈出兩個元素。堆疊頂端用作鍵,以從索引表格中擷取項目。然後,堆疊中第二個整數會新增到該索引的整數組,而新的記錄會寫回資料庫檔案。請注意,如果兩個欄位對於兩個或更多表格項目具有相同的值,則相同的索引項目可以儲存多個整數。
現在,讓我們看看這個索引將如何使用。考量以下查詢
SELECT * FROM examp WHERE two==50;
SQLite 會產生以下 VDBE 程式碼來處理此查詢
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 ColumnName 0 0 one
1 ColumnName 1 0 two
2 Integer 0 0
3 OpenRead 0 3 examp
4 VerifyCookie 0 256
5 Integer 0 0
6 OpenRead 1 4 examp_idx1
7 Integer 50 0 50
8 MakeKey 1 0 n
9 MemStore 0 0
10 MoveTo 1 19
11 MemLoad 0 0
12 IdxGT 1 19
13 IdxRecno 1 0
14 MoveTo 0 0
15 Column 0 0
16 Column 0 1
17 Callback 2 0
18 Next 1 11
19 Close 0 0
20 Close 1 0
21 Halt 0 0
SELECT 的開頭與一般相同。首先初始化欄位名稱,並開啟要查詢的資料表。從第 5 和第 6 個指令開始,情況有所不同,因為索引檔案也已開啟。第 7 和第 8 個指令建立一個值為 50 的金鑰。MemStore 指令在第 9 個位置將索引金鑰儲存在 VDBE 記憶體位置 0。VDBE 記憶體用於避免從堆疊深處擷取值,雖然可以這樣做,但會讓程式更難產生。下一個指令 MoveTo 在位置 10 會將金鑰從堆疊中彈出,並將索引指標移至具有該金鑰的索引第一列。這會初始化指標,以便在下列迴圈中使用。
指令 11 至 18 實作一個迴圈,遍歷所有索引記錄,其金鑰是由指令 8 擷取的。所有具有此金鑰的索引記錄在索引表中將是連續的,因此我們遍歷它們並從索引中擷取對應的表格金鑰。然後使用此表格金鑰將游標移至表格中的該列。迴圈的其餘部分與非索引 SELECT 查詢的迴圈相同。
迴圈從第 11 行的 MemLoad 指令開始,將索引金鑰的副本推回堆疊。第 12 行的 IdxGT 指令將金鑰與游標 P1 指向的目前索引記錄中的金鑰進行比較。如果目前游標位置的索引金鑰大於我們正在尋找的索引,則跳出迴圈。
第 13 行的 IdxRecno 指令將索引中的表格記錄號碼推入堆疊。以下的 MoveTo 會將其彈出並將表格游標移至該列。接下來的 3 個指令以與非索引情況相同的方式選擇欄位資料。Column 指令會擷取欄位資料,並呼叫 callback 函式。最後的 Next 指令會將索引游標(而非表格游標)推進至下一列,然後如果還有任何索引記錄,則分支回迴圈的開頭。
由於索引用於在表格中查詢值,因此索引和表格保持一致非常重要。現在 examp 表格上有一個索引,每當在 examp 表格中插入、刪除或變更資料時,我們都必須更新該索引。請記住上述第一個範例,我們能夠使用 12 個 VDBE 指令在「examp」表格中插入新列。現在此表格已建立索引,需要 19 個指令。SQL 陳述式如下
INSERT INTO examp VALUES('Hello, World!',99);
而產生的程式碼如下
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 交易 1 0
1 交易 0 0
2 VerifyCookie 0 256
3 Integer 0 0
4 OpenWrite 0 3 examp
5 Integer 0 0
6 OpenWrite 1 4 examp_idx1
7 NewRecno 0 0
8 String 0 0 Hello, World!
9 Integer 99 0 99
10 Dup 2 1
11 Dup 1 1
12 MakeIdxKey 1 0 n
13 IdxPut 1 0
14 MakeRecord 2 0
15 PutIntKey 0 1
16 Close 0 0
17 Close 1 0
18 Commit 0 0
19 暫停 0 0
在這個時候,您應該對 VDBE 有足夠的了解,可以自行找出上述程式如何運作。因此,我們在本文中不會再進一步討論它。
在聯結中,會將兩個或多個表格合併以產生單一結果。結果表格包含要聯結的表格中每一列的組合。實作此功能最簡單且最自然的方式是使用巢狀迴圈。
回想上面討論的查詢範本,其中有一個單一迴圈會搜尋表格中的每一筆記錄。在聯結中,我們基本上會執行相同的事情,只不過有巢狀迴圈。例如,若要聯結兩個表格,查詢範本可能會類似下列範例
此範本可以運作,但可能會很慢,因為我們現在處理的是 O(N2) 迴圈。但 WHERE 子句通常可以分解為多個項目,其中一個或多個項目只會涉及第一個表格中的欄位。當發生這種情況時,我們可以將 WHERE 子句測試的一部分從內部迴圈中分解出來,並大幅提升效率。因此,更好的範本會類似以下內容
如果可以使用索引來加速兩個迴圈中的任一個搜尋,則可以進一步提升速度。
SQLite 總是按照表格在 SELECT 陳述式的 FROM 子句中出現的順序來建構迴圈。最左邊的表格會變成外部迴圈,最右邊的表格會變成內部迴圈。理論上,在某些情況下可以重新排列迴圈順序,以加速聯結的評估。但 SQLite 沒有嘗試進行此最佳化。
您可以在以下範例中看到 SQLite 如何建構巢狀迴圈
CREATE TABLE examp2(three int, four int); SELECT * FROM examp, examp2 WHERE two<50 AND four==two;
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 ColumnName 0 0 examp.one
1 ColumnName 1 0 examp.two
2 ColumnName 2 0 examp2.three
3 ColumnName 3 0 examp2.four
4 Integer 0 0
5 OpenRead 0 3 examp
6 VerifyCookie 0 909
7 Integer 0 0
8 OpenRead 1 5 examp2
9 Rewind 0 24
10 Column 0 1
11 Integer 50 0 50
12 Ge 1 23
13 倒帶 1 23
14 欄位 1 1
15 欄位 0 1
16 否 1 22
17 欄位 0 0
18 欄位 0 1
19 欄位 1 0
20 欄位 1 1
21 回呼 4 0
22 下一頁 1 14
23 下一頁 0 10
24 關閉 0 0
25 關閉 1 0
26 暫停 0 0
外層迴圈 over table examp 由指令 7 到 23 實作。內層迴圈是指令 13 到 22。請注意 WHERE 表達式的「two<50」項目僅包含第一個表格的欄位,且可以從內層迴圈中分解出來。SQLite 執行此動作,並在指令 10 到 12 中實作「two<50」測試。「four==two」測試由內層迴圈中的指令 14 到 16 實作。
SQLite 對於聯結中的表格不施加任何任意限制。它也允許表格與自身聯結。
基於歷史原因和效率,目前所有排序都在記憶體中執行。
SQLite 使用一組特殊指令實作 ORDER BY 子句,以控制稱為排序器的物件。在查詢的最內層迴圈中,通常會有 Callback 指令,但會改為建構包含回呼參數和金鑰的記錄。此記錄會新增至排序器(在連結清單中)。查詢迴圈結束後,記錄清單會被排序,並對此清單進行遍歷。對於清單上的每筆記錄,都會呼叫回呼。最後,排序器會關閉,且記憶體會被釋放。
我們可以在下列查詢中看到此程序的執行狀況
SELECT * FROM examp ORDER BY one DESC, two;
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 ColumnName 0 0 one
1 ColumnName 1 0 two
2 Integer 0 0
3 OpenRead 0 3 examp
4 VerifyCookie 0 909
5 倒帶 0 14
6 Column 0 0
7 Column 0 1
8 SortMakeRec 2 0
9 Column 0 0
10 Column 0 1
11 SortMakeKey 2 0 D+
12 SortPut 0 0
13 Next 0 6
14 Close 0 0
15 Sort 0 0
16 SortNext 0 19
17 SortCallback 2 0
18 Goto 0 16
19 SortReset 0 0
20 Halt 0 0
只有一個分揀器物件,因此沒有開啟或關閉它的指令。需要時會自動開啟,而 VDBE 程式暫停時會關閉它。
查詢迴圈是由指令 5 到 13 建立的。指令 6 到 8 建立一個包含 azData[] 值的記錄,供一次呼叫回呼使用。排序金鑰是由指令 9 到 11 產生的。指令 12 將呼叫記錄和排序金鑰合併成一個單一項目,並將該項目放入排序清單中。
指令 11 的 P3 參數特別有趣。排序金鑰是透過在每個字串前加上一個 P3 字元,並串接所有字串而形成的。排序比較函式會查看這個字元,以判斷排序順序是遞增還是遞減,以及是要以字串還是數字排序。在此範例中,第一個欄位應以遞減順序排序為字串,因此它的前置字元是「D」,而第二個欄位應以遞增順序以數字排序,因此它的前置字元是「+」。遞增字串排序使用「A」,而遞減數字排序使用「-」。
查詢迴圈結束後,查詢的表格會在指令 14 關閉。這麼做是為了及早允許其他程序或執行緒存取該表格(如果需要)。在查詢迴圈內建立的記錄清單會由指令 15 排序。指令 16 到 18 會瀏覽記錄清單(現在已按排序順序排列),並針對每個記錄呼叫回呼一次。最後,分揀器會在指令 19 關閉。
為了計算聚合函數,VDBE 實作了一個特殊資料結構和用於控制該資料結構的指令。資料結構是一個無序的儲存區組,其中每個儲存區組有一個金鑰和一個或多個記憶體位置。在查詢迴圈中,GROUP BY 子句用於建構金鑰,並將具有該金鑰的儲存區組帶入焦點。如果先前不存在,則使用金鑰建立一個新的儲存區組。儲存區組進入焦點後,將使用儲存區組的記憶體位置累積各種聚合函數的值。查詢迴圈終止後,會拜訪每個儲存區組一次,以產生單一結果列。
以下範例有助於釐清這個概念。考慮以下查詢
SELECT three, min(three+four)+avg(four) FROM examp2 GROUP BY three;
為此查詢產生的 VDBE 程式碼如下
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 ColumnName 0 0 three
1 ColumnName 1 0 min(three+four)+avg(four)
2 AggReset 0 3
3 AggInit 0 1 ptr(0x7903a0)
4 AggInit 0 2 ptr(0x790700)
5 Integer 0 0
6 OpenRead 0 5 examp2
7 VerifyCookie 0 909
8 Rewind 0 23
9 Column 0 0
10 MakeKey 1 0 n
11 AggFocus 0 14
12 Column 0 0
13 AggSet 0 0
14 Column 0 0
15 欄位 0 1
16 Add 0 0
17 Integer 1 0
18 AggFunc 0 1 ptr(0x7903a0)
19 Column 0 1
20 Integer 2 0
21 AggFunc 0 1 ptr(0x790700)
22 Next 0 9
23 Close 0 0
24 AggNext 0 31
25 AggGet 0 0
26 AggGet 0 1
27 AggGet 0 2
28 Add 0 0
29 Callback 2 0
30 Goto 0 24
31 Noop 0 0
32 Halt 0 0
第一個引起興趣的指令是 2 處的 AggReset。AggReset 指令將儲存區集合初始化為空集合,並指定每個儲存區中可用的記憶體插槽數目為 P2。在此範例中,每個儲存區將包含 3 個記憶體插槽。這並不明顯,但如果您仔細查看程式其餘部分,您可以找出這些插槽的預定用途。
記憶體插槽 此記憶體插槽的預定用途 0 「three」欄位 -- 儲存區的鍵值 1 最小「three+four」值 2 所有「four」值的總和。這用於計算「avg(four)」。
查詢迴圈由指令 8 到 22 實作。GROUP BY 子句指定的彙總金鑰由指令 9 和 10 計算。指令 11 會讓適當的儲存區進入焦點。如果具有給定金鑰的儲存區尚未存在,就會建立新的儲存區,並將控制權傳遞至指令 12 和 13,以初始化儲存區。如果儲存區已存在,就會跳到指令 14。彙總函數的值會由指令 11 到 21 更新。指令 14 到 18 會更新記憶體插槽 1,以容納下一個值「min(three+four)」。然後,「four」欄的總和會由指令 19 到 21 更新。
查詢迴圈結束後,會在指令 23 關閉「examp2」表格,以釋放其鎖定,並讓其他執行緒或程序使用。下一步是迴圈遍歷所有彙總儲存區,並為每個儲存區輸出結果的一列。這是由指令 24 到 30 的迴圈完成的。指令 24 的 AggNext 指令會讓下一個儲存區進入焦點,或是在已檢查所有儲存區時跳到迴圈的結尾。結果的 3 個欄會依序從彙總儲存區擷取,指令為 25 到 27。最後,會在指令 29 呼叫回呼。
總之,任何具有彙總函數的查詢都會由兩個迴圈實作。第一個迴圈會掃描輸入表格,並將彙總資訊計算到儲存區中,而第二個迴圈會掃描所有儲存區,以計算最終結果。
了解到聚合查詢實際上是兩個連續迴圈,這使得更容易理解 SQL 查詢語句中的 WHERE 子句和 HAVING 子句之間的差異。WHERE 子句是對第一個迴圈的限制,而 HAVING 子句是對第二個迴圈的限制。您可以透過在範例查詢中加入 WHERE 和 HAVING 子句來了解這一點
SELECT three, min(three+four)+avg(four) FROM examp2 WHERE three>four GROUP BY three HAVING avg(four)<10;
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 ColumnName 0 0 three
1 ColumnName 1 0 min(three+four)+avg(four)
2 AggReset 0 3
3 AggInit 0 1 ptr(0x7903a0)
4 AggInit 0 2 ptr(0x790700)
5 Integer 0 0
6 OpenRead 0 5 examp2
7 VerifyCookie 0 909
8 Rewind 0 26
9 Column 0 0
10 Column 0 1
11 Le 1 25
12 Column 0 0
13 MakeKey 1 0 n
14 AggFocus 0 17
15 Column 0 0
16 AggSet 0 0
17 欄位 0 0
18 欄位 0 1
19 Add 0 0
20 整數 1 0
21 聚合函數 0 1 ptr(0x7903a0)
22 欄位 0 1
23 整數 2 0
24 聚合函數 0 1 ptr(0x790700)
25 下一個 0 9
26 關閉 0 0
27 聚合函數下一個 0 37
28 聚合函數取得 0 2
29 整數 10 0 10
30 Ge 1 27
31 AggGet 0 0
32 AggGet 0 1
33 AggGet 0 2
34 Add 0 0
35 Callback 2 0
36 Goto 0 27
37 Noop 0 0
38 Halt 0 0
在這個最後的範例中產生的程式碼與前一個範例相同,除了新增兩個用於實作額外的 WHERE 和 HAVING 子句的條件式跳躍。WHERE 子句由查詢迴圈中的指令 9 到 11 實作。HAVING 子句由輸出迴圈中的指令 28 到 30 實作。
「結構化查詢語言」這個名稱本身就告訴我們,SQL 應支援巢狀查詢。事實上,它支援兩種不同的巢狀。任何傳回單一列單一欄位結果的 SELECT 陳述式,都可用作另一個 SELECT 陳述式表達式中的項目。而傳回單一欄位多列結果的 SELECT 陳述式,可用作 IN 和 NOT IN 算子的右運算元。我們將從第一種巢狀的範例開始,其中單一列單一欄位的 SELECT 用作另一個 SELECT 的表達式中的項目。以下是我們的範例
SELECT * FROM examp WHERE two!=(SELECT three FROM examp2 WHERE four=5);
SQLite 處理此問題的方式,是先執行內部 SELECT(針對 examp2 的那個),並將其結果儲存在私人記憶體儲存格中。當 SQLite 評估外部 SELECT 時,會將此私人記憶體儲存格的值替換為內部 SELECT。程式碼如下所示
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 String 0 0
1 MemStore 0 1
2 Integer 0 0
3 OpenRead 1 5 examp2
4 VerifyCookie 0 909
5 Rewind 1 13
6 欄位 1 1
7 整數 5 0 5
8 否 1 12
9 欄位 1 0
10 記憶體儲存體 0 1
11 前往 0 13
12 下一個 1 6
13 關閉 1 0
14 欄位名稱 0 0 one
15 欄位名稱 1 0 two
16 Integer 0 0
17 OpenRead 0 3 examp
18 Rewind 0 26
19 Column 0 1
20 MemLoad 0 0
21 Eq 1 25
22 Column 0 0
23 Column 0 1
24 Callback 2 0
25 Next 0 19
26 Close 0 0
27 Halt 0 0
第一個指令會將私有記憶體儲存格初始化為 NULL。指令 2 到 13 會對 examp2 表格實作內部 SELECT 陳述式。請注意,查詢結果並未傳送至回呼或儲存在排序器上,而是由指令 10 將查詢結果推入記憶體儲存格,且迴圈會由指令 11 的跳躍動作中斷。指令 11 的跳躍動作是殘留的,且從未執行。
外部 SELECT 是由指令 14 到 25 實作的。特別是,包含巢狀選取的 WHERE 子句是由指令 19 到 21 實作的。您會看到內部選取的結果是由指令 20 載入堆疊,並由指令 21 的條件式跳躍使用。
當子選取的結果為純量時,可以使用單一私有記憶體儲存格,如前一個範例所示。但是,當子選取的結果為向量時,例如子選取為 IN 或 NOT IN 的右手運算元時,則需要不同的方法。在此情況下,子選取的結果會儲存在暫時表格中,且會使用 Found 或 NotFound 運算子來測試該表格的內容。請考慮這個範例
SELECT * FROM examp WHERE two IN (SELECT three FROM examp2);
產生來實作此最後一個查詢的程式碼如下
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 OpenTemp 1 1
1 Integer 0 0
2 OpenRead 2 5 examp2
3 VerifyCookie 0 909
4 Rewind 2 10
5 Column 2 0
6 IsNull -1 9
7 String 0 0
8 PutStrKey 1 0
9 Next 2 5
10 Close 2 0
11 ColumnName 0 0 one
12 ColumnName 1 0 two
13 整數 0 0
14 OpenRead 0 3 examp
15 倒帶 0 25
16 Column 0 1
17 非空 -1 20
18 彈出 1 0
19 跳轉 0 24
20 找不到 1 24
21 欄位 0 0
22 欄位 0 1
23 回呼 2 0
24 下一個 0 16
25 關閉 0 0
26 暫停 0 0
儲存內部 SELECT 結果的暫存表是由 0 處的 OpenTemp 指令建立的。此操作碼用於僅存在於單一 SQL 陳述式期間的表格。即使主資料庫為唯讀,暫存指標也會始終開啟讀取/寫入。暫存表會在指標關閉時自動刪除。P2 值 1 表示指標指向 BTree 索引,它沒有資料,但可以有任意鍵。
內部 SELECT 陳述式由指令 1 到 10 實作。此程式碼所做的就是為 examp2 表格中「three」欄位具有非 NULL 值的每一列在暫存表中建立一個項目。每個暫存表項目的鍵是 examp2 的「three」欄位,而資料是空字串,因為它從未被使用過。
外部 SELECT 由指令 11 到 25 實作。特別是,包含 IN 運算子的 WHERE 子句由 16、17 和 20 處的指令實作。指令 16 將目前列的「two」欄位值推入堆疊,而指令 17 檢查它是否為非 NULL。如果成功,執行會跳到 20,在該處測試堆疊頂端是否與暫存表中的任何鍵相符。其餘程式碼與之前顯示的相同。
SQLite 也允許使用運算子 UNION、UNION ALL、INTERSECT 和 EXCEPT 將兩個或多個 SELECT 陳述式作為對等項目加入。這些複合選取陳述式使用暫存表實作。每個運算子的實作略有不同,但基本概念相同。我們將使用 EXCEPT 運算子作為範例。
SELECT two FROM examp EXCEPT SELECT four FROM examp2;
此最後範例的結果應該是 examp 表格中「two」欄位的每個唯一值,但會移除 examp2 的「four」欄位中的任何值。實作此查詢的程式碼如下
addr opcode p1 p2 p3
---- ------------ ----- ----- -----------------------------------
0 OpenTemp 0 1
1 KeyAsData 0 1
2 Integer 0 0
3 OpenRead 1 3 examp
4 VerifyCookie 0 909
5 Rewind 1 11
6 欄位 1 1
7 MakeRecord 1 0
8 String 0 0
9 PutStrKey 0 0
10 Next 1 6
11 Close 1 0
12 Integer 0 0
13 OpenRead 2 5 examp2
14 倒回 2 20
15 欄位 2 1
16 建立記錄 1 0
17 找不到 0 19
18 刪除 0 0
19 下一個 2 15
20 關閉 2 0
21 欄位名稱 0 0 four
22 倒回 0 26
23 欄位 0 0
24 回呼 1 0
25 下一個 0 23
26 Close 0 0
27 Halt 0 0
產生結果的暫時性資料表是由指令 0 建立的。接著有三個迴圈。指令 5 到 10 的迴圈實作第一個 SELECT 陳述式。第二個 SELECT 陳述式是由指令 14 到 19 的迴圈實作的。最後,指令 22 到 25 的迴圈會讀取暫時性資料表,並針對結果中的每一列呼叫回呼一次。
指令 1 在這個範例中特別重要。通常,欄位指令會從 SQLite 檔案條目的資料中,萃取較大記錄中的欄位值。指令 1 會在暫時性資料表上設定一個旗標,以便欄位會將 SQLite 檔案條目的金鑰視為資料,並從金鑰中萃取欄位資訊。
以下是將發生的情況:第一個 SELECT 陳述式將建構結果列,並將每列儲存為暫時表中某個項目的金鑰。暫時表中每個項目的資料從未使用過,因此我們以空字串填入。第二個 SELECT 陳述式也會建構列,但第二個 SELECT 建構的列會從暫時表中移除。這就是我們希望列儲存在 SQLite 檔案的金鑰中,而不是資料中,如此一來便能輕鬆地找到並刪除它們。
讓我們更仔細地檢視這裡發生的情況。第一個 SELECT 由指令 5 到 10 中的迴圈實作。指令 5 透過倒轉游標來初始化迴圈。指令 6 從「examp」中擷取「two」欄位的數值,而指令 7 將其轉換成列。指令 8 將空字串推入堆疊中。最後,指令 9 將列寫入暫時表中。但請記住,PutStrKey opcode 使用堆疊頂端作為記錄資料,而堆疊中的下一個作為金鑰。對於 INSERT 陳述式,MakeRecord opcode 所產生的列是記錄資料,而記錄金鑰是 NewRecno opcode 所建立的整數。但這裡的角色相反,MakeRecord 所建立的列是記錄金鑰,而記錄資料只是一個空字串。
第二個 SELECT 由指令 14 到 19 實作。指令 14 透過倒轉游標來初始化迴圈。新的結果列是由「examp2」表格的「four」欄位透過指令 15 和 16 所建立。但我們並未使用 PutStrKey 將這個新列寫入暫時表中,而是呼叫 Delete 以從暫時表中移除它(如果它存在的話)。
複合選取的結果會透過指令 22 到 25 中的迴圈傳送至回呼常式。這個迴圈沒有什麼新奇或特別的地方,除了指令 23 中的 Column 指令會從記錄金鑰中擷取欄位,而不是記錄資料。
本文已檢閱 SQLite 的 VDBE 用來實作 SQL 陳述式的所有主要技術。未顯示的是,這些技術大多數可以用於組合,以產生適當複雜查詢陳述式的程式碼。例如,我們已展示如何在簡單查詢中完成排序,以及如何實作複合查詢。但我們未提供複合查詢中排序的範例。這是因為對複合查詢排序不會引入任何新概念:它只在同一個 VDBE 程式中結合兩個先前的概念(排序和複合)。
有關 SQLite 函式庫如何運作的更多資訊,建議讀者直接查看 SQLite 原始碼。如果您了解本文中的資料,您不應難以遵循原始碼。認真學習 SQLite 內部結構的學生,可能也會想要仔細研究 VDBE 操作碼,如此處所記錄。大多數操作碼文件都是使用腳本從原始碼中的註解中擷取,因此您也可以直接從 vdbe.c 原始碼檔案取得關於各種操作碼的資訊。如果您已成功閱讀至此,您應不難理解其餘的內容。
如果您在文件或程式碼中發現錯誤,請隨時修正它們和/或透過 drh@hwaci.com 聯絡作者。我們隨時歡迎您的錯誤修正或建議。
此頁面最後修改於 2022-01-08 05:02:57 UTC