開發人員與資料庫的交易

進行資料庫的交易時,應該重視與思考的事項

資料庫的交易使複雜的資料庫操作行為變得較為簡潔清晰且不易出錯,但交易真如表面上如此簡單嗎?本文將探討開發人員操作資料庫的交易時,並行交易可能產生的問題,以及注意各資料庫實作上的差異細節。

交易是什麼

交易讓開發人員不必在意底層實作細節而可以達成:將一系列的讀寫操作視為一個動作/一個不可分割的邏輯單位,不是全部寫入成功(committed),就是完全與交易前的狀態一樣而無變化(abort, rollback),不會有部分寫入的情形

在交易中只要遇到錯誤,像是斷電、硬體故障、磁碟已滿、網路斷線或是開發人員主動執行 abort 指令等問題,即便已執行些許寫入指令,也會全部回滾(rollback)至交易前的狀態,這讓開發人員專注在交易執行內容以及發生錯誤時該如何處理。

不過交易就那麼讓人安心嗎?交易實作的細節被隱藏起來,好讓開發人員更便利以及更有效率地利用交易開發系統,但其細節實作卻不一定是我們想像的那樣。考慮到並行執行,假如有兩個交易 A 和 B 同時寫入同一筆資料,我們會想像兩個交易互不干擾,執行的結果如同循序執行一般,不是 A 交易執行完再執行 B 交易,就是 B 交易先執行完再執行 A 交易。但各個資料庫實作會考量執行效能的成本,會產生意想不到的意外或邏輯上的錯誤。

我們已大致了解交易帶來的效用,就是將一系列讀寫操作視為一個不可分割的邏輯單位,要麼全部提交成功,要麼全部回滾,這讓我們的系統操作有了保障,例如銀行轉帳系統,不會出現轉帳人轉帳扣款,因系統發生錯誤,而收款人卻沒收到錢這種只寫入部分成功的狀況。而交易這個全有全無的保障,具備了廣為人知的四個特性,即 ACID 特性。接下來一起來看看 ACID 特性吧!

ACID

ACID 是四個特性首字組合而成的詞彙,即 Atomicity(原子性)、Consistency(一致性)、Isolation(隔離性)以及 Durability(持久性)。接著來簡略介紹一下 ACID 特性:

  • 原子性(Atomicity):交易中的所有操作,只會有兩種情況,全部成功或是全部不成功,不會有部分操作成功而部分操作失敗的問題。交易中只要有錯誤就會回滾,回到交易前的狀態。
  • 一致性(Consistency):交易必須使資料庫從一個一致性狀態轉換到另一個一致性狀態,這一致性的狀態指的是資料符合約束(constraint)、觸發器(trigger)和欄位定義(例如:欄位為 INT,就不會儲存成字串),不會破壞外來鍵、唯一鍵等約束。
  • 隔離性(Isolation):並行的交易如同隔開執行一般,不會互相干擾。並行交易的執行會讓你覺得交易是循序執行一樣(例例如,有兩個交易 A 和 B 並行執行,其結果如同 A 交易執行完再執行 B 交易,或是 B 交易先執行完再執行 A 交易)。
  • 持久性(Durability):交易成功後所有操作會寫入資料庫,資料不會遺失。

這邊要注意,隔離性並非如同定義一般,並行交易執行會因為各個資料庫實作的隔離等級不同而產生問題,在後面的「並行交易問題與隔離等級」主題將來探討。

並行交易問題與隔離等級

之前提到隔離性並不是如定義般一樣,是因為資料庫若完整實作隔離性時,其並行交易的執行效率會很差。考量到效能問題,資料庫提供不同的隔離等級(isolation levels),讓開發人員可以選擇以效能為主而降低隔離性,或是選擇較強的隔離性而犧牲效能。隔離等級大多是依照其能解決的並行交易問題來劃分的,接下來,我們就來看看並行交易可能產生的問題,以及這些問題與隔離等級之間的關係。

髒讀(Dirty Read)問題

髒讀指的是,在交易中讀取到其他交易修改後但尚未提交的資料。「如果修改後的資料最終回滾,那麼本次交易讀取到的資料就如同不存在一般。另一個問題是,修改後的資料可能只是部分更新,必須與其他相關資料一同修改才有意義。看看以下例子:

餐廳內場有個系統會定時顯示客人已下單付錢的餐品。某天,櫃檯人員輸入客人的餐點後,接受信用卡付款,但因客人信用卡付款一直不成功且沒有現金,所以取消了此次交易。系統回滾了金額收入以及下單的餐點,然而,內場系統卻已顯示下單品項,廚師也已開始烹調,即將有 5 個和牛牛排、10 份龍蝦的員工餐。

讀取另一個交易修改的資料後,另一個交易後來回滾其修改

料理鐵人賽八強淘汰賽正在電視轉播,採兩人一組對決,共分為 A-D 組。目前評審評審給予的最高分是 A 組的小當家 89 分。此時,C 組的對決正好結束,C 組的勞叔目前獲得最高評分 91 分。戶外的兩位友人正用手機刷新網頁,想知道目前的最高分是誰。友人 A 看著手機,顯示最高分是 A 組的小當家 91 分,便說道:『最高分 91 分!』友人 B:『對啊!勞叔好厲害!』友人 A:『蛤?』。

讀取到部分更新的資料

讀取到未提交的變更可能會導致嚴重的問題,解決這個問題的方法通常是將交易隔離等級設定為「讀取已提交」(Read Committed)或更高等級。

不可重複讀取(Non-Repeatable Read)

在同一交易中讀取資料時,在第二次讀取同一筆資料前,另一個交易已經修改了該筆資料的值,導致在同一個交易中多次讀取同一筆資料時,資料內容不一致的狀況,稱為不可重複讀取。

同一交易中兩次讀取同一資料時資料不一致的狀況

不可重複讀取會造成資料不一致的問題, 尤其像是製作報表這種較為耗時的交易。要解決不可重複讀取問題的方法,通常是將交易隔離等級設定為「可重複讀取」(Repeatable Read)或更高等級,例如 Oracle 資料庫提供的「可序列化」(Serializable)等級。

此外,需要注意的是「不可重複讀取」和「幻讀」的微妙差異,有時,除了「不可重複讀取」的問題外,還可能發生「幻讀」的現象,請務必參考後續關於幻讀的說明。

遺失更新(Lost Update)

遺失更新指的是當多個不同交易並行地更新同一筆資料時,由於執行順序交錯,導致其中一個交易的更新被另一個交易更新的結果所覆蓋,最後造成部分資料更新的遺失。

舉個熱鬧水餃店例子,水餃店因為結帳人潮眾多,所以設置了許多收銀櫃台。某日,在客人結帳過程中發生了遺失更新的現象:

水餃店發生了遺失更新現象

遺失更新通常有一種典型的模式:

  1. 讀取:兩個或多個交易同時賭取同一個資料,其資料的值相同(也可以說是讀取到同一個版本)。
  2. 修改:對該資料(運算後)修改。
  3. 回寫:將修改後的資料回寫回資料庫,不過後寫回的會覆蓋到較早寫回的資料。

要防止遺失更新,有下列幾種方法:

  • 設定隔離等級:有些資料庫對於遺失更新這種模式會有對應的防禦措施,例如,PostgreSQL 的可重複讀(Repeatable Read)隔離等級,在修改時偵測到資料已被其他交易並提交,會執行回滾(Rollback);類似地還有 Oracle 的 Serializable 隔離等級以及 SQL Server 的 Snapshot 隔離等級。關於各資料庫的隔離等級,還請詳閱官方文件。
  • 樂觀鎖:在更新的時候,判斷資料內容是否有變更,SQL 執行後若沒有更新(affected rows: 0),交易就要再重試一遍。使用這方法要注意 WHERE 條件可能還是讀到舊資料,還請實際測試看看。
        UPDATE product SET quantity = 86 WHERE id = 1 && quantity = 89;
    
        -- 或是增加一個 version 欄位
        UPDATE product SET quantity = 86, ver = ver + 1 WHERE id = 1 && ver = 1;
  • 悲觀鎖:使用資料庫提供的鎖,尋找一個可以解決遺失更新又影響效能較低的鎖吧!以下為範例:
        BEGIN TRANSACTION;
    
        SELECT quantity FROM product WHERE id = 1 FOR UPDATE;
    
        -- 取得 quantity 為 89,客人買 3 包,所以要將 quantity 修改為 86
    
        UPDATE product SET quantity = 86 WHERE id = 1;
    
        COMMIT;
  • 原子操作:使用 UPDATE / DELETE SQL 操作,這裡直接看範例:
        -- 此範例在執行前還要確保修改後 quantity 不能小於 0
        UPDATE product SET quantity = quantity - 3 WHERE id = 1;
  • 應用程式端,外部邏輯鎖:在應用程式端解決此問題,所有相關交易先在應用程式端這裡取得所後再執行。使用此方法在有多個併行交易時效能較不佳,且鎖的範圍上升至應用程式端,也許架構會變得複雜。如果程式只有單一個 process 在執行,可以使用該程式語言的鎖;如有多個 processes 或是分散式架構,可以用 Redis 模擬 lock 等類似方法實行分散式鎖,

幻讀(Phantom Read)

幻讀指的是在同一個交易中,首次依照特定範圍條件讀取資料後,另一個交易在該範圍內新增了符合條件的資料,導致在同一個交易中再次以相同條件讀取時,出現了額外的新資料,造成讀取結果不一致的狀況。

即使設定的隔離等級解決了不可重複讀取的問題,仍然有可能發生幻讀現象。該隔離等級為了考量效能因素,並未使用範圍鎖之類的方法來防止幻讀的發生。

例如在 number 表中,有欄位 id 以及 value,兩次查詢 numbervalue 為 10 的總數,下圖顯示幻讀現象:

幻讀

解決幻讀問題的方法是提高交易的隔離等級,具體做法需要詳細查閱所使用資料庫關於不同隔離級別中幻讀行為的說明。例如,在 MySQL 中設定了可重複讀(Repeatable Read)的隔離等級,通常會使用 next-key locking 防止幻讀,但即使如此,交易中包含更新(或刪除等 DML 資料操作語言指令)的操作時,仍有可能發生幻讀,請參考官方文件 Consistent Nonlocking Reads 中 Note 區塊。發生幻讀步驟如下:第一次查詢符合條件的記錄 -> 另一個交易新增了符合 WHERE 條件的記錄 -> 第一個交易執行修改或刪除指令,其 WHERE 條件包含了新增的記錄 -> 第二次查詢符合相同 WHERE 條件的記錄時,結果可能與預期不符。

交易並行問題應對策略

在效能與隔離性之間取捨中,設定適當的交易隔離等級。此外,對於設定的隔離等級仍無法解決的並行問題,需要尋找其他解決方法。

各資料庫實作上的差異

資料操作語言和資料定義語言

在此,採用 SQL 或其延伸、變體的分類方式——資料操作語言和資料定義語言——將能更清晰地進行說明。

資料操作語言 (DML,Data Manipulation Language) 主要用於查詢、新增、修改和刪除資料表中的資料,例如 SQL 中的 SELECTINSERTUPDATEDELETE 等指令。有時,查詢操作 (SELECT) 會被獨立歸類為資料查詢語言 (DQL,Data Query Language)。

資料定義語言 (DDL,Data Definition Language) 則用於定義和修改資料庫結構,例如建立、修改和刪除資料庫、資料表和索引等,例如 SQL 中的 CREATE DATABASEALTER TABLEDROP INDEX 等指令。

在一個交易中,可以執行多個資料操作語言 (DML) 指令,這些指令要麼全部提交 (commit),要麼全部回滾 (rollback)。那麼,資料定義語言 (DDL) 的操作是否也遵循相同的原則呢?由於各個資料庫的實作方式存在差異,雖然大多數資料庫允許在交易中執行資料定義語言 (DDL) 指令,但部分資料庫可能會在執行 DDL 指令時進行隱式提交 (implicit commit),導致 DDL 操作的回滾可能完全無法實現,或者產生非預期的結果。不僅是資料定義語言 (DDL),任何非資料操作語言 (DML) 的寫入操作,例如與權限操作相關的資料控制語言 (DCL,Data Control Language),都需要仔細查閱官方文件,以確認其在交易中是否能達到預期的效果。

總結

關於交易,開發人員應該要思考下列三項情形:

  1. 交易的錯誤處理:將錯誤回報給使用者、重試或記錄錯誤等處理方式。
  2. 並行交易問題:在效能與隔離性之間取捨中,設定適當的交易隔離等級。此外,對於設定的隔離等級仍無法解決的並行問題,需要尋找其他解決方法。
  3. 各個資料庫供應商實作交易細節差異:例如各資料庫提供不同的交易隔離等級,以便在效能與隔離性之間取得平衡;交易中資料操作語言和資料定義語言的效果;實現巢狀交易行為的方式。也應注意同一資料庫的不同版本可能在實作細節上也會有所差異。