SQLite 事務


事務定義了一組 SQL 命令的邊界,這組命令或者作為一個整體被全部執行,或者都不執行,這被稱為數據庫完整性的原子性原則。這種關系的典型例子就是銀行轉賬,假設銀行程序從一個賬戶向另一個賬戶轉賬,轉賬程序通過如下方式進行:首先將第一個賬戶的錢轉入第二個賬戶,然后從第一個賬戶刪除對應的數目;或者首先從第一個賬戶刪除要轉賬的數目,然后向第二個賬戶插入對應的數目。無論哪種方式,都是通過兩步完成的:先插入,后刪除;或者先刪除,后插入。

但是在轉賬期間,如果數據庫服務器突然奔潰或電力中斷,第二個操作沒有完成怎么辦?要么這筆錢存在於兩個賬戶(第一種方式),要么這筆錢在兩個賬戶中都不存在(第二種方式)。無論發生哪種情況,總有人無法接受。數據庫也處於不一致的狀態,關鍵是這兩步操作必須要同時被執行或者一步都不執行。這就是事務的本質。

一、事務的范圍

事務由 3 個命令控制:begincommit 和 rollback。begin 開始一個事務,begin 之后的所有操作都可以取消,如果連接中止前沒有發出 commit,也會被取消。commit 提交事務開始后所執行的所有操作。類似地,rollback 還原 begin 之后的所有操作。例如:

1
2
3
4
BEGIN;
DELETE FROM FOODS;
ROLLBACK;
SELECT COUNT(*) FROM FOODS;

此處輸入圖片的描述

上面開始了一個事務,先刪除了 FOODS 表的所有行,但是又用 rollback 進行了回滾。在執行 SELECT 時發現表中沒有發生任何改變。

默認情況下,SQLite 中每條 SQL 語句自成事務(自動給提交模式)。也就是說,如果你沒有使用 begin...commit/rollback 定義事務的范圍,SQLite 默認每條單獨的 SQL 命令就是有 begin...commit/rollback 的事務。這種情況下,所有成功完成的命令都自動提交。同樣,所有遇見錯誤的命令都回滾。這種操作模式(隱式事務)也稱為自動提交模式:SQLite 以自動提交模式運行單個命令,如果命令沒有失敗,那它將自動提交。

SQLite 也支持 savepoint 和 release 命令,這些命令擴展了事務的靈活性,包含多個語句的工作體可以設置 savepoint,回滾可以返回到某個 savepoint。創建 savepoint 和啟動 savepoint 命令一樣簡單,如下所示:

1
savepoint justincase;

如果意識到需要返回到某個地方,不用回滾整個事務,可以使用如下命名回滾:

1
rollback [transaction] to justincase;

本例使用 justincase 作為 savepoint 名稱,你也可以使用其他名稱。

二、沖突解決

如前所述,違反約束會導致事務的中止。在對數據進行很多修改的過程中,命令中止會造成什么后果?大多數數據庫(管理系統)都是簡單地將前面所做的修改全部取消。這也是數據庫處理違反約束的方式。

SQLite 有其獨特的方法允許你指定不同的方式來處理約束違反(或者說從約束違反中恢復),這種功能被稱為沖突解決,如下例所示:

此處輸入圖片的描述

當 UPDATE 語句執行到第 6 個記錄時,它視圖將 ID 更新為 15-6=9,但是 ID 為 9 的記錄已經存在,違反了唯一性約束。因此,該命令終止。但是在違反約束前,SQLite 已經更新了 5 個記錄。應該如何處理?默認行為是終止命令並回滾所有的修改,保存事務的完整性。

如果你想讓已經修改的 5 個記錄繼續保留,該怎么辦?其實只需要使用恰當的沖突解決方案就行。SQLite 提供 5 種可能的沖突解決方案或策略,它們可以用來解決沖突(約束違反):replaceignorefailabort 和 rollback。這 5 種方法定義了錯誤容忍范圍或敏感度:從最寬松的 replace,到最嚴格的 rollback。這些解決方法的定義如下(按嚴重度排序):

  • replace:當違反了唯一性約束時,SQLite 將造成這種違反的記錄刪除,以插入或修改的新記錄替代,SQL 繼續執行,且不報錯。如果違反了 NOT NULL 約束,使用該字段的默認值替代 NULL。如果該字段沒有默認值,SQLite 應用 abort 策略。有一點要特別注意,當沖突解決策略為了滿足約束而刪除記錄時,該行的刪除觸發器不會被觸發。這種行為可能在將來的版本中改變。

  • ignore:當約束違反發生時,SQLite 允許命令繼續執行,違反約束的行保持不變。而它之前和之后的記錄都繼續修改。也就是說,所有會觸發約束違反的行都不動,保持原貌,命令繼續處理其他的,且不報錯。

  • fail:當約束違反發生時,SQLite 終止命令,但是不恢復約束違反之前已經修改的記錄。也就是說,在約束違反發生前的改變都保留。例如,如果 UPDATE 命令在第 100 行違反約束,那么前 99 行已經修改的記錄不會回滾。但是對第 100 行和之外的改變不會發生,因為命令已經終止了。

  • abort:當約束違反發生時,SQLite 恢復命令所做的所有改變並終止命令。abort 是 SQLite 中所有操作的默認解決方法,也是 SQL 標准定義的行為。注意:abort 也是最昂貴的沖突解決策略——要求額外的工作,設置要求沒有沖突發生。

  • rollback:當約束違反發生時,SQLite 執行回滾——終止當前命令和整個事務。最終結果就是當前命令所做的改變和事務中之前命令的改變都被回滾。這也是最嚴格的沖突解決方法,單個約束違反導致事務中執行的所有操作都回滾。

沖突解決方法既可以在 SQL 命令中指定,也可以在表和索引的定義中執行。具體地講,沖突解決方法可以在 insertupdatecreate table 和 create index 中指定。此外,它在觸發器中也有具體含義。沖突解決方法在 insert 和 update 中的語法形式如下:

1
2
insert or resolution into table (column_list) values (value_list);
update or resolution table set (value_list) where predicate;

沖突解決策略緊跟在 insert 或 update 命令后面,並加上前綴 or。insert 或 replae 表達式可以縮寫成 replace。這與其他數據庫中的 “merge” 或 “upsert” 行為類似。

前面的 UPDATE 例子中,已經更新的 5 行要回滾,因為使用了默認的 abort 沖突解決方法。如果要繼續保留已更新的行,可以使用 fail 沖突解決方法。下面的例子說明了如何使用。為了不影響原始表 FOODS,我們可以將 FOODS 表的數據復制到新表 TEST 中,並用表 TEST 做實驗。FOODS 表建表語句和復制語句如下:

1
2
3
4
5
6
7
8
9
10
11
-- 創建 FOODS 表,插入數據
CREATE TABLE FOODS(ID INTEGER PRIMARY KEY, NAME TEXT, TYPE_ID INTEGER);
INSERT INTO FOODS (NAME, TYPE_ID) VALUES ('蘋果', 1);
INSERT INTO FOODS (NAME, TYPE_ID) VALUES ('桔子', 1);
INSERT INTO FOODS (NAME, TYPE_ID) VALUES ('西瓜', 1);
INSERT INTO FOODS (NAME, TYPE_ID) VALUES ('芹菜', 2);
INSERT INTO FOODS (NAME, TYPE_ID) VALUES ('黃瓜', 2);
INSERT INTO FOODS (NAME, TYPE_ID) VALUES ('土豆', 2);
INSERT INTO FOODS (NAME, TYPE_ID) VALUES ('牛肉', 3);
INSERT INTO FOODS (NAME, TYPE_ID) VALUES ('豬肉', 3);
INSERT INTO FOODS (NAME, TYPE_ID) VALUES ('雞肉', 3);
1
2
3
4
-- 復制 FOODS 表數據到 TEST 表中
CREATE TABLE TEST(ID INTEGER PRIMARY KEY, NAME TEXT, TYPE_ID INTEGER);
INSERT INTO TEST SELECT * FROM FOODS;
SELECT * FROM TEST;

此處輸入圖片的描述

數據復制到新表 TEST 后,可以在 TEST 表中添加一個名為 MODIFIED 的字段,默認值是 N。在 UPDATE 語句中,將其更新為 Y 以追蹤哪些記錄在約束違反發生前更新了。使用 fail 沖突解決方法時,這些更新將保留,可以追蹤之后有多少記錄被更新了。

1
2
3
CREATE UNIQUE INDEX TEST_IDX ON TEST(ID);
ALTER TABLE TEST ADD COLUMN MODIFIED TEXT NOT NULL DEFAULT 'N';
SELECT * FROM TEST WHERE MODIFIED='N';

此處輸入圖片的描述

好了,現在可以使用 fail 沖突解決方法來追蹤被更新的記錄了。更新報錯,然后查詢修改狀態為 N 的數據,和預期一致。

1
UPDATE OR FAIL TEST SET ID=15-ID, MODIFIED='Y';

此處輸入圖片的描述

1
SELECT * FROM TEST WHERE MODIFIED='Y';

此處輸入圖片的描述

注意:fail 需要額外考慮。記錄更新的順序是不確定的,也就是說,你無法確定記錄在表中的順序或者 SQLite 以何種順序處理它們。你可能假設它遵從 rowid 字段順序,但是事實並不總是這樣,文檔中從未這樣說過。再次重申,與任何數據庫工作時,不要假設有某種隱式的順序。如果你正在使用 fail,很多情況下,使用 ignore 會更好。ignore 會完成工作,修改所有可以修改的記錄,而不是從第一個約束違反處跳出。

表內定義時,可以為單個字段指定沖突解決方法,例如:

1
2
3
4
CREATE TEMP TABLE CAST(NAME TEXT UNIQUE ON CONFLICT ROLLBACK);
INSERT INTO CAST VALUES('Jerry');
INSERT INTO CAST VALUES('Elaine');
INSERT INTO CAST VALUES('Kramer');

CAST 表只有一個字段 NAME,該字段具有唯一約束性,沖突解決方法設置為 ROLLBACK

任何觸發該字段約束的 insert 或 update 語句都由 rollback 沖突解決來裁決,而不是默認的 abort。結果不僅是該語句被回滾,而且該語句所在的整個事務都將回滾。

此處輸入圖片的描述

1
2
BEGIN;
INSERT INTO CAST VALUES('Jerry');

此處輸入圖片的描述

1
COMMIT;

此處輸入圖片的描述

本例中的 commit 失敗是因為 NAME 字段的沖突解決方法已經終止了事務。create index 的工作方式類似。當某些字段是約束違反的源頭時,表或索引內的沖突解決方法將默認的 abort 改變為這些字段上定義的沖突解決方法。

需要注意的是:沖突解決方法是語句級的(DML),可以覆蓋對象級(DDL)定義的。以前面為例:

1
2
3
BEGIN;
INSERT OR REPLACE INTO CAST VALUES('Jerry');
COMMIT;

此處輸入圖片的描述

replace 沖突解決方法可以覆蓋 CAST.NAME 中定義的 rollback 沖突解決方法。

三、數據庫鎖

在 SQLite 中,鎖和事務是緊密聯系的。要有效地使用事務,需要了解一些關於如何加鎖的知識。SQLiite 采用粗粒度的鎖。當一個連接要寫數據庫時,所有其他的連接被鎖住,直到寫連接結束它的事務。SQLite 有一個加鎖表,用來幫助不同的寫數據庫都能在最后一刻再加鎖,以保證最大的並發性。

SQLite 使用鎖逐步提升機制,為了寫數據庫,連接需要逐級獲得排它鎖。SQLiite 有 5 種不同的鎖狀態:未加鎖(unlocked)、共享(shared)、預留(reserved)、未決(pending)和排它(exclusive)。每個數據庫連接在同一時刻只能處於其中一個狀態,每個狀態(未加鎖狀態除外)都有一種鎖與之對應。

最初的狀態是未加鎖狀態,在此狀態下,連接還沒有訪問數據庫。當連接一個數據庫,甚至已經用 BEGIN 開始了一個事務時,連接都還處於未加鎖狀態。

未加鎖的下一個狀態是共享狀態。為了能夠從數據庫種讀(不是寫)數據,連接必須首先進入共享狀態,也就是說,首先要獲得一個共享鎖。多個連接可以同時獲得並保持共享鎖,也就是說,多個連接可以同時從同一個數據庫中讀數據。但哪怕只有一個共享鎖還沒有釋放,也不允許任何連接寫數據庫。

如果一個連接想要寫數據庫,它必須首先獲得一個預留鎖。一個數據庫同時只能有一個預留鎖,該預留鎖可以與共享鎖共存,它是寫數據庫的第一階段。預留鎖既不阻止其他擁有共享鎖的連接繼續讀取數據庫,也不阻止其他連接獲得新的共享鎖。

一旦一個連接獲得了預留鎖,它就可以開始處理數據庫修改操作了,盡管這些修改只能在緩沖區中進行,而不是實際寫到磁盤,對讀出內容所做的修改保存在內存緩沖區中。

當連接想要提交修改(事務)時,需要將預留鎖提升為排它鎖。為了得到排它鎖,還必須首先將預留鎖提升為未決鎖。獲得未決鎖之后,其他連接就不能再獲得新的共享鎖了,但已經擁有共享鎖的連接仍然可以繼續正常讀數據庫。此時,擁有未決鎖的連接等待其他擁有共享鎖的連接完成工作並釋放其共享鎖。

一旦所有的其他共享鎖都被釋放,擁有未決鎖的連接就可以將其鎖提升至排它鎖,此時就可以自由地對數據庫進行修改。所有以前緩存的修改都會被寫到數據庫文件中。

四、死鎖

雖然你可能覺得前面關於鎖的討論很有趣,但是你可以能也在考慮為什么鎖如此重要?為什么需要了解鎖的機制呢?如果不了解你正在做什么,你可能會陷入死鎖。

考慮下表的情況,兩個連接 A 和 B 完全不知道對方在同一時刻對同一數據庫進行操作。連接 A 啟動第一個命令,B 啟動第二、第三個命令,A 啟動第四個,如此等等。

假設的死鎖情況
執行順序 連接A 連接B
1 sqlite> begin;  
2   sqlite> begin;
3   sqlite> insert into foo values('x')
4 sqlite> select * from foo;  
5   sqlite> commit;
6   SQL error:database is locked
7 sqlite> insert into foo values('x')  
8 SQL error:database is locked  

兩個連接都在死鎖中結束。B 首先嘗試寫數據庫,也就擁有了一個未決鎖。A 再視圖寫,但當其 insert 語句視圖將共享鎖提升為預留鎖時失敗。

為了方便討論,在此假設連接 A 和 B 都一直在等待數據庫可寫。那么此時,其他的連接都被鎖在外面。如果試圖打開第三個連接,它甚至不能讀數據庫。因為 B 擁有未決鎖(它能阻止其他連接獲得共享鎖)。那么此時,不僅 A 和 B 死鎖了,而且它們也將其他人鎖在數據庫外。基本上,有共享鎖和未決鎖的那些連接如果不想放棄控制,其他所有的進程都不能再操作此數據庫了。

如何避免死鎖?當然不能讓 A 和 B 坐在會議室通過彼此的律師談判解決,因為它們甚至不知道彼此的存在。答案時采用正確的事務類型來完成工作。

五、事務的類型

SQLite 有三種不同的事務類型,它們以不同的鎖狀態啟動事務。事務可以開始於:deferred、immediate 或 exclusive。事務類型在 begin 命令中指定:

1
begin [ deferred | immediate | exclusive ] transaction;

一個 deferred 直到必須使用時才獲取鎖。因此,對於延遲事務,begin 語句本身不會做什么事情——它從未鎖定狀態開始。這是默認的情況。如果僅僅用 begin 開始一個事務,那么事務就是延遲的,停留在未鎖定狀態。多個連接可以在同一時刻未創建任何鎖的情況下開始延遲事務。這種情況下,第一個對數據庫的讀操作獲取共享鎖,類似地,第一個對數據庫的寫操作試圖獲取預留鎖。

由 begin 開始的 immediate 事務在 begin 執行時試圖獲取預留鎖。如果成功,begin immediate 保證沒有其他的連接可以寫數據庫。正如你知道的,其他的連接可以繼續對數據庫進行讀操作,但是,預留鎖會阻止其他新的讀取數據庫。預留鎖的另一個結果是沒有其他連接能成功啟動 begin immediate 或者 begin exclusive 命令,當其他連接執行上述命令時,SQLite 會返回 SQLITE_BUSY 錯誤。這時你可以對數據庫進行修改操作,但是你還不能提交,當調用 commit 時,會返回 SQLITE_BUSY 錯誤。這意味着還有其他的讀事務沒有完成,需要等它們執行完后才能提交事務。

exclusive 事務會試着獲取對數據庫的排它鎖。這與 immediate 的工作方式類似,但是一旦成功,exclusive 事務保證數據庫中沒有其他的活動連接,所以就可對數據庫進行任意的讀寫操所。

前面例子的問題在於兩個連接最終都想寫數據庫,但是它們都沒有放棄各自原來的鎖,最終,共享鎖導致了問題。如果兩個連接都以 begin immediate 開始事務,那么死鎖就不會發生。在這種情況下,在同一時刻只能有一個連接進入 begin immediate,其他的連接就得等待。必須等待的連接將會不斷嘗試以確保它最終能開始 immediate 事務。如果所有想對數據庫進行寫操作的連接使用 begin immediate 和 begin exclusive,那它就提供了一種同步機制,通過這種機制防止了死鎖的產生。要使這種方式可以工作,所有的人都必須遵守規則。

基本的准則是:如果使用的數據庫沒有其他連接,用 begin 就足夠了。但是,如果使用的數據庫有其他也會對數據庫進行寫操作的連接,就得使用 begin immediate 或 begin exclusive 開啟事務。

From:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM