小巧、快速、可靠。
任選三項。

SQLite 的虛擬資料庫引擎

過時文件警告:此文件說明 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 在準備程式執行時會附加該指令。

追蹤 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 的工作是提出 nColumnazData[]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 程式範本

前兩個查詢範例說明每個 SELECT 程式都會遵循的一種範本。基本上,我們有

  1. 為回呼初始化 azColumnName[] 陣列。
  2. 開啟要查詢的資料表游標。
  3. 針對資料表中的每筆記錄,執行下列動作
    1. 如果 WHERE 子句評估為 FALSE,則略過後續步驟並繼續到下一筆記錄。
    2. 計算結果目前列的所有欄位。
    3. 針對結果目前列呼叫回呼函數。
  4. 關閉游標。

當我們考慮其他複雜情況(例如聯結、複合選取、使用索引加快搜尋、排序以及帶有或不帶有 GROUP BY 和 HAVING 子句的聚合函數)時,此範本將會大幅擴充。但相同的基本概念將持續適用。

UPDATE 和 DELETE 陳述式

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

使用 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 有足夠的了解,可以自行找出上述程式如何運作。因此,我們在本文中不會再進一步討論它。

聯結

在聯結中,會將兩個或多個表格合併以產生單一結果。結果表格包含要聯結的表格中每一列的組合。實作此功能最簡單且最自然的方式是使用巢狀迴圈。

回想上面討論的查詢範本,其中有一個單一迴圈會搜尋表格中的每一筆記錄。在聯結中,我們基本上會執行相同的事情,只不過有巢狀迴圈。例如,若要聯結兩個表格,查詢範本可能會類似下列範例

  1. 為回呼初始化 azColumnName[] 陣列。
  2. 開啟兩個游標,每個游標對應一個要查詢的兩個表格。
  3. 對於第一個表格中的每筆記錄,執行
    1. 對於第二個表格中的每筆記錄,執行
      1. 如果 WHERE 子句評估為 FALSE,則略過後續步驟並繼續到下一筆記錄。
      2. 計算結果目前列的所有欄位。
      3. 針對結果目前列呼叫回呼函數。
  4. 關閉兩個游標。

此範本可以運作,但可能會很慢,因為我們現在處理的是 O(N2) 迴圈。但 WHERE 子句通常可以分解為多個項目,其中一個或多個項目只會涉及第一個表格中的欄位。當發生這種情況時,我們可以將 WHERE 子句測試的一部分從內部迴圈中分解出來,並大幅提升效率。因此,更好的範本會類似以下內容

  1. 為回呼初始化 azColumnName[] 陣列。
  2. 開啟兩個游標,每個游標對應一個要查詢的兩個表格。
  3. 對於第一個表格中的每筆記錄,執行
    1. 評估 WHERE 子句中只涉及第一個表格欄位的項目。如果任何項目為 false(表示整個 WHERE 子句必定為 false),則跳過此迴圈的其餘部分,並繼續進行下一筆記錄。
    2. 對於第二個表格中的每筆記錄,執行
      1. 如果 WHERE 子句評估為 FALSE,則略過後續步驟並繼續到下一筆記錄。
      2. 計算結果目前列的所有欄位。
      3. 針對結果目前列呼叫回呼函數。
  4. 關閉兩個游標。

如果可以使用索引來加速兩個迴圈中的任一個搜尋,則可以進一步提升速度。

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 對於聯結中的表格不施加任何任意限制。它也允許表格與自身聯結。

ORDER BY 子句

基於歷史原因和效率,目前所有排序都在記憶體中執行。

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 關閉。

聚合函數與 GROUP BY 及 HAVING 子句

為了計算聚合函數,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 實作。

使用 SELECT 陳述式作為表達式中的項目

「結構化查詢語言」這個名稱本身就告訴我們,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,在該處測試堆疊頂端是否與暫存表中的任何鍵相符。其餘程式碼與之前顯示的相同。

複合 SELECT 陳述式

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