事務是應用程式將多個讀寫操作組合成一個邏輯單元的一種形式,這樣其中所有的讀寫操作都被視為單個操作來執行,要麼成功提交,要麼失敗回滾,不存在任何部分成功和部分失敗的情況。現在,幾乎所有的關係型資料庫和一些非關係型資料庫都支持事務。 ...
1. 什麼是事務?
應用在運行時可能會發生資料庫、硬體的故障,應用與資料庫的網路連接斷開或多個客戶端端併發修改數據導致預期之外的數據覆蓋問題,為了提高應用的可靠性和數據的一致性,事務應運而生。
從概念上講,事務是應用程式將多個讀寫操作組合成一個邏輯單元的一種形式,這樣其中所有的讀寫操作都被視為單個操作來執行,要麼成功提交,要麼失敗回滾,不存在任何部分成功和部分失敗的情況。現在,幾乎所有的關係型資料庫和一些非關係型資料庫都支持事務。
1.1 ACID
事務通過ACID來保證安全的操作,它們分別是原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)和持久性(Durability)。ACID的提出旨在為資料庫容錯機制建立精確的術語,但是它在不同的資料庫中實現並不相同,我們來對其逐一的進行解釋。
- 原子性
原子性定義的特征是:一個事務必須被視為一個不可分割的工作單元,整個事務中的所有操作要麼全部提交成功,要麼全部失敗回滾,不可能只執行其中的一部分操作。
- 一致性
一致性在ACID中是“多餘的”存在,它不同於原子性、隔離性和持久性,一致性是應用程式的屬性,而其他三者是資料庫的屬性。應用可能依賴原子性和隔離性來保證一致性,但有時候一致性的保證並不僅僅取決於資料庫。
一致性的體現依賴應用程式對數據的約束,比如在會計系統中,所有賬戶的交易收支一定是平衡的。如果一個事務開始於一個平衡狀態,那麼在該事務執行完成提交後,那麼依然會保持平衡。從概念上來說,一致性是對數據的一組特定約束必須始終成立,這一點由應用程式來保證,因為這些寫入和修改的邏輯都是由應用程式決定的,資料庫只負責對這些操作進行執行。
- 隔離性
在實際工作中,大多數資料庫都同時被多個客戶端訪問,這就可能會發生客戶端併發修改同一條數據的情況,引發併發問題。如下圖中例子所示:
User1和User2要同時在資料庫中操作計數器增長,每個用戶都是先讀取值,執行加1,然後寫入。理論上計數器的值最終應該為44,但是由於併發問題,使得終值為43。
通常來說,隔離性讓一個事務所做的修改在最終提交以前,對其他事務不可見,它解決的是併發問題。如果一個事務進行多次寫入,則另一個事務要麼看到全部寫入結果,要麼什麼都看不到。所以在上述例子中,User2在修改計數器時讀取到的值應該是User1修改完之後的結果43,之後執行加1,使得最終結果為44。
- 持久性
持久性是一個承諾,即事務成功提交,即使發生硬體故障或者資料庫崩潰,寫入的任何數據都不會丟失,在單節點資料庫中,它通常意味著數據已經被寫入硬碟或SSD;在多節點資料庫中,持久性可能意味著數據已經成功複製到一些節點。但是,如果硬碟和備份被銷毀,那麼顯然沒有任何資料庫能再找回這些數據,所以完美的持久性並不存在。
2. 併發產生數據不一致的問題
往往由於事務之間的操作對象有競爭關係,並且又因為併發事務之間不確定的時序關係,會導致這些所操作的有競爭關係的對象會出現各種奇怪的結果,下麵我們就來看看這些常見的問題。
2.1 臟寫
兩個事務嘗試同時更新資料庫中相同的對象,如果先前的寫入是尚未提交事務的一部分,後面的寫入將一個尚未提交的值覆蓋掉了,這種情況被稱為臟寫。
2.2 臟讀
如果A事務已經將一些數據寫入資料庫,但是A事務還沒有提交或中止,現在開啟另一個B事務查詢,那麼B事務能看到A事務中沒有提交的數據,這就是臟讀。
我們考慮一種情況,一旦A事務發生回滾,B事務很有可能將未提交過的數據提交給資料庫,因此造成的問題會讓人無從下手去排查。
2.3 不可重覆讀
我們拿一個例子來說,Alice 在銀行有 1000 美元的儲蓄,分為兩個賬戶,每個 500 美元。現在有一個事務從她的一個賬戶轉移了 100 美元到另一個賬戶。如果她在事務處理的過程中查看其賬戶餘額,她可能在發出轉賬之後看到付款賬戶的餘額為 400 美元,而收款賬戶的餘額仍為 500 美元。對 Alice 來說,現在她的賬戶看起來總共只有 900 美元,轉賬的 100 美元似乎憑空消失了,而再對收款賬戶進行讀取時,發現餘額變成了 600 美元。
這種情況被稱為不可重覆讀,又被稱為讀偏差,即對同一數據兩次讀取的結果不一致。
2.4 丟失更新
兩個事務同時執行讀取-修改-寫入序列,其中一個寫操作在沒有合併另一個寫操作變更的情況下,直接覆蓋了另一個寫操作的結果,導致了數據的丟失,這種情況被稱為丟失更新。
比較直接的避免丟失更新的方法是不使用讀取-修改-寫入這一系列操作,而是進行原子更新,以計數器為例,SQL如下。它的原理通常是獲取要讀取對象的排他鎖,使得事務在修改同一數據時依次執行。
update counters set value = value + 1 where key = 1;
如果不能避免讀取-修改-寫入這一系列操作,那麼可以通過顯式加鎖(FOR UPDATE)的方式來避免丟失更新,使得任何其他想要讀取同一對象的事務被阻塞,直到第一個獲取到該鎖的事務執行完畢。
BEGIN TRANSACTION;
SELECT * FROM xxx FOR UPDATE;
-- 執行業務邏輯
UPDATE xxx SET ...;
COMMIT;
比較並設置(CAS)是一種比較常見的樂觀的避免丟失更新的操作。當對數據更新時,會將數據表中的值和讀取值進行對比,只有在沒有發生改變的情況下才允許更新,否則需要重試這個事務。一般在工作中會採用在數據表中添加時間戳列的方式來實現CAS。
2.5 幻讀和寫入偏差問題
幻讀用一句話來概括就是:一個事務的寫入改變了另一個事務的搜索查詢結果。
A事務的select
查詢出符合條件的數據,並檢查是否符合業務要求,根據檢查結果決定業務是否繼續執行。如果此時B事務對數據進行修改,並符合A事務select
的查詢條件,那麼A事務在執行完寫入操作後,再次執行select
查詢會發現不同的結果,這很可能會導致寫入偏差問題。
寫入偏差問題是兩個事務讀取相同的對象,然後更新其中一些對象時發生了預期之外的異常情況。它區別於臟寫和丟失更新,因為它是兩個事務正在更新兩個不同的對象。如下麵這個例子所示,Alice 和 Bob 是兩位值班醫生,兩人都感到不適,所以他們都決定請假。不幸的是,他們恰好在同一時間點擊按鈕下班:
這導致了沒有醫生值班,違反了至少有一名醫生值班的業務要求。
解決寫入偏差問題比較麻煩,因為它涉及多個對象,採用單對象原子操作的方法不能解決。通常情況下會採用更改隔離級別為可串列化或通過加鎖的方式來解決。
但是,加鎖的方式並不是在所有情況下都適用。比如,多人預定同一時段的會議室,因為該時段的會議室預定記錄還沒有生成,導致多人讀取預定紀錄時都沒有讀到對應的結果值,所以就無從加鎖,那麼此時將會造成多人預定同一時段會議室的結果。為瞭解決這種情況,可以再創建一張數據表管理會議室的時間段,當有人想預定某時段會議室時,會將該時段的數據進行加鎖,那麼這時再有其他用戶來查詢時,將會被阻塞,這種方法被稱為物化衝突。
3. 隔離級別
資料庫一直試圖通過事務隔離解決併發問題。可串列化隔離級別能保證事務串列執行,這意味著不會發生併發問題。但是在實際生產中為了保證系統的性能,往往不會採用該隔離級別,而是會採用一些較弱的隔離級別,它們可能在某些情況下不能保證數據的一致性,但是能夠讓系統的性能更好。下麵我們對這些隔離級別進行介紹:
3.1 讀未提交
該隔離級別相對更弱,只能避免臟寫。
3.2 讀已提交(Read Committed)
這種隔離級別非常流行,它能夠避免臟讀和臟寫。
最常見的情況是使用行鎖來防止臟寫:當事務想要修改同一個對象時,則必須等到第一個事務提交或回滾後才能獲取該行的鎖繼續。
臟讀也可以通過加讀鎖來避免,但是這種方式會導致在有長時間的寫入事務持有要讀數據的鎖時,讀請求被阻塞,所以這種方式在實踐中的效果並不好。另一種避免方式是資料庫將已經寫入的舊值記住,即使發生新的寫入事務且並沒有執行完時,讀請求讀取到的都是這個舊值,只有當該寫事務提交時才能讀取到新值。
3.3 可重覆讀
可重覆讀能夠避免臟寫、臟讀、不可重覆讀和只讀查詢中的幻讀,快照隔離是實現可重覆讀的常見解決方案。每個事務都從資料庫的一致性快照中進行讀取,那麼這也就意味著該事務能看到事務開始時在資料庫中提交的所有數據。即使這些數據隨後被新的事務更改,該事務還仍然讀取的是在事務開始時的舊數據。這種辦法對長時間運行的只讀查詢非常有用,因為如果在查詢過程中數據不斷的變化,那麼沒有辦法對數據進行分析。
不提供快照隔離的讀已提交不能實現可重覆讀,因為它只記住了數據的兩個版本。
快照隔離也是通過寫鎖的方式來避免臟寫,而避免臟讀的方式無需加鎖,而是通過讀取資料庫中維護的對應版本的數據對象,它的關鍵原則是讀不阻塞寫,寫不阻塞讀。這也就意味著資料庫在處理一致性快照上的長時間查詢時,能夠同時處理寫入,而不會發生鎖的爭用。
使用InnoDB引擎的MySQL對快照隔離的實現方法是MVCC多版本併發控制,它會同時維護單個對象的多個版本,以提供多個不同時間節點的數據狀態,我們下麵來簡單地看一下它的實現原理。
對於InnoDB引擎的表來說,它的聚簇索引記錄中都包含兩個必要的隱藏列:trx_id
和roll_pointer
- trx_id: 事務每次對某條聚簇索引記錄進行改動時,都會把該事務的事務ID賦值給 trx_id
- roll_pointer: 每次對某條聚簇索引記錄進行改動時,都會把舊的版本寫入到 undo 日誌中, 這個隱藏列相當於一個指針,可以通過它來找到該記錄修改前的信息。我們舉個例子來理解它,假設表 hero 中只包含一條記錄:
mysql> select * from hero;
+-----+-----+
|id |name |
+-----+-----+
|1 |劉備 |
+-----+-----+
指定插入該記錄的事務ID為80,此時再開啟兩個事務對這條記錄進行修改,每次修改都會生成一條 undo log,每條日誌也都有 trx_id 屬性和 roll_pointer 屬性。通過 roll_pointer 屬性可以將多條 undo log 連接成一條鏈表,如下圖所示:
這個鏈表被稱為版本鏈,版本鏈的頭節點是當前最新的記錄,利用這個記錄的版本鏈可以來控制併發事務訪問相同記錄時的行為,這種方式被稱為多版本併發控制。
事務在執行第一次查詢的時候會生成一致性快照(Read View),通過它來判斷版本鏈中的哪個版本對當前事務是可見的。Read View 中包含4個比較重要的內容如下:
- m_ids: 生成 Read View 時,當前系統中活躍的讀寫事務的 id 列表,它用來保證即使活躍的這些事務被提交,它們的寫入也會被當前事務忽略
- min_trx_id: 當前系統中活躍的讀寫事務的最小事務 id
- max_trx_id: 系統應該分配給下一個事務的事務 id
- creator_id: 生成該 Read View 的事務的事務 id
有了 Read View,只需要按照下麵的步驟去判斷記錄中的某個版本是否可見:
- 如果被訪問版本的 trx_id 屬性值與 Read View 中的 creator 中的 creator_trx_id 值相同,則意味著當前事務在訪問自己修改過的內容,該版本能夠被當前事務訪問
- 如果被訪問的版本的 trx_id 屬性值小於 Read View 中的 min_trx_id 值,表明生成該版本的事務在當前事務生成 Read View 前已經提交,這些版本能夠被當前事務訪問
- 如果被訪問的版本的 trx_id 屬性大於或等於 Read View 中的 max_trx_id 值,表明生成該版本的事務在當前事務生成 Read View 之後才開啟,那麼該版本不能被當前事務訪問
- 如果被訪問的版本的 trx_id 屬性值在 Read View 的 min_trx_id 和 max_trx_id 之間,則需要判斷 trx_id 是否在 m_ids 列表中。如果在,說明創建該 Read View 時生成該版本的事務還是活躍的,所以該版本不可見;如果不在,說明創建該 Read View 時生成該版本的事務已經被提交,該版本可以被訪問
也就是說,想要滿足記錄對當前讀事務可見,需要創建該記錄的事務在當前讀事務開啟前已經提交。
3.4 可串列化
可串列化通常被認為是最強的隔離級別,能夠避免我們上訴所有數據不一致問題。它能保證即使事務可以並行執行,但最終的結果也是一樣的,就好像它們沒有任何併發性,連續挨個執行一樣。也就是說,資料庫可以防止所有可能的競爭條件。
可串列化的實現技術大多採用如下3種方式之一:串列化執行事務,兩階段鎖定或可串列化快照隔離。
串列化執行事務
使用這種技術實現必須要求每個事務小而快,如果其中有一個緩慢的事務,那麼自然會將其他事務拖慢。除此之外,這種方式限於活躍數據集可以放入記憶體的情況,如果需要在事務中訪問磁碟中的數據,那麼系統也會變得非常慢。寫入吞吐量必須低到在單個CPU核上處理,如若不然,事務需要能劃分至單個分區,且不需要跨分區協調。當然跨分區事務可以實現,但是它的執行效率會非常低。所以,串列化執行事務的伸縮性較差。
兩階段鎖定(2PL, two pahse locking)
兩階段提交的含義是:第一階段事務執行時獲取鎖(共用鎖/排他鎖),第二階段在事務執行完成時釋放鎖。它要求沒有寫入時多個事務都可以讀取同一個對象,但是只要有寫入就會獨占訪問,讀阻塞寫,寫也會阻塞讀,與快照隔離不同,因此兩階段鎖定可以避免競爭條件而實現可串列化。
Mysql的InnoDB引擎實現可串列化隔離級別採用的就是2PL機制。
兩階段鎖定的性能很差,不僅是因為它獲取和釋放鎖的開銷,而且還包括併發性的降低,因為如果兩個事務修改同一個對象時,第二個事務必須要等待第一個事務執行完為止。除此之外,2PL實現的可串列化隔離出現死鎖的情況也比較頻繁。
可串列化快照隔離(SSI, serializable snapshot isolation)
可串列化快照隔離是一種樂觀的併發控制技術,它在快照隔離的基礎上,添加了一種演算法來檢測寫入之間的串列化衝突,並確定要終止哪些事務。
樂觀意味著如果存在潛在的危險也不阻止事務,而是繼續執行事務,希望一切都會好起來。當一個事務想要提交時,資料庫檢查是否有什麼不好的事情發生(即隔離是否被違反),如果是的話,事務將被中止,並且必須重試。在爭用不是很高時,樂觀的併發控制往往比悲觀的併發控制性能要好。
事務從資料庫中讀取一些數據,並根據這些數據進行條件判斷執行業務邏輯時,在快照隔離的條件下,往往先前的查詢結果不是最新的,因為在數據查詢之後,該數據可能會被修改,所以執行的業務邏輯可能會出現異常。因此在事務提交時判斷先前讀的數據是否發生改變就需要兩方面的校驗:
- 檢查是否存在讀之前未提交的寫入
- 檢查讀之後的寫入
只有通過這些校驗後才能保證事務提交時使用的數據是新的。
可串列化快照隔離與串列執行相比,可串列化快照隔離並不局限於單個 CPU 核的吞吐量,所以它的伸縮性更好;可串列化快照隔離與兩階段鎖定相比,它的最大優點是一個事務不需要阻塞等待另一個事務所持有的鎖,就像在快照隔離下一樣,讀不阻塞寫,寫也不阻塞讀,這對於讀取較多的業務場景非常友好。
可串列化快照隔離的性能表現在中止率上,如果長時間的讀寫事務較多,很可能會經常發生衝突導致事務中止。因此在事務比較短小的情況下,可串列化快照隔離的表現更好。
4. 及時性與完整性
ACID事務通常能保證強一致性,也就是說,寫入者會等到事務提交,而且在寫入完成後,寫入結果對所有讀取者可見。在強一致性這個語義中,包含兩個特別值得考慮的方面:
- 及時性:這意味著確保用戶觀察到系統的最新狀態。如果不是強一致性而是最終一致性的情況,那麼用戶可能會讀取到陳舊的數據,但這種不一致是暫時的,最終都會通過等待與簡單地重試得到解決
- 完整性:完整性代表數據沒有丟失、矛盾或錯誤,即沒有損壞。尤其是某些衍生數據集(緩存、搜索索引等),它們一定要與底層資料庫保持一致。在ACID事務中,原子性和持久性是保證完整性的重要原則
有意思的是:基於非同步流處理系統實現的分散式事務,它能夠將及時性與完整性分開,只保證完整性,而不保證及時性,除非我們顯示地構建一個在事務提交返回結果之前明確等待特定消息到達的消費者。
下麵我們來看一個基於流處理系統實現分散式事務的例子,來加深對及時性和完整性的理解。
4.1 使用基於日誌的消息隊列保證完整性
我們以轉賬為例,比如有三個分區:一個包含請求ID,一個包含收款人賬戶,另一個包含付款人賬戶。如果在資料庫傳統的方法中,執行此事務需要跨三個分區進行原子提交,這樣就需要協調分散式事務,因此吞吐量很可能會受到影響。但事實上使用基於日誌的消息隊列實現的流處理系統,可以達到等價的數據完整性而不需要原子提交。例子執行過程如下:
- 從賬戶 A 向賬戶 B 轉賬的請求由客戶端提供一個唯一的請求 ID,並按請求 ID 追加寫入相應的消息隊列,並對該消息進行持久化
- 消費者讀取請求日誌。對於每個請求消息,它向輸出流發出兩條消息:付款人的借記指令(A分區),收款人的貸記指令(B分區),發出的消息中會攜帶原始的請求ID
- 後續消費者消費借記和貸記指令,按照ID除重,並將變更應用到賬戶的餘額
為了在多分區間保證數據完整性而且還要避免對分散式事務的協調(2PC等協議),我們首先需要將這個事務所要做的事情持久化為單條記錄,然後從這條消息記錄中衍生出貸記指令和借記指令。在幾乎所有的數據系統中,單對象的寫入都是原子性的:即請求要麼出現在日誌中,要麼都不出現。
如果流處理在步驟2崩潰,則它會從上一個存檔點恢復處理,這樣它就不會跳過任何消息,但可能會生成多條重覆的借記/貸記指令,不過由於它是確定性的,因此它生成的只是相同的指令,在步驟3中的處理器可以通過ID值輕鬆地去重。
在上述例子中,我們把一個操作拆分為跨越多個階段的流處理器,消息記錄的消費是非同步的,發送者不會等其消息被消費處理完,而且這個消息與消息的處理結果被解耦,所以我們沒有對及時性進行保證,只是保證了完整性。
一般地,我們在藉助可靠的流處理系統時無需再協調分散式事務或採用其他原子提交協議就能保證完整性,其中所包含的機制如下:
- 將寫入操作的內容表示為單條消息,這樣就保證了寫入的原子性
- 從這一消息中衍生出其他所需要的狀態變更
- 將客戶端生成的請求ID傳遞通過所有的處理層,從而能達到去重和保證冪等性的目的
- 保證消息不可變,並允許衍生數據能被隨時重新處理,這使從錯誤中恢復更加容易
4.2 完整性的重要性
不論是ACID事務還是基於流處理系統的分散式事務,它們都保證數據的完整性。因為違反及時性可能會令人困惑,不過這隻是暫時的,但是如果違反完整性,那麼它的結果可能是災難性的。違反一致性,最終一致性;違反完整性,永無一致性,是最好的概括。
巨人的肩膀
- 《數據密集型應用系統設計》:第七章 事務、第十二章 數據系統的未來
- Replication(下):事務,一致性與共識
- 《MySQL是怎樣運行的》第二十一章
- 《高性能MySQL 第四版》第一章
作者:京東物流 王奕龍
來源:京東雲開發者社區