為什麼我們需要資料庫事務

godruoyi發表於2021-08-05

為什麼我們需要資料庫事務
日常工作中我們可能會遇到如下的問題,在未引入資料庫事務這一特性前,應用程式在處理這些問題時總顯得過於複雜,如:

  • 資料庫在寫入一半資料時崩潰
  • 訂單資料儲存一半後網路連結中斷
  • 多個客戶端可能會同時寫入資料庫
  • 多個客戶端之間的條件競爭可能會擾亂整個應用程式

而事務一直是簡化這些問題的首選機制。他為上層應用程式提供一個可靠性保障:將多個讀寫操作組合成一個邏輯單元來執行,要麼全部成功,要麼全部失敗。應用程式在處理這些問題時將不再關心一半成功一半失敗的情況,也不再拘泥於下層各種不可靠的系統,因為大多數資料庫系統都會從多個維度(事務的ACID)來保證資料的正確性。

計算機發展到今天,我們一直在不可靠的環境中構建可靠的系統。

事務的 ACID

ACID 並不是什麼高大上的術語;而是資料庫系統在實現事務時為保證其正確可靠而必須滿足的幾個約束,不同的約束提供了不同的保障。

Atomicity(原子性)

原子性並不是指把事務當作一個整體來執行,既執行過程中不可中斷、不可切換;而是指事務再遇到出錯時,能終止事務,丟棄該事務的所有變更。事務一旦終止,實現這一特性的資料庫會保證資料恢復到原始狀態,應用程式不在擔心各種可能存在的中間結果,只用專注於處理成功或失敗兩種狀態。

Consistency(一致性)

一致性主要是指資料狀態的一致性,考慮這樣一個例子:從 A 賬戶轉入 100 元到 B 賬戶,最終的一致性狀態是指 A 賬戶的支出和 B 賬戶的收入達到收支平衡。

而在 DDIA 這一書裡,作者認為一致性是應用程式的屬性,不應該由資料庫來實現。拿上面例子來說,賬戶 A 轉出 100 元但由於程式問題賬戶 B 卻收入 200 元,雖然最終狀態是收支不平衡,但這並不影響資料庫會按正確的方式來儲存這些資料。狀態是否一致的判斷,應該交由應用程式去處理,資料庫系統只會正確的儲存你給他的所有資料,而不會關心資料本身(參考 ACID 中的 C 是被扔進去拼湊的單詞)。

Isolation(隔離性)

當多個 client 同時操作同一資料時,就可能會出現併發問題(race conitions)。事務的隔離性要求併發執行的事務之間互不干擾,如果在一個事務中進行多次寫入,則另一個事務要麼看到她全部寫入結果,要麼什麼都看不到。

下面的例子就不滿足事務的隔離性:User1 先後進行了兩次寫入,在她未提交前,User1 讀取到了部分新增的資料。

違反隔離性
圖 1.0 違反隔離性:一個事務讀取另一個事務的未提交的寫入

為什麼不能讀取一個事務未提交的寫入?是因為一個未提交的寫入,其後續可能會被終止,終止後該事務的所有寫入都會被回滾;若一個事務讀取到了另一個事務未提交的變更,而該事務回滾後,程式將得到一個完全不應該存在的值。

Durability(永續性)

事務的永續性是一個承諾,即一旦事務成功提交,即使發生硬體故障或資料庫崩潰,寫入的資料也不會丟失。

這只是一個美好性承諾,我們小心翼翼地祈禱不會出現如硬碟被偷、檔案損壞等故障導致的資料丟失。雖然這有點杞人憂天,不過也說明了並不存在絕對 100% 的保證。

事務的隔離級別

為了簡化應用程式在面對併發時的各種問題,大部分關係型資料庫都提供了不同的隔離級別。隔離級別並不是一個什麼高深的概率,只是資料庫系統在簡化併發問題時的抽象。不同的隔離級別對應不同的保障,保障係數越高,相應的效能就越低。在選擇不同的隔離級別前,我們應該思考該隔離級別提供了什麼樣的保障?相同的程式碼在不同的隔離級別下可能會存在什麼問題?不同的隔離級別可能會帶來什麼問題?而不是一味為了應付面試而瞭解的諸如髒讀、幻讀、不可重複讀、MVCC 等抽象的概念。

Read Committed

正如名字一樣,在 Read Committed(讀已提交)隔離級別下,一個事務只能讀取已提交的資料(對照上面隔離性時的例子)。如下面的例子中,在 User1 未提交前,User2 前兩次讀取的結果都相同,而當 User1 提交後,User2 就能讀取到提交後的資料。

Read Committed
圖 2.0 Read Committed User2 在 User1 提交後才能看到新值

Read Committed 的實現

Read Committed 是 Oracle 11g、PostgreSQL 的預設隔離級別,通常採用加鎖來防止併發寫入(寫寫)。一個事務在嘗試更新(寫入)物件時,必須先獲得該物件的鎖,同一時刻只能有一個事務持有該物件的鎖,未獲得鎖的事務需要一直等待,直到持有鎖的事務提交或終止。此種方式相當於將兩個併發寫請求通過加鎖的方式串聯起來,使得同一時刻最多隻允許一個事務進行寫入,也就不會存在資料競爭等情況。

幾乎所有的資料庫系統都允許多個事務併發讀取(讀讀),併發讀取資料時並不會對資源加鎖,在 Read Committed 和快照隔離級別下,寫操作也不會柱塞讀操作。

若存在讀寫併發時(讀寫),寫操作的事務會記錄所操作資源的兩個版本,一個是原始值,一個是修改後的新值;讀事務在寫事務提交前,都只能讀取到原始值,而看不到新值。只有當寫事務提交後,讀事務才能讀取到他提交後的新值。

如圖 2.1 所示,寫操作把 ID 為 1 的記錄從 18 更新到 28,但未提交;其內部可以簡單的理解為有一個指向上一個版本的「連結」,通過這個「連結」就能獲取上一次已提交的值。讀操作在檢索到這一物件時(where id = 1),由於最新值 28 是 UnCommitted,將通過「連結」獲取上一次已提交的值作為查詢的返回值,既返回 18。

Read Committed
圖 2.1 Read Committed

通過這種方式,Read Committed 就能保障讀取到的資料,一定是已經提交了的。

Read Committed 帶來的問題

Read Committed 相較於其他隔離級別,不但提供了較好的效能,並且能夠滿足絕大多數的應用場景,但這並不代表它就是完美的。如下面的例子,在一個事務中,程式篩選滿足條件的記錄數量,若數量大於 0,再獲取相應的資料集合,並返回記錄條數和資料集本身。

考慮到當事務執行完第一個查詢條件後,另外的事務新增了幾條資料並提交,由於在 Read Committed 隔離級別下,事務能讀取到另一事物已提交的更新,這將導致後面一次查詢出來的資料集條數和第一次查詢的 count 不匹配(不可重複讀問題)。

start transaction;
count = select count(1) from t where foo = bar

if count > 0 {
    return {
        count,
        datas: select x from t where foo = bar
    }
}
commit;

Read Committed 認為這種問題是可以被接受的,也沒打算解決這一問題;因為當你重新執行一遍事務,你可能會得到正確的資料。

Snapshot Isolation(快照隔離)

快照隔離(Snapshot Isolation)相比於 Read Committed 提供了更嚴謹的保障,在 Read Committed 的基礎上,還能解決上述的不可重複讀現象。這也是 MySQL InnoDB 的預設隔離級別,在 MySQL 中快照隔離被稱為可重複讀(repeatable read),其名字在不同的資料庫系統實現中有不同的叫法,我們只需要知道其具體原理即可。

Snapshot Isolation 的實現

快照隔離在處理多事務併發寫入(寫寫)和多事務併發讀取(讀讀)時,採用與 Read Committed 一樣的機制,既允許「讀與讀」併發而「寫與寫」互斥。參考 [Read Committed 的實現](Read Committed 的實現)。

在處理多事務併發讀寫時(讀寫),不同於 Read Committed,快照隔離通常會保留所操作資源的多個版本,並在每個版本中記錄更新資料時的事務 ID(事務 ID 在事務開始時由資料庫系統分配,通常是單調遞增的)。如圖 3.0 所示,記錄了 ID 為 1 的資料更新歷史,其值先後被更新為 0 -> 6 -> 15 -> 18 -> 28,其事務 ID 依次為 1、3、5、7、9;最後一次更新操作暫未提交。

多版本併發控制
圖 3.0 多版本控制

讀操作在讀取資料時,會過濾事務 ID 大於自身的版本。假設有一個讀事務正在讀取 ID 為 1 的這條記錄,其 txid 為 6,由於程式執行較慢,該記錄已經向前提交了兩個版本,既上圖的 txid 為 7、9 的兩次提交;則讀操作在查詢時,只會獲取 txid<=6 並且已提交的版本作為查詢的返回值,所以查詢將返回 {txid:5, value: 15}

通過這個機制,事務在整個生命週期內進行的多次查詢,都將使用同一個版本的資料,即使查詢的物件已經提交了多個版本,查詢時都將使用事務開始時的資料。相當於在事務啟動的時候就生成了一個一致性快照,但這個快照並不是一個資料備份,其並沒有 Copy 資料的開銷,而是在執行時通過 txid 動態計算的不同版本。

Snapshot Isolation 帶來的問題

快照隔離和 Read Committed 都通過「寫與寫互斥」來解決多事務併發寫入的問題,但在某些場景下這種方式並不能保障資料的正確性,其中最主要的就是丟失更新問題(Lost Update)。

如圖 3.1,兩個併發請求開始讀取到的 counter 都是 42,應用程式將值自增後在更新到資料庫,最後儲存的結果卻為 42,User1 的更新被覆蓋了。

Lost Update
圖 3.1 Lost Update

更新丟失準確來說不算是資料庫的問題,也不應該要求資料庫做出這方面的保障,畢竟資料庫在儲存資料時,並不知道資料本身的合法性。通常,更新丟失有以下幾種解決辦法:

  • 原子寫
update t set count = count + 1 where id = 1
  • 排它鎖(FOR UPDATE)

通過在 SQL 語句後面指定 FOR UPDATE 來鎖定查詢條件返回的記錄數,在事務未提交期間,其他查詢&寫入必須等待。

start
select count from t where id = 1 for update

count ++
update t set count = count where id = 1

commmit

Serializable

最後一種隔離級別是可序列化,可序列化隔離通常被認為是最強的隔離級別。他將多個併發執行的事務序列化,一個事務必須等待之前的任務處理完成後才能接著處理。這種隔離級別可以防止所有可能的競爭條件。

  • Serializable 的實現

Serializable 通常採用兩階段鎖(two-phase locking,2PL)的方式來實現。他允許多事務併發讀取,既讀與讀之間互不干涉。但如果要對某一物件進行寫入時,需要等待該物件上的所有讀&寫事務完成後,才能寫入;如果要對寫入的物件進行讀取時,需要等待寫入事務提交或終止後,才能讀取。

  • Serializable 帶來的問題

由於兩階段鎖在遇到寫操作時,都會對資源進行加鎖,並且寫操作還會柱塞讀操作。所以 Serializable 帶來的效能十分低下。並且還可能會發生死鎖和寫放大等現象,畢竟在生產環境中,當其中一個服務讀寫變慢時,他就有可能會拖壞整個應用的吞吐率,並逐漸擴大至整個程式不可用。這也是 Serializable 即使提供了更好的隔離級別卻很少使用的原因。

總結

資料庫事務為我們提供了一個良好的抽象,讓開發人員不在擔心各種不可靠的環境,不在關心各種模凌兩可的狀態。有一點需要知道,事務不是天然存在的,我們不要想當然的以為他能夠處理好所有的問題,而不考慮他在不同場景下可能帶來的影響。

雖然文章標題叫做「為什麼我們需要資料庫事務」,但其實作者大部分篇幅都在寫隔離級別,因為我發現再解釋完為什麼後,還需要接著解釋 Why,那姑且就這樣吧。如果你覺得文章對你有幫助,你也可以訂閱作者的部落格 RSS 或直接訪問作者部落格 二愣的閒談雜魚

參考

本作品採用《CC 協議》,轉載必須註明作者和本文連結
二愣的閒談雜魚

相關文章