如何實現數據庫讀一致性

語言: CN / TW / HK

持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第6天,點擊查看活動詳情

1 導讀

數據的一致性是數據準確的重要指標,那如何實現數據的一致性呢?本文從事務特性和事務級別的角度和大家一起學習如何實現數據的讀寫一致性。

2 一致性

1. 數據的一致性:通常指關聯數據之間的邏輯關係是否正確和完整。

舉個例子:某系統實現讀寫分離,讀數據庫是寫數據庫的備份庫,小李在系統中之前錄入的學歷信息是高中,經過小李努力學習,成功獲得了本科學位。小李及時把信息變成成了本科,可是由於今天系統備份時間較長,小李變更信息時,數據已經開始備份。公司的 HR 通過系統查詢小李信息時,發現還是本科,小李的申請被駁回。這就是數據不一致問題。

2. 數據庫的一致性:是指數據庫從一個一致性狀態變到另一個一致性狀態。這是事務的一致性的定義。

舉個例子:倉庫中商品 A 有 100 件,門店中商品 A 有 10 件。上午 10 點,倉庫發送商品 A50 件到門店,最後倉庫中有商品 A50 件,門店有商品 A60 件,這樣商品的總是是不變的。不能門店收到貨後,倉庫的商品 A 還是 100 件,這樣就出現數據庫不一致問題。倉庫和門店商品 A 的總數是 110 才是正確的,這就是數據庫的一致性。

3 數據庫事務

數據庫事務 (transaction) 是訪問並可能操作各種數據項的一個數據庫操作序列,這些操作要麼全部執行,要麼全部不執行,是一個不可分割的工作單位。事務由事務開始與事務結束之間執行的全部數據庫操作組成。

事務的性質:

  • 原子性 (Atomicity):事務中的全部操作在數據庫中是不可分割的,要麼全部完成,要麼全部不執行。
  • 一致性 (Consistency):幾個並行執行的事務,其執行結果必須與按某一順序 串行執行的結果相一致。
  • 隔離性 (Isolation):事務的執行不受其他事務的干擾,事務執行的中間結果對其他事務必須是透明的。
  • 持久性 (Durability): 對於任意已提交事務,系統必須保證該事務對數據庫的改變不被丟失,即使數據庫出現故障

4 併發問題

數據庫在併發環境下會出現髒讀、重複讀和幻讀問題。

1. 髒讀

事務 A 讀取了事務 B 未提交的數據,如果事務 B 回滾了,事務 A 讀取的數據就是髒的。
舉例:訂單 A 需要商品 A20 件,訂單 B 需要商品 A10 件。倉庫中有商品 A 庫存是 20 件。訂單 B 先查詢,發現庫存夠,進行扣減。在扣減的過程中,訂單 A 進行查詢,發現庫存只有 10 個不夠訂單數量,拋出異常。這時候訂單 B 提交失敗了。庫存數量又變成 20 了。這時候,倉庫人員去查庫存,發現數量是 20,可是訂單 A 卻説庫存不足,這就讓人很奇怪。

2. 不可重複讀

復讀指的是在一個事務內,最開始讀到的數據和事務結束前的任意時刻讀到的同一批數據出現不一致的情況。
舉例:庫房管理員查詢商品 A 的數量,讀取結果是 20 件。這是訂單 A 出庫,扣減了商品 10 件。這時管理員再去查商品 A 時,發現商品 A 的數量時 10 件和第一此查詢的結果不同了。

3. 幻讀

事務 A 在執行讀取操作,需要兩次統計數據的總量,前一次查詢數據總量後,此時事務 B 執行了新增數據的操作並提交後,這個時候事務 A 讀取的數據總量和之前統計的不一樣,就像產生了幻覺一樣,平白無故的多了幾條數據,成為幻讀。
舉例:操作員查詢可生產單量 10 個,調用接口下發 10 個訂單,事務 A 增加 10 個訂單。操作員獲取 10 個訂單落庫,查詢 發現變成 30 個訂單。

5 事務隔離級別

Read Uncommitted(未提交讀)
一個事務可以讀取到其他事務未提交的數據,會出現髒讀,所以叫做 RU,它沒有解決任何的問題。

Read Committed(已提交讀)
一個事務只能讀取到其他事務已提交的數據,不能讀取到其他事務未提交的數據,它解決了髒讀的問題,但是會出現不可重複讀的問題。

Repeatable Read(可重複讀)
它解決了不可重複讀的問題,也就是在同一個事務裏面多次讀取同樣的數據結果是一樣的,但是在這個級別下,沒有定義解決幻讀的問題。

Serializable(串行化)
在這個隔離級別裏面,所有的事務都是串行執行的,也就是對數據的操作需要排隊,已經不存在事務的併發操作了,所以它解決了所有的問題。

6 解決數據讀一致性

有兩個方案可以解決讀一致性問題:基於鎖的併發操作(LBCC)和基於多版本的併發操作(MVCC)

6.1 LBCC

既然要保證前後兩次讀取數據一致,那麼讀取數據的時候,鎖定我要操作的數據,不允許其他的事務修改就行了。這種方案叫做基於鎖的併發控制 Lock Based Concurrency Control(LBCC)。

LBCC 是通過悲觀鎖來實現併發控制的。

如果事務 A 對數據進行加鎖,在鎖釋放前,其他事務就不能對數據進行讀寫操作。這樣併發調用,改成了順序調用。對目前的大多數系統來説,性能完全不能滿足要求。

6.2 MVCC

要讓一個事務前後兩次讀取的數據保持一致,那麼我們可以在修改數據的時候給它建立一個備份或者叫快照,後面再來讀取這個快照就行了。不管事務執行多長時間,事務內部看到的數據是不受其它事務影響的,根據事務開始的時間不同,每個事務對同一張表,同一時刻看到的數據可能是不一樣的。這種方案我們叫做多版本的併發控制 Multi Version Concurrency Control (MVCC)。

MVCC 是基於樂觀鎖的。

在 InnoDB 中,MVCC 是通過 Undo log 中的版本鏈和 Read-View 一致性視圖來實現的。

6.2.1 Undo log

undo log 是 innodb 引擎的一種日誌,在事務的修改記錄之前,會把該記錄的原值先保存起來再做修改,以便修改過程中出錯能夠恢復原值或者其他的事務讀取。undo log 是一種用於撤銷回退的日誌,在事務沒提交之前,MySQL 會先記錄更新前的數據到 undo log 日誌文件裏面,當事務回滾時或者數據庫崩潰時,可以利用 undo log 來進行回退。

對數據變更的操作不同,undo log 記錄的內容也不同:

  • 新增一條記錄的時候,在創建對應 undo 日誌時,只需要把這條記錄的主鍵值記錄下來,如果要回滾插入操作,只需要根據對應的主鍵值對記錄進行刪除操作。
  • 刪除一條記錄的時候,在創建對應 undo 日誌時,需要把這條數據的所有內容都記錄下來,如果要回滾刪除語句,需要把記錄的數據內容生產相應的 insert 語句,並插入到數據庫中。
  • 更新一條記錄的時候,如果沒有更新主鍵,在創建對應 undo 日誌時,如果要回滾更新語句,需要把變更前的內容記錄下來,如果要回滾更新語句,需要根據主鍵,把記錄的數據更新回去。
  • 更新一條記錄的時候,如果有更新主鍵,在創建對應 undo 日誌時,需要把數據的所有內容都記錄下來,如果要回滾更新語句,先把變更後的數據刪掉,再執行插入語句,把備份的數據插入到數據庫中。

undo log 版本鏈

每條數據有兩個隱藏字段,trx_id 和 roll_pointer,trx_id 表示最近一次事務的 id,roll_pointer 表示指向你更新這個事務之前生成的 undo log。
事務 ID:MySQL 維護一個全局變量,當需要為某個事務分配事務 ID 時,將該變量的值作為事務 id 分配給事務,然後將變量自增 1。

舉例:

  • 事務 A id 是 1 插入一條數據 X,這條數據的 trx_id =1 ,roll_pointer 是空(第一次插入)。
  • 事務 B id 是 2 對這條數據進行了更新,這條數據的 trx_id =2 ,roll_pointer 指向 事務 A 的 undo log.
  • 事務 C id 是 3 又對數據進行了更新操作,這條數據的 trx_id =3,roll_pointer 指向 事務 B 的 undo log.

所以當多個事務串行執行的時候,每個事務修改了一行數據,都會更新隱藏字段 trx_id 和 roll_pointer,同時多個事務的 undo log 會通過 roll_pointer 指針串聯起來,形成 undo log 版本鏈。

6.2.2 Read-View 一致性視圖

InnoDB 為每個事務維護了一個數組,這個數組用來保存這個事務啟動的瞬間,當前活躍的事務 ID。這個數組裏有兩個水位值: 低水位 (事務 ID 最小值) 和 高水位 (事務 ID 最大值 + 1); 這兩個水位值就構成了當前事務的一致性視圖(Read-View)

ReadView 中主要包含 4 個比較重要的內容:

  • m_ids:表示在生成 ReadView 時當前系統中活躍的讀寫事務的事務 id 列表。
  • min_trx_id:表示在生成 ReadView 時當前系統中活躍的讀寫事務中最小的事務 id,也就是 m_ids 中的最小值。
  • max_trx_id:表示生成 ReadView 時系統中應該分配給下一個事務的 id 值。
  • creator_trx_id:表示生成該 ReadView 的事務的事務 id。

有了這些信息,這樣在訪問某條記錄時,只需要按照下邊的步驟判斷記錄的某個版本是否可見:

  • 如果被訪問版本的 trx_id 屬性值與 ReadView 中的 creator_trx_id 值相同,意味着當前事務在訪問它自己修改過的記錄,所以該版本可以被當前事務訪問。
  • 如果被訪問版本的 trx_id 屬性值小於 ReadView 中的 min_trx_id 值,表明生成該版本的事務在當前事務生成 ReadView 前已經提交,所以該版本可以被當前事務訪問。
  • 如果被訪問版本的 trx_id 屬性值大於 ReadView 中的 max_trx_id 值,表明生成該版本的事務在當前事務生成 ReadView 後才開啟,所以該版本不可以被當前事務訪問。
  • 如果被訪問版本的 trx_id 屬性值在 ReadView 的 min_trx_id 和 max_trx_id 之間,那就需要判斷一下 trx_id 屬性值是不是在 m_ids 列表中,如果在,説明創建 ReadView 時生成該版本的事務還是活躍的,該版本不可以被訪問;如不在,説明創建 ReadView 時生成該版本的事務已經被提交,該版本可以被訪問。
  • 如果某個版本的數據對當前事務不可見的話,那就順着版本鏈找到下一個版本的數據,繼續按照上邊的步驟判斷可見性,依此類推,直到版本鏈中的最後一個版本。如果最後一個版本也不可見的話,那麼就意味着該條記錄對該事務完全不可見,查詢結果就不包含該記錄。

6.2.3 數據的查找方式

1. 快照讀

快照讀又叫一致性讀,讀取的是歷史版本的數據。不加鎖的簡單的 SELECT 都屬於快照讀,即不加鎖的非阻塞讀,只能查找創建時間小於等於當前事務 ID 的數據或者刪除時間大於當前事務 ID 的行(或未刪除)。

2. 當前讀

當前讀查找的是記錄的最新數據。加鎖的 SELECT、對數據進行增刪改都會進行當前讀。

6.2.4 數據舉例

如圖所示:

事務 A id =1 初始化了數據
事務 B id=2 進行了查詢操作(MVCC 只讀取創建時間小於當前事務 ID 的數據或者刪除時間大於當前事務 ID 的行)
事務 B 的結果是 (商品 A:10, 商品 B:5)

事務 C id =3 插入了商品 C
事務 B id=2 進行了查詢操作(MVCC 只讀取創建時間小於當前事務 ID 的數據或者刪除時間大於當前事務 ID 的行)
事務 B 的結果是 (商品 A:10, 商品 B:5)

事務 D id =4 刪除商品 B
事務 B id=2 進行了查詢操作(MVCC 只讀取創建時間小於當前事務 ID 的數據或者刪除時間大於當前事務 ID 的行)
事務 B 的結果是 (商品 A:10, 商品 B:5)

事務 E id =4 修改商品 A 的數量
事務 B id=2 進行了查詢操作(MVCC 只讀取創建時間小於當前事務 ID 的數據或者刪除時間大於當前事務 ID 的行)
事務 B 的結果是 (商品 A:10, 商品 B:5)

所以當事務 E 提交後,當前讀獲取的數據和事務 B 讀取的快照數據明顯不同。

6.2.5 可解決問題

MVCC 可以很好的解決讀一致問題,只能看到這個時間點之前事務提交更新的結果,而不能看到這個時間點之後事務提交的更新結果。而且降低了死鎖的概率和解決讀寫之間堵塞問題。

7 小結

LBCC 和 MVCC 都可以解決讀一致問題,具體使用哪種方式,要結合業務場景選擇最合適的方式,MVCC 和鎖也可以結合使用,沒有最好只有更好。