處理資料庫中的事務並行問題:隔離層級(Isolation Levels)與鎖機制(Locks)

先來說說事務與回滾

在資料庫中,事務(Transaction)是指對資料庫的一系列操作,要不就全部成功執行,要不就全部失敗的一種機制。其中若是在事務操作中,遇到錯誤或是條件不滿足時,我們會撤銷已經執行的所有更改,並將資料恢復到事務開始之前的狀態,這樣的行為稱做回滾(rollback)

以實際的應用來舉例:

  • 銀行轉賬:從一個帳戶轉賬到另一個帳戶,需要確保:① 從轉出帳戶扣除金額 ② 往轉入增加金額。這兩件事情,要不就兩個操作都成功,要不就兩者都不發生任何更改,方能保證存款人的資金安全。
  • 訂單系統:在電商平台或售票系統下單時,涉及以下操作:① 庫存扣減 ② 生成訂單 ③ 更新客戶帳戶。這一系列的操作完成,才能確保庫存與訂單資料正確,不會兜不上。

在 Django 中,可能會這樣去寫:

from django.db import transaction
from myapp.models import Account

def transfer_funds(from_account_id, to_account_id, amount):
    try:
        with transaction.atomic():
            from_account = Account.objects.select_for_update().get(id=from_account_id)
            to_account = Account.objects.select_for_update().get(id=to_account_id)

            # 檢查餘額是否足夠
            if from_account.balance < amount:
                raise ValueError("Insufficient funds")

            # 扣除轉出帳戶金額
            from_account.balance -= amount
            from_account.save()

            # 增加轉入帳戶金額
            to_account.balance += amount
            to_account.save()

    except Exception as e:
        print("Transaction failed:", e)

再來聊聊資料庫的 ACID 特性

資料庫管理系統中,在寫入或更新資料的過程中,為了保證事務(transaction)是正確可靠的,必須具備下述的四種特性,我們一般會取這四個特性的首字母,縮寫為 ACID 特性:

  • 原子性(Atomicity)
    事務中的所有操作要不就全部執行,要不就全部不執行。如果事務中途出現錯誤,所有已完成的操作將被回滾(rollback)到事務開始前的狀態。
  • 一致性(Consistency)
    事務執行完成後,資料必須保持符合規則應使資料庫從一種狀態轉換到另一種狀態。換句話說,事務在完成前後的資料庫狀態必須符合所有的業務規則和約束條件。
  • 隔離性(Isolation)
    在多個事務並行(concurrency)執行時,不同事務之間的操作不應互相干擾。在資料庫層面會使用以下方式來實現:
    • 透過設定不同的隔離層級(isolation level)來控制事務之間資料的可見性與訪問性。
    • 資料庫引擎透過鎖(lock)機制來阻止其他事務讀取或修改未提交的資料。
  • 持久性(Durability)
    一旦事務提交(commit)出去,其對資料庫的修改將永久保存,即使發生系統崩潰或斷電等情況。資料庫透過預寫日誌記錄(WAL)和事務備份來實現持久性。

那些並行事務引發的那些困擾

讓我們先來看看在現實生活中,可能會遇到什麼狀況?

  • 銀行系統:多個使用者可能會同時訪問同一個帳戶記錄,如一個使用者在提款時,另一個使用者查詢帳戶餘額。
  • 訂單系統:在搶購活動中,多個使用者同時下單購買同一件商品,需要確保庫存不會被超賣。

不同的使用者在上述狀況中,可能同時會觸發不同的事務(transaction)。而所謂的並行事務,便是指多個事務在同一資料庫中同時被執行,由於多個事務之間可能需要訪問或修改相同的一筆資料,因此會使得彼此之間相互影響,從而引發下述常見的並行問題。

事務並行問題:髒讀(Dirty Read)

髒讀(Dirty Read)是指一個事務讀取了另一個事務尚未提交(un-commit)的資料,如果該筆資料被修改或回滾,前一個事務就會得到不一致或錯誤的資料。

舉例:兩位銀行職員,在同一時間處理同一個帳戶的相關交易:事務 A 是將帳戶餘額從 1000 元修改為 800 元,這個修改尚未提交。同時,事務 B 查詢該帳戶餘額並顯示為 800 元。此時如果事務 A 回滾,則餘額應該回到 1000 元,但事務 B 顯示的餘額卻是不正確的,因為它讀取了未提交的髒資料。

事務並行問題:不可重複讀(Non-repeatable Read)

不可重複讀(Non-repeatable Read)是指在同一事務中多次讀取相同數據時,由於其他事務的修改導致讀到的數據值不一致。例如,事務 A 第一次讀取某數據後,事務 B 修改了該數據並提交,事務 A 再次讀取該數據時會發現值已變化。

舉例:一個訂單管理系統中,客戶正在查看自己訂單的狀態(事務 A),並在該事務中多次查詢訂單的狀態;此時,另一個管理員修改了該訂單的狀態(事務 B)。當客戶再次查詢時,發現訂單狀態已經改變,前後不一致,這就是不可重複讀的現象。

事務並行問題:幻讀(Phantom Read)

幻讀(Phantom Read)是指一個事務在某些條件下會讀取多筆資料,當再次讀取時,由於其他事務在過程中插入或刪除了符合條件的數據,導致讀取結果的資料條目數量不一致。

舉例:公司正在生成一份銷售報告,事務 A 查詢所有銷售資料的記錄以計算總銷售額。同時,事務 B 新增了一筆銷售訂單並提交。當事務 A 再次查詢時,結果中多了一筆訂單數據,導致總銷售額發生變化。這就是幻讀的情況。

事務並行問題:更新丟失(Lost Update)

更新丟失(Lost Update)是指當兩個事務同時更新相同的數據時,後提交的事務會覆蓋先提交事務的更新,導致數據變化被覆蓋。

舉例:兩個用戶同時編輯公司網站上的某一篇文章。事務 A 先修改了文章內容並保存,但尚未提交。此時事務 B 也修改了相同的文章內容並提交。最後,事務 A 提交其修改後,覆蓋了事務 B 的更改,使得 B 的編輯被丟失。

處理競爭與衝突的手段:隔離與上鎖

我們不難看出,引致這些事務並行的問題原因,主要是多個事務對共享數據的同時訪問。在資料庫中,會使用以下手段或機制,處理事務並行時的競爭與衝突問題。

隔離層級(Isolation Levels)

資料庫可以通過設置事務的隔離層級(Isolation Levels),來控制不同事務之間的可見性和訪問性。根據不同需求,隔離級別從低到高依次為:

  • 未提交讀取(Read Uncommitted)
    允許事務讀取其他事務尚未提交的資料,可能導致「髒讀」。亦即基本上不做任何隔離,通常只用於不嚴格要求一致性的情況,適用於不重要或臨時的資料讀取操作,例如:日誌分析。
  • 已提交讀取(Read Committed)
    僅允許事務讀取已提交的資料,可以避免髒讀,但可能出現「不可重複讀」問題。適用於需要避免讀到過時資料的系統,比如:銀行的餘額查詢,確保讀到的資料是其他事務提交後的結果。
  • 可重複讀取(Repeatable Read):保證事務中多次讀取的結果一致,避免髒讀和不可重複讀,但可能出現「幻讀」。適合電商系統,確保使用者讀取的商品價格和庫存量一致,避免多次讀取時資料發生變化。
  • 可被順序化(Serializable):最高的隔離級別,將並行事務予以順序化,可以防止所有的並行問題,但會因為鎖定大量資料而降低性能。適用於銀行的轉賬操作等高一致性要求的場景。
from django.http import JsonResponse
from myapp.models import Product

def update_product_stock(request, product_id, quantity):
    # 設置事務隔離級別為 REPEATABLE READ
    with connection.cursor() as cursor:
        cursor.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;")
    
    try:
        with transaction.atomic():
            # 鎖定行以防止競爭條件
            product = Product.objects.select_for_update().get(id=product_id)
            
            # 檢查庫存
            if product.stock >= quantity:
                product.stock -= quantity
                product.save()
                return JsonResponse({'status': 'success', 'message': 'Stock updated successfully'})
            else:
                return JsonResponse({'status': 'failed', 'message': 'Insufficient stock'})
    except Product.DoesNotExist:
        return JsonResponse({'status': 'failed', 'message': 'Product not found'})

鎖機制(Locks)

資料庫中的鎖機制(Locks),可以用來確保多個事務同時訪問相同資料時的資料一致性和完整性。能夠有效地在事務並行時,防止讀寫衝突和競爭條件問題。

鎖的分類會根據視角的不同而有所不同,以 MySQL 關聯式資料庫為例:

  • 依使用方式,可以分成共享鎖(Share Lock)、排他鎖(Exclusive Lock)
  • 依行為模式,可以分為悲觀鎖(Pessimistic Lock)、樂觀鎖(Optimistic Lock)

共享鎖與排他鎖

共享鎖(Share lock)又稱為讀取鎖(Read Lock),是指當資源被鎖定時,允許其它事務讀取該資源,也允許其它事務從該資源上再次獲取共享鎖,但不能對該資源進行寫入。

START TRANSACTION;

SELECT * FROM employees
WHERE department = 'IT'
LOCK IN SHARE MODE;

-- Perform operations like data analysis or reporting on the selected rows

COMMIT;
LOCK TABLES employees READ;

-- Perform read-only operations on the table
SELECT * FROM employees;

UNLOCK TABLES;

排他鎖(Exclusive Lock)又稱為寫入鎖(Write Lock),主要是防止其它事務和當前加鎖事務鎖定同一資源。亦即當排他鎖加在指定資源上時,其它事務無法對該資源進行 INSERTUPDATEDELETEALTERDROP 等操作。

START TRANSACTION;

SELECT * FROM employees
WHERE department = 'HR'
FOR UPDATE;

-- Perform update operations on the selected rows
UPDATE employees SET salary = salary + 500 WHERE department = 'HR';

COMMIT;
LOCK TABLES employees WRITE;

-- Perform operations such as inserts, updates, or deletes
UPDATE employees SET salary = salary + 500 WHERE department = 'HR';

UNLOCK TABLES;

悲觀鎖與樂觀鎖

悲觀鎖(Pessimistic Lock)假設資料會經常發生衝突,因此在訪問資源時,會先進行加鎖以確保資料不會被別的事務修改。

  • 通常會使用資料庫的鎖機制來實現。
  • 適用於高併發且數據經常被修改的場景,例如:銀行轉賬、訂單管理等。

樂觀鎖(Optimistic Lock)假設資料很少會發生衝突,因此在訪問資源時不進行加鎖,只有在更新時才會資料檢查是否被其他事務修改過,如果發現衝突則回滾當前操作並重新嘗試。

  • 通常會使用版本編號或時間戳記來實現,例如添加 versiontimestamp 欄位作為比對用途。
  • 適用於讀多寫少、數據衝突較少的情況,例如:社交媒體中的瀏覽計數、產品庫存查看等。