Categories
程式開發

數據庫內核雜談(十):事務、隔離、並發(1)


本篇文章選自數據庫內核雜談系列文章。

在之前的文章,我們和大家分享了基本的數據庫優化器和執行器。這篇文章,我們要分享一個很重要的概念:事務及其相關實現。

事務(transaction)和ACID

事務的定義是:一個事務是一組對數據庫中數據操作的集合。無論集合中有多少操作,對於用戶來說,只是對數據庫狀態的一個原子改變。

單從概念定義來理解,可能有些晦澀難懂,我們舉個例子來講解:數據庫中有兩個用戶的銀行賬戶A:100元; B:200元。假設事務是A轉賬50元到B,可以理解為這個事務由兩個操作組成:1) A-= 50; 2) B+=50。對於用戶來說,數據庫對於這個事務只有兩個狀態:執行事務前的初始狀態,即A:100元; B:200元,以及執行事務後的轉賬成功狀態:A:50元;B:250元,不會有中間狀態,比如錢從A已經扣除,卻還沒轉到B上:A:50元; B:200元。

一個事務的所有操作要么全部執行,要么一個都不執行。如果在執行事務的過程中,因為任何原因導致事務失敗,已經執行的操作都要被回滾(rollback)。這種“all-or-none”的屬性就是所謂的事務的原子性(atomicity)。

當一個事務被認定執行成功後,即代表這個事務的操作被數據庫持久化。因此,即使數據庫在此時奔潰了,比如進程被殺死了,甚至是服務器斷電了,這個事務的操作依然有效,這就是事務的另一個屬性,持久性(durability)。

假定數據庫的初始狀態是穩定的,或者說對用戶來說是一致的。由於事務執行的原子性,即執行失敗就回滾到執行前的狀態,執行成功就變成一個新的穩定狀態。因此,事務的執行會保持數據庫狀態的一致性(consistency)。

數據庫系統是多用戶系統。多個用戶可能在同一時間執行不同的事務,稱為並發。如果想要做到事務的原子性,那麼數據庫就必須做到並發的事務互不影響。從事務的角度出發,在執行它本身的過程中,不會感知到其他事務的存在。從數據庫的角度出發,即使同一時間有多個事務並發,從微觀尺度上看,它們之間也有先來後到,必須等一個事務完成後,另一個事務才開始。這種並發事務之間的不感知就是所謂的事務隔離性(isolation)。

總之,一個事務是一組對數據庫中數據操作的集合。事務,對於數據庫系統,具有原子性(atomicity),一致性(consistency),隔離性(isolation),以及持久性(durability)。曾經聽過這樣一個觀點,事務的出現主要是針對並發。其實不然,ACID屬性中只有隔離性是針對並發事務的。所以,即使數據庫系統是一個單用戶系統,我們依然希望事務具有原子性、一致性和持久性。

隔離級別(Isolation Level)

如果讓你來實現事務的隔離性,最容易的辦法,你會想到什麼?我想絕大部分的讀者都會想到,給數據庫加一個全局的操作鎖,在同一時間裡只允許一個用戶對數據庫進行操作,這就保證了隔離性。

的確,這樣可以保證隔離性,但也限制了並發性,對數據庫的性能產生了極大的影響。在實際情況中,沒有數據庫會這麼去實現。並且這個世界並非非黑即白,隔離性也並不是有或者沒有。數據庫一般會提供多種隔離性的級別,供用戶選擇:越嚴格的隔離級別越接近全局鎖,越寬鬆的隔離級別越能提高並發。天下沒有免費的午餐,寬鬆的隔離級別也會隨之帶來一些問題。

我們結合併發事務可能帶來的問題,來講述一下不同的隔離級別。

首先,我們定義一個相對簡單的事務模型,方便後續討論各種隔離級別和可能遇到的數據問題。雖然數據庫支持各種複雜的操作,但歸根到底就是對數據基本單元的讀寫操作,對於任一給定數據單元A,我們定義read(A),write(A, val)分別為讀取和寫入操作。同時,對於事務,提供begin(開啟事務), commit(提交事務), rollback(回滾事務)操作。

先從最寬鬆的隔離級別開始,read uncommitted(讀未提交)。顧名思義,讀未提交就是在一個事務中,允許讀取其他事務未提交的數據。下圖示例很清晰地詮釋了讀未提交:

數據庫內核雜談(十):事務、隔離、並發(1) 1

在事務T1中,讀取A得到結果是5,是因為事務T2修改了A的值,雖然當時T2還未提交,甚至最後T2回滾了。讀未提交導致的問題就是dirty read(臟讀)。臟讀的定義就是,一個事務讀取了另一個事務還未提交的修改。雖然可能大多數情況下,我們都會認為臟讀產生了不正確的結果。但是,拋開業務談正確性都是耍流氓。或許,某些用戶的某些業務,為了支持更大地並發,允許臟讀的出現。因為,對於讀未提交,完全不需要對操作進行加鎖,自然並發性更高。

如何避免臟讀呢?數據庫引入了第二層的隔離級別,read committed(讀提交)。讀提交就是指在一個事務中,只能夠讀取到其他事務已經提交的數據。

在讀提交的隔離級別下,再回看上面的例子,T1中讀取A的值就應該還是10,因為當時T2還沒有提交。沿著上面的例子,接著往下看,如果最後T2提交了事務,而T1在之後又讀取了一次A,這時候的值就變為5了。

數據庫內核雜談(十):事務、隔離、並發(1) 2

這又出現了什麼問題呢?在T1事務中,先後讀取了兩次A,兩次的值不一樣了。回顧最早提及的事務的隔離性,兩次讀取同一數據的值不一樣,其實違反了隔離性。因為隔離性定義了一個事務不需要感知其他事務的存在,但顯然,由於值不同,說明在這個過程中另一個事務提交了數據。這類問題就被定義為nonrepeatable read(不可重複度讀):在一個事務過程中,可能出現多次讀取同一數據但得到的值不同的現象。

如何避免不可重複度這個問題呢?數據庫引入了第三層隔離級別,根據上面的經驗,你可能已經猜出來了,名稱就叫做repeatable read(可重複讀)。可重複讀指的是在一個事務中,只能讀取已經提交的數據,且可以重複查詢這些數據,並且,在重複查詢之間,不允許其他事務對這些數據進行寫操作。雖然我們還沒講到實現,但不難想像,對讀數據加讀鎖鎖就能實現。

對於可重複讀級別來說,上述例子中的兩次讀取都會得到數據是10。讀者可能會有疑問,那彼時T2的commit會失敗嗎?如果是加鎖實現的可重複讀,那T2的commit就會hold在那,直至T1結束,取決於T1最後有沒有更新A,如果有,T2就會失敗。

可重複讀,似乎看上去很完美,解決了所有並行事務帶來的不確定性。其實不然,我們通過下面這個SQL語句的例子來看:

T1:
BEGIN;
SELECT * FROM students WHERE class_id = 1;  // (1)
... 
SELECT * FROM students WHERE class_id = 1;  // (2)
...
COMMIT;

上面示例中的查詢語句(1)和(2),在可重複讀隔離級別下,應該返回相同的結果嗎?乍一看,應該覺得,沒錯啊。但可重複讀隔離級別只是規定對被已經讀取的數據,禁止其他事務進行修改。那如果是下面這個事務呢?

T2:
BEGIN;
INSERT INTO students (1 /* class_id */, ...);
COMMIT; 

T2事務並沒有修改現有數據,而是新增了一條新數據,恰巧class_id = 1。如果這條插入介於(1)和(2)之間,(2)的結果會改變嗎?答案是,會的。語句(2)會比(1)多顯示一條記錄,即T2插入的。這個問題被稱為phantom read(幻讀),指的是,在一個事務中,當查詢了一組數據後,再次發起相同查詢,卻發現滿足條件的數據被另一個提交的事務改變了。

如何才能避免幻讀呢?數據庫系統只能推出最保守的隔離機制,serializable(可有序化),即所有的事務必須按照一定順序執行,直接避免了不同事務並髮帶來的各種問題。

數據庫系統針對不同需求,推出了不同的隔離級別,由寬到緊分別是:

1)讀未提交:在一個事務中,允許讀取其他事務未提交的數據。

2)讀提交:在一個事務中,只能夠讀取到其他事務已經提交的數據。

3)可重複讀:在一個事務中,只能讀取已經提交的數據,且可以重複查詢這些數據,並且,在重複查詢之間,不允許其他事務對這些數據進行寫操作。

4)可有序化:所有的事務必須按照一定順序執行。

而後三種隔離級別分別為了解決前一種隔離級別遇到的問題:

1)臟讀:一個事務讀取了另一個事務還未提交的修改。

2)不可重複度:在一個事務過程中,可能出現多次讀取同一數據但得到不同值的現象。

3)幻讀:在一個事務中,當查詢了一組數據後,再次發起相同查詢,卻發現滿足條件的數據被另一個提交的事務改變了。

下方列出了一張表格,更直觀地展現它們之間的關係。

隔離級別 臟讀 不可重複度 幻讀
讀未提交 可能出現 可能出現 可能出現
讀提交 不能 可能出現 可能出現
可重複讀 不能 不能 可能出現
可有序化 不能 不能 不能

總結

這篇文章主要覆蓋了事務的定義、ACID屬性以及對於隔離性,數據庫推出的不同隔離級別。雖然並沒有提到很多的實現,不過,理清這些概念對於理解和學習事務的實現是很有必要的。預告一下,下篇文章我們會分享事務的實現。