我們繼關系型數據庫事務一:概念之后,再聊聊隔離級別(Isolation Level)。
隔離級別是為了解決並發所帶來的問題的,我們期望並發的結果跟串行化(一個之后接一個)一樣。實際上,串行化(Serializability)是最強的隔離級別,能解決世間所有並發問題帶來的痛苦。那還有什么好說的?不難想象,串行化隔離有嚴重的性能問題,並且很多數據庫都沒有實現它,而是開發了一些弱隔離級別,每種隔離級別解決某些並發問題。所以,我們需要搞明白,到底存在哪些並發問題?某種隔離級別是如何解決問題的?如何選擇隔離級別解決問題?我們的目的是利用工具和知識構建可靠,正確的應用程序,而不是盲目地使用工具。
隔離性如同雙刃劍,一邊是性能,另一邊是安全。
Read Committed(讀已提交)
最基本的隔離級別是read committed(讀已提交),它提供了兩個保證:
- 從數據庫讀時,只能讀取已經提交的數據。(沒有贓讀,dirty reads)
- 寫入數據庫時,只會覆蓋已經寫入(提交)的數據。(沒有贓寫,dirty writes)
沒有贓讀
一個事務若能看到了另一個事務過程中(未提交或未中止)的數據,則為贓讀。Read committed保障沒有贓讀,例如圖1所示。
防止贓讀解決的問題:
- 如果一個事務更新多個對象,贓讀意味着其他事務可能會只看到部分更新。例如在關系型數據庫事務一:概念中的圖2,用戶看到了新郵件,但是未讀計數器卻是0,讓人很困惑。這種只看到部分更新的問題也可能導致錯誤的決定。
- 如果事務將來中止,那么之前的改變會被回滾掉。若允許贓讀,那其他事務就有可能讀到了某個將來被回滾的數據,感覺更不合理。。。。
沒有臟寫
如果兩個事務同時更新數據庫中的相同對象,我們通常認為后面的寫入會覆蓋掉前面的。但是,如果先前的寫入是尚未提交事務的一部分,會發生什么?后面的寫入會覆蓋一個尚未提交的值,這叫做臟寫。一般解決方法是延遲第二次寫入,直到第一次寫入事務提交或者中止為止。
防止臟寫解決的問題:
如下圖7-5所示,Alice和Bob同時在網站購買同一個商品(id:1234),操作需要寫入數據庫兩次:網站的商品列表更新顯示購買人,銷售發票需要發送給買家。 但是由於臟寫,最終網站顯示Bob購買了商品(他臟寫了Alice的購買),但是Alice收到了銷售發票(她臟寫了Bob的發票)。
另外,我們期望防止臟寫也可以解決計數器加1的並發問題,但是仔細想想,並沒有。第二個事務是在第一個事務提交后才進行覆蓋的。后面會談論這點。
實現Read Committed
Oracle 11g, PostgreSQL,SQLServer 2012,MemSQL和其他許多數據的默認設置都是Read committed.
使用行級鎖(row-level lock)防止贓寫。 一個事務需要修改數據前,必須獲取該鎖,並在事務期內一直持有。所以,另一個事務想要修改數據獲取鎖,必須等到這個事務結束(提交或者中止)。這種鎖定是數據庫自動完成的。
如何實現防止贓讀? 一個辦法是使用相同的鎖,並要求讀取后,立即釋放鎖。但是要求讀鎖對性能和響應時間是個挑戰,因為一個長時間的寫入事務可能會阻塞多個包含讀取的事務,迫使它們等待很久,引起連鎖反應。因此,大多數數據庫會保留這個已經提交的舊值和當前持有寫入鎖的事務設置的新值。當事務進行時,任何其他讀取對象的事務都會拿到舊值。事務提交之后,則后續的讀取就會切換拿到新值。
快照隔離和可重復讀(Snapshot Isolation and Repeatable read)
如果你認為Read Committed已經足夠好去解決很多問題,那我們不妨看看下面的例子:
- 備份: 有時候在線備份整個數據庫需要花費數小時的時間,並且整個過程數據庫仍然要處理寫入操作的。不難想象,備份可能會包含舊的數據和新的數據(備份過程中產生的數據),這樣的備份是不一致的,沒有用的。
- 分析查詢:對於數據倉庫而言,耗時的統計分析查詢很多,如果在這期間,數據發生了變化或正在發生變化,查詢將在不同時間看到數據庫的不同部分,這樣的分析結果應該不是業務需要的。
快照隔離(snapshot isolation)則是解決此類問題的方案,事務會看到事務開始時在數據庫提交的所有數據。即使數據隨后被另一事務更改提交,這個事務也只能看到自己開始那個時間點的舊數據。
實現快照隔離
快照隔離使用和Read committed一樣的鎖定。讀取不需要鎖定。所以,快照隔離遵循“讀不阻塞寫,寫不阻塞讀”。當處理一致性快照上的長時間查詢時,可以同時處理寫入操作,不會產生鎖定爭用。
為了實現快照隔離,數據庫一般化了圖1的中避免贓讀的機制,數據庫必須保留一個對象上幾個不同提交的版本,因為多個事務可能在不同的時間點發出查詢,看到不同時間點的數據狀態。因為它並排維護着多個版本的對象,所以稱此技術為多版本並發控制MVCC(Multi-version Concurrency Control)。
實現Read committed隔離,只需要保留兩個版本即可(已經提交的版本和當前尚未提交的版本)。對於實現了MVCC的數據庫,也使用其來實現Read committed隔離。讀已提交隔離為每個查詢使用單獨的快照而快照隔離為每個事務使用相同的快照。我們通過下圖7-7說明一下大體實現:每一個事務都被分配一個自增的事務id(txid),灰色表中的每一項都有created by表示創建者,最初其值為插入數據的事務id.數據刪除使用deleted by標記(為了維護版本,只有在確定刪除的數據未被當前系統的事務使用時再清理),UPDATE操作在內部被翻譯為DELETE和INSERT(為了保留版本),如中間的那個灰色表格展示。
觀察一致性快照的可見性規則
當一個事務從數據庫讀取時,事務ID用於決定它可以看見哪些對象。通過仔細定義可見性規則,數據庫可以向應用呈現一致性快照。規則如下:
- 事務開始時,數據庫列出正在進行的所有其他的事務列表,即使之后提交了,這些事務的寫入也都會被忽略。
- 被中止的事務所執行的任何寫入都將被忽略。
- 由較晚事務ID的所謂所做的任何寫入都被忽略,而不管這些事務是否已經提交。
- 所有其他寫入,對應用都是可見的。
圖7-7中,事務12從賬戶2中讀取數據,根據規則3,讀取的應該是500. 對於事務12來講,事務13所做的任何修改(刪除和創建,其實是UPDATE)都被忽略。
如果以下兩個條件成立,則對象可見。
- 讀事務開始時,創建對象的事務已經提交。
- 對象未被標記刪除,或者如果標記為刪除,請求刪除的事務在讀事務開始時尚未提交。
由於每次只是創建一個新的版本,只會產生很小的額外開銷。
索引和快照隔離
數據庫中有多版本對象,那么索引該如何工作?一種方式是簡單低使索引指向所有版本,需要索引查詢來過濾掉不可見的版本。為了增加性能,可以將同一個對象的多版本放在同一頁面上。 另一種辦法是使用僅追加/寫時拷貝,不會修改頁面,只會增加新的B樹,這些B樹會形成一個一致性快照。
防止更新丟失
我們討論下並發寫入的第二個問題丟失更新(lost update)(第一個問題我們之前已經討論過:臟寫)。丟失更新(lost update)是我們在增量計數器中引入的問題,符合(讀取-修改-寫入)模式的並發寫入都有類似問題。其中一個的更新可能會丟失,因為第二個事務或者線程沒有在第一個事務的修改基礎上寫入。有幾種解決辦法。
- 增加計數器或者賬戶余額(讀取當前值,修改,寫回)
- 復雜值得本地修改:將元素添加到JSON中的一個列表,要求解析,添加,寫回。
- 兩個用戶同時編輯WIKI頁面,然后同時將整個頁面發送至服務器保存。
原子寫
有很多數據庫已經實現了簡單的原子寫操作,比如以下數據庫操作是原子的。
UPDATE counters SET value = value + 1 WHERE key = 'foo';
原子操作通常通過在讀取對象時,獲取其上的排它鎖來實現。保證更新完成之前沒有其他事務可以讀取它。這種技術也叫作游標穩定性(Cursor stability)。另一個選擇是簡單地強制所有原子操作在單一線程上執行。
值得一提的是,ORM框架會執行不安全的“讀取-修改-寫入”操作,而不是數據庫的原子操作。 所以小心。
顯示鎖定防止丟失更新
讓應用程序顯式鎖定(for update)行以防止丟失更新.
BEGIN TRANSACTION; SELECT * FROM figures WHERE name = 'robot' AND game_id = 222 FOR UPDATE; --顯式鎖定 UPDATE figures SET position = 'c4' WHERE id = 1234; COMMIT;
自動檢測丟失的更新
數據庫事務管理器可以結合快照隔離高效地檢測是否存在丟失更新,如果是,則中止事務並強制他們重試。PostgreSQL的可重復讀,Oracle的可串行化,SQLServer的快照隔離級別(其實他們都是快照隔離級別的不同名字,沒有嚴格的標准命名多混亂呀),都會自動檢測到丟失更新,並中止事務。MySQL/InnoDB貌似不支持。
不得不說,這個辦法好,干凈。
比較並設置(CAS)
有些數據庫提供一種原子操作:CAS(比較設置),目的是為了防止丟失更新:只有當前值從上一次讀取以來沒有變化,才允許更新。否則,更新失敗,並重試。
比如,為防止兩個用戶同時更新一個WIKI頁面,可以使用這種方法。
寫入偏差與幻讀
前面,我們討論了臟寫和丟失更新,在並發寫入相同對象的時候,會出現兩種問題。我們可以用過數據庫自動解決,也可以通過鎖和原子寫操作手動防止。
但是,由於並發寫入帶來的問題還沒完,我們在本節中會看到一些更有趣的問題。首先,我們想象一下醫院的值班系統,業務要求任何時刻必須有至少一名醫生值班(on call),醫生自己也可以通過系統申請休假(前提是滿足至少一名醫生值班的條件即可)。如下圖7-8所示,目前有兩位醫生Alice和Bob都是on call狀態,他們同時向系統請假。 開始他們查詢當前值班的醫生數量,結果是2(在快照隔離級別下),基於這個查詢結果,他們都做出了請假操作並且事務提交成功。但是現在我們發現已經沒有醫生值班了,違反了業務要求!
寫偏差的特性
這種問題稱為寫偏差,它既不是臟寫,也不是丟失更新,因為寫入的是不同的對象。雖然這里的寫入沖突不明顯,但是顯然是一個競態條件:如果兩個事務一個接一個地運行,第二個醫生就不能休假了。異常行為只有在並發進行時才有可能出現。
我們雖然有很多方法防止丟失更新。隨着寫入偏差,選擇更受限:
- 涉及多個對象,單對象原子操作不工作。
- 快照隔離無法檢測,而需要真正的可串行化隔離。
- 使用觸發器等實現這種約束。
- 如果無法使用可串行化的隔離級別,則可以顯示鎖定事務依賴的行。在例子中,可以寫下如下代碼。FOR UPDATE告訴數據庫鎖定返回的所有行用於更新。
BEGIN TRANSACTION; SELECT * FROM doctors WHERE on_call = TRUE AND shift_id = 1234 FOR UPDATE;
UPDATE doctors SET on_call = FALSE WHERE name = 'Alice' AND shift_id = 1234; COMMIT;
寫偏差的更多例子
會議室預訂系統
我們不允許同一會議室在同一時間內重復預訂。當有人預訂時,首先檢查是否存在互相沖突的預訂(即預定時間范圍重疊的同一房間),如果沒找到,則創建預訂。
BEGIN TRANSACTION; -- 檢查所有現存的與12:00~13:00重疊的預定 SELECT COUNT(*) FROM bookings WHERE room_id = 123 AND end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00'; -- 如果之前的查詢返回0 INSERT INTO bookings(room_id, start_time, end_time, user_id) VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666); COMMIT;
快照隔離並不能防止另一個用戶同時插入沖突的會議,看來是得需可串行化級別了。
搶注用戶名
同樣的道理,兩個用戶同時注冊同樣的用戶名(若沒有,則創建)。在快照隔離下和前面的例子一樣不安全。但是可以通過唯一性約束來解決。
導致寫入偏差的幻讀
這些列子都遵循類似的模式
- 一個SELECT查詢出符合條件的行,並檢查是否符合一些要求。(例如,至少有兩名醫生值班;不存在對會議室同一時間段的預訂;用戶名還沒有被搶注。。。)
- 按照第一個查詢結果,應用代碼決定是否繼續。(可能會繼續操作,也可能中止並報錯)
- 如果應用決定繼續操作,就執行某種寫入(增刪改),並提交事務。
這個寫入的結果改變了步驟2中的先決條件。換句話說,提交寫入后,重復執行一次步驟1的SELECT查詢,將會得到不同的結果。因為寫入改變了符合搜索條件的行集(少了一個醫生值班,會議室被預訂了,用戶名已經被搶注。。。)
這些步驟可能以不同的順序發生。例如可以首先進行寫入,然后進行SELECT查詢,最后根據查詢結果決定是放棄還是提交(因為這些操作都在同一個事務中)。
在醫生值班例子中,步驟3修改的行,是步驟1返回的結果的一部分,所以我們可以通過鎖定步驟1中的行(SELECT FOR UPDATE )來使事務安全並避免寫入偏差。但其他例子不同,他們檢查是否不存在某些滿足滿足條件的行,寫入會添加一個匹配相同條件的行。如果步驟1中的查詢沒有返回任何行,則SELECT FOR UPDATE無行可鎖。
這種現象:一個事務中的寫入改變另一個事務的搜索查詢的結果,被稱為幻讀。快照隔離避免了只讀查詢中的幻讀,但在我們這里討論的讀寫事務中,幻讀會導致棘手的寫傾斜情況。
物化沖突(Materializing conflicts)
對於有些幻讀問題,我們沒有對象加鎖,但我們可以人為地引入鎖對象。
比如,在會議室預訂的場景中,我們可以創建一個關於時間槽和房間的表。此表中每一行對應於特定時間段(例如15分鍾)的特定房間,可以提前插入房間和時間的所有可能組合(例如接下來的半年時間)。
現在,要創建預訂的事務可以鎖定(SELECT FOR UPDATE)表中與所需房間和時間段對應的行,之后,檢查重疊的預訂是否存在,若不存在,則插入新的預訂。這個表的目的只是為鎖定提供實體對象,從而防止對同一時間段的同一會議室信息的並發訪問。
這種方法被稱為物化沖突。不幸的是,弄清楚如何物化沖突可能很難,而讓並發控制機制暴露到應用數據模型中是很丑陋的做法。所以,這種方法不推薦。大多數情況下,可串行化的隔離級別是可取的。
可串行化(Serializability)
最強的隔離級別,事務可以並行執行,但是結果就跟沒有任何並發性,連續一個接一個執行一樣。所以,數據庫可以防止所有的並發問題。我們分析三種實現技術。
真正串行執行
即在單線程中按照順序一個接一個地執行事務。有兩個原因支持
- RAM足夠便宜: 活躍的數據集可以全部放入內存,避免對磁盤的訪問。
- OLTP操作都足夠短。
串行執行事務的方法在VoltDB/H-Store,Redis和Datomic中實現。
在存儲過程中封裝事務
具有單線程串行事務處理的系統不允許交互式的多語句事務(因為會嚴重影響系統的吞吐量,因為數據庫大部分時間都在等待當前事務的下一條語句)。取而代之,應用程序必須提前將整個事務代碼作為存儲過程提交給數據庫。這些方法之間的差異如圖7-9 所示。如果事務所需的所有數據都在內存中,則存儲過程可以非常快地執行,而不用等待任何網絡或磁盤I/O。如下圖7-8所示:
分區
如果能找到將數據集分區的辦法,使每個事務的讀寫只會限定在一個分區之上,那么每個分區就可以擁有獨立運行的事務處理線程,運行在獨立的CPU核心。但對於跨多個分區的事務(比如二級索引),則需要多個分區之間的協調鎖定,會帶來很大的開銷。
串行執行小結
- 事務小而快
- 數據集在內存中。如果不在內存,可以先中止事務,然后異步地將數據加載到內存,同時處理其他事務,等數據加載完畢后,重啟執行事務。
- 寫入吞吐量必須低到能在單核CPU核上處理,否則,事務要能划分到分區,且不要跨分區協調。
- 跨分區事務是可能的,但是使用程度有很大限制。
2PL(Two-phase Locking)
大約30年來,在數據庫中只有一種廣泛使用的串行化算法:兩階段鎖定(2PL,two-phase locking)。其對鎖的要求更強。寫會阻塞讀,讀也會阻塞寫(而快照隔離是寫和讀都不會阻塞對方)。 其實現使用了兩種對象鎖模式,共享模式(shared mode)和排它模式(exclusive mode)。工作如下:
- 若事務要讀取對象,則須先以共享模式獲取鎖。允許多個事務同時持有共享鎖(允許多讀)。但如果另一個事務已經在對象上持有排它鎖,則這些事務必須等待。
- 若事務要寫入一個對象,它必須首先以獨占模式獲取該鎖。沒有其他事務可以同時持有鎖(無論是共享模式還是獨占模式),所以如果對象上存在任何鎖,該事務必須等待。(寫阻塞任何操作)
- 如果事務先讀取再寫入對象,則它可能會將其共享鎖升級為獨占鎖。升級鎖的工作與直接獲得排它鎖相同。
- 事務獲得鎖之后,必須繼續持有鎖直到事務結束(提交或中止)。這就是“兩階段”這個名字的來源:第一階段(當事務正在執行時)獲取鎖,第二階段(在事務結束時)釋放所有鎖
由於使用了很多鎖,2PL比其他隔離級別很容易發生死鎖問題。數據庫會自動檢測到死鎖,並中止其中一個事務,另一個才能繼續執行。
2PL的性能很差。一方面是由於鎖的獲取和釋放的開銷,但更重要的是,鎖等待使並發性極大地降低。
謂詞鎖
謂詞鎖不屬於特定對象上的鎖,它屬於所有符合特定查詢條件的對象。如:
SELECT * FROM bookings WHERE room_id = 123 AND end_time > '2018-01-01 12:00' AND start_time < '2018-01-01 13:00';
謂詞鎖限制訪問,如下所示:
- 如果事務A想要讀取匹配某些條件的對象,就像在這個 SELECT 查詢中那樣,它必須獲取查詢條件上的共享謂詞鎖(shared-mode predicate lock)。如果另一個事務B持有任何滿足這一查詢條件對象的排它鎖,那么A必須等到B釋放它的鎖之后才允許進行查詢。(當B得到或者升級為排它鎖並插入一條預訂的時候,A是不能查詢當前會議室同一時間段的預訂情況的)。
- 如果事務A想要插入,更新或刪除任何對象,則必須首先檢查舊值或新值是否與任何現有的謂詞鎖匹配。如果事務B持有匹配的謂詞鎖,那么A必須等到B已經提交或中止后才能繼續。(如果A想要更新會議室預訂,但是此時B已經獲取了謂詞鎖,A則無法升級謂詞鎖為排它鎖,而是必須等待B的提交或者中止。如果A,B同時更新,就會發生死鎖)
這里的關鍵思想是,謂詞鎖甚至適用於數據庫中尚不存在,但將來可能會添加的對象(幻象)。如果兩階段鎖定包含謂詞鎖,則數據庫將阻止所有形式的寫入偏差和其他競爭條件,因此其隔離實現了可串行化。
索引范圍鎖
不幸的是謂詞鎖性能不佳:如果活躍事務持有很多鎖,檢查匹配的鎖會非常耗時。因此,大多數使用2PL的數據庫實際上實現了索引范圍鎖(也稱為間隙鎖(next-key locking)),這是一個簡化的近似版謂詞鎖。
通過使謂詞匹配到一個更大的集合來簡化謂詞鎖是安全的。例如,如果你有在中午和下午1點之間預訂123號房間的謂詞鎖,則鎖定123號房間的所有時間段,或者鎖定12:00~13:00時間段的所有房間(不只是123號房間)是一個安全的近似,因為任何滿足原始謂詞的寫入也一定會滿足這種更松散的近似。
搜索條件的近似值都附加到其中一個索引上。現在,如果另一個事務想要插入,更新或刪除同一個房間和/或重疊時間段的預訂,則它將不得不更新索引的相同部分。在這樣做的過程中,它會遇到共享鎖,它將被迫等到鎖被釋放。
串行化快照隔離(SSI:serializable snapshot isolation)
2PL和真正的串行化是一種對並發的悲觀控制技術,他們假設任何並發都可能帶來問題。而SSI采用樂觀的並發控制技術:在這種情況下,樂觀意味着,如果存在潛在的危險也不阻止事務,而是繼續執行事務,希望一切都會好起來。當一個事務想要提交時,數據庫檢查是否有什么不好的事情發生(即隔離是否被違反);如果是的話,事務將被中止,並且必須重試。只有可序列化的事務才被允許提交。其實現方式是通過檢測過時的前提條件,即當一個事務中的寫入提交時,數據庫會檢測是否存在一個之前的讀取,並認為他們有因果關系。如果這個讀取的結果被另一個事務修改過,則中止此事務。以下是兩種檢測情形:
- 檢測對舊MVCC對象版本的讀取(讀之前存在未提交的寫入)
- 檢測影響先前讀取的寫入(讀之后發生寫入)
其也有明顯的優缺點,在並發很高的情況下,被中止的事務會很多。而並發爭用很少的情況下則表現很好。中止率顯著影響SSI的整體表現。例如,長時間讀取和寫入數據的事務很可能會發生沖突並中止,因此SSI要求同時讀寫的事務盡量短。