RDBMS - 錯誤讀取現象及交易隔離層級原理介紹與舉例

首先要知道 RDBMS 有所謂 Isolation (隔離性) 的機制,這也就是包含在 ACID 裡面的 I 的涵義,如果還不清楚何謂 ACID,可以參考:RDBMS - ACID 基礎觀念

所以今天要介紹的內容是根據 RDBMS 裡面不同隔離性的機制會產生那些讀取的現象 (read phenomena)。

今天內容依舊是參考 [TritonHo 大神的簡報](https://github.com/TritonHo/slides/tree/master/Taipei 2019-04 course),好教材!

RDBMS 的 read phenomena

首先先要知道會有那些讀取現象發生。

Dirty Read (髒讀)

當有兩個交易 (TX) 進行的時候,其中一個 TX 可以讀取另一個還未 committ 的 TX 在這段時期做的資料改動。

過程如下:

  1. 交易 A 更新 X 欄位
  2. 交易 B 讀取 X 欄位
  3. 交易 A Rollback
  4. 交易 B Commit

在以上的過程中,交易 B 讀到 X 欄位的值是交易 A 更新 X 欄位的值,但是!交易 A 並還沒有 Commit,只要還沒 Commit,該交易都是可能會被 Rollback 回去的,因此這樣的現象就稱為 Dirty Read!

Non-repeateable Read (無法重複的讀取)

在同一個交易 (TX) 中,讀取某筆資料在第一次讀取和第二次讀取時候出現不同結果,又或者可以理解成:讀取的資料是包含其它已經 committed TX 的 update,而這些 TX 的 commit 時間卻是在發生在本 TX 的開始之後。

過程如下:

  1. 交易 A 讀取 X 欄位
  2. 交易 B 更新 X 欄位
  3. 交易 B Commit
  4. 交易 A 讀取 X 欄位
  5. 交易 A Commit

在這樣的交錯執行可以發現,由於交易 B 在交易 A 還沒 Commit 就率先 Commit,而更改 X 欄位的值卻被交易 A 在前後順序各自讀取到,會造就兩次讀取的資料是不一樣的問題。

Phantom Read (幻讀)

在同一個交易 (TX),同一個 Query Statement 在第一次和第二次執行時,出現不同結果,又或者可以理解成:讀取的資料包含其它已經 committed TX 的 insert or update statement,而這些 TX 的 commit 時間卻發生在本 TX 的開始之後。

過程如下:

  1. 交易 A 發送 Query Statement 查到 2 筆符合的資料
  2. 交易 B 發送 Insert Statement 插入一筆類似的資料
  3. 交易 B Commit
  4. 交易 A 再次發送 Query Statement 得到 3 筆符合的資料
  5. 交易 A Commit

由於交易 B 插入的資料也符合交易 A Query Statement 中的條件,因此如果交易 B 在交易 A 完成之前就率先 Commit,會使得交易 A 在次讀取的時候,反而多了一筆資料,造成這種錯誤。

Non-repeatable Read 跟 Phantom Read 的差別

從這邊是不是覺得 Non-repeatable Read 跟 Phantom Read 定義很像呢?沒錯它們的確有重疊的部分,但是最重要的差別在於:

  1. Non-repeatable Read 第一次 Query 和第二次返回的是同一批的 Record,只是 Record 裡面的內容可能不同。請注意上面定義寫的是:讀取的資料是包含其它已經 committed TX 的 update,而這些 TX 的 commit 時間卻是在發生在本 TX 的開始之後。強調的是 update!!
  2. Phantom Read 第二次返回的結果比第一次多了 Record,請注意上面定義寫的是:讀取的資料包含其它已經 committed TX 的 insert or update statement,而這些 TX 的 commit 時間卻發生在本 TX 的開始之後。這邊強調的其實是 insert!!但其實也包含 Delete 的情況

我個人看法是覺得要分的話就是 Non-repeatable Read 返回都是同一批的 record,不多也不少,但是 record 內容可能會被更動過。而 Phantom Read 返回來的 record 數量與第一次讀取的數量不一樣。

從這邊其實得知一件事情,就是:

Phantom Read 鎖的程度一定大於 Non-repeatable Read,因為對於前者,要鎖住滿足條件及其相近的紀錄,對於後者,只需要鎖住滿足條件的紀錄。

RDBMS Isolation 多種交易隔離層級介紹

隔離交易基本原理就是鎖定資料庫,之所以要分隔離層級是因為但鎖的程度影響到的就是資料庫的效能,也就是鎖的程度越高,效能越低,兩者是成反比的,所以實務上要根據系統讀寫需求去設定資料庫中不同的交易隔離層級 (transaction isolation level)

Read Uncommitted

這個是資料庫中最低層級,這個層級代表在交易過程中,交易 A 已更新但尚未 Commit 的資料,交易 B 只可以做存取的動作,也就是保證至少兩個交易同時進行的過程同時對兩個欄位做更改。但是這樣的層級是很危險的,因為 Dirty Read 其實是一件很嚴重的事情,大部分主流的資料庫預設交易隔離層級並不是設定 Read Uncommitted。甚至有些資料庫是沒有去實作 Read Uncommitted 的,意思就是代表不允許你設定該層級,因為資料讀取錯誤資料機率太高,像是 PostgreSQL 雖然可以設定 Read Uncommitted,但是官方文檔說即使你設定該層級,預設還是把你調回 Read Committed 層級。

Read Committed

比 Read Uncommitted 更嚴格,簡單來說就是可以防止 Dirty Read 的錯誤!也就是保證:交易中讀取的資料必須是已經 Commit 後的資料。

Repeatable Read

比 Read Committed 更加嚴格,簡單來說就是可以防止 Dirty Read、Non-repeatable Read。也就是保證:同一交易內讀取的資料其內容應該要一致。

Serializable

這個是最高層級,但其影響資料庫的效能也是最多的,會導致效能低落,非特殊情況是不會使用到該層級的。它能防止 Dirty Read、Non-repeatable Read、Phantom Read。

這邊要特別注意的是:

這四個隔離層級只是定義,事實上隔離層級在實作上又分為 SX LOCK 跟 MVCC 然後去滿足這四個隔離層級。

針對四種讀取現象,舉出情境例子

Dirty Read 例子

以股票交易系統為例,假設現在有一個奸商、客人各自開啟 TX,各自執行以下過程:

奸商 客人
Start transaction read uncommitted; Start transaction read uncommiited;
Update stock set last_price = '0USD' where stock_name = 'MSFT';
Select last_price from stock where stock_name = 'MSFT';
/* 這時客人會看到 0USD,看到奸商 update last _price 後的價錢,但是請注意還未奸商還沒有 committ */
Rollback;
Commit;

如果雙方是這樣執行的話,代表奸商先將 last_price 改成 0USD,而客人又讀取到還未 committed 成功的 0USD。誤以為價錢就是這樣,接著在客人 Commit 奸商先 Rollback,於是 last_price 又回復到當初的價錢。但是至始至終客人都只看偽造的 0USD。而客人如果因為 0USD 這樣的價錢而決定買股票的話,在它付錢的那一刻因為奸商先把 last_price 又 Rollback 回去,客人的錢就是扣掉原本的錢而不是扣掉 0USD,客人完全不知情當看到自己帳戶的錢才會發現被騙!!

結論:

  • Dirty Read 的數據都是因為還沒有 Committed 的數據,也就是這些數據是可能會被 Rollback 的。
  • 在還未 Commit 之前,database 是處於 inconsistent state,這些還未 Commit 的數據變動是不應該被其他用戶看到的。
  • 任何情況下,Dirty Read 在 RDBMS 的設定下都應該避免,因此這在任何的資料庫都是標準設定會採用的隔離層級是不會有 Dirty Read 的現象產生。所以通常開發者不需特地去設定。

Non-repeatable Read 例子

以航班機票系統為例,假設現在有一個顧客、航空公司各自開啟 TX,各自執行以下過程:

顧客 航空公司
Start transaction read committed;
Select price from flight where flight_name = 'HKG-->BKK';
/* 這時假設顯示的價錢是 200USD,如果顧客覺得價格合理,顧客按下購買按鍵 */`
這時航空公司決定加價
Start transaction read uncommiited;
update flight set price = '300USD' where flight_name = 'HKG-->BKK';
Commit;
Update user set balance = balance - (Select price from flight where flight_name = 'HKG-->BKK') Where user.id = $userId;
/* 這時用戶反而會被扣除 300USD */
Commit;

結論:

  • Non-repeatable Read 會讓同一 Record 在不同的 Select 中顯示不同的數值。
  • 通常同一數據的第一次 select 是用作 data verification 或是叫 checking,第二次是用作 data processing。
  • 如果 verification 和 processing 是使用不同的數值, 那麼 verification 根本毫無意義!

Phantom read 例子

以航班機票系統為例,假設現在有一個顧客、航空公司各自開啟 TX,各自執行以下過程:

顧客 航空公司
Start transaction repeatable read;
Select sum(cost) from flight_misc_cost where flight_name = 'HKG-->BKK';
/* 機場費,稅金加起來顯示 50USD,用戶覺 得價格合理,並且按下「購買」按鍵 */
政府決定讓航空公司徵收燃油附加費,航空公司當然立即跟隨:
Start transaction repeatable read;
Insert into flight_misc_cost(flight_name, item_name, cost) values ( 'HKG-->BKK', '燃油附加費', '10USD');
Commit;
Update user set balance = balance - (Select sum(cost) from flight_misc_cost where flight_name = 'HKG-->BKK');
/* 這時用戶會被扣除 60USD */
Commit;

結論:

  • Phantom Read 會讓第二次的 query 出現了新的 record
  • Phantom Read 引起的問題跟 Non-repeatable Read 相近
  • 但是 Phantom Read 比 Non-repeatable Read 更難防範

總結

根據以上內容可以知道讀取現象可以 Dirty Read、Non-repeatable Read、Phantom Read,而根據解決這些讀取現象分為四種交易隔離層級去解決這些事情。實際上在實作上它們是如何去控制鎖的機制去解決的呢?這個就需要去理解 SX LOCK、MVCC,下篇文章帶來這兩種機制的介紹,以及用同樣的情境例子去解釋。