資料庫交易與並發控制:Django 開發者必修課

從理論到實戰,徹底理解並發問題與解決方案

為什麼並發控制如此重要?

想像一下黑色星期五的購物網站:成千上萬的用戶同時搶購限量商品。如果沒有適當的並發控制,可能會發生超賣、訂單遺失,甚至帳戶餘額錯誤等災難性後果。

這就是為什麼每個後端開發者都必須深入理解交易與並發控制。

📚 交易基礎:全有或全無

什麼是交易(Transaction)?

交易是資料庫中一組不可分割的操作序列,要麼全部成功執行,要麼全部不執行。

生活中的交易

ATM 轉帳的例子

當你從 A 帳戶轉 1000 元到 B 帳戶時,實際上包含兩個步驟:

  1. A 帳戶扣款 1000 元
  2. B 帳戶增加 1000 元

如果第一步成功但第二步失敗,錢就憑空消失了!交易機制確保:

  • 兩步都成功 → 轉帳完成
  • 任一步失敗 → 全部回滾,錢還在 A 帳戶

交易的 ACID 特性

  • Atomicity(原子性):不可分割,全有或全無
  • Consistency(一致性):始終保持資料的有效狀態
  • Isolation(隔離性):多個交易互不干擾
  • Durability(持久性):提交後永久保存

👻 三大異常現象:並發的幽靈

當多個交易同時執行時,如果沒有適當的控制,就會出現以下異常現象:

1. 髒讀(Dirty Read):讀到未提交資料

購物車的例子

時間線:
10:00 - 用戶A:將商品價格從 100 改為 50(未提交)
10:01 - 用戶B:看到價格 50,高興地加入購物車
10:02 - 用戶A:取消修改(回滾)
10:03 - 用戶B:結帳時發現價格變回 100,錯愕!

用戶 B 讀到了用戶 A 未提交的「髒」資料。

2. 不可重複讀(Non-repeatable Read):同筆資料兩次查詢值不同

查看餘額的例子

時間線:
10:00 - 你查詢帳戶餘額:5000 元
10:01 - 你思考要買什麼
10:02 - 配偶從同帳戶提領 2000 元
10:03 - 你再次確認餘額:3000 元(怎麼變少了?)

在同一個交易中,兩次讀取同一筆資料得到不同結果。

3. 幻讀(Phantom Read):查詢結果筆數改變

統計訂單的例子

時間線:
10:00 - 管理員查詢今日訂單數:100 筆
10:01 - 新用戶下單 5 筆
10:02 - 管理員準備匯出 100 筆訂單
10:03 - 實際匯出 105 筆(多出幽靈訂單!)

查詢條件相同,但結果集的大小改變了。

🛡️ 隔離級別:控制異常的防護罩

資料庫提供不同的隔離級別來控制這些異常,從寬鬆到嚴格分為四級:

1. Read Uncommitted(讀取未提交)

  • 特點:最寬鬆,可能發生所有異常
  • 性能:最快
  • 使用場景:幾乎不使用

2. Read Committed(讀取已提交)

  • 特點:防止髒讀
  • 可能發生:不可重複讀、幻讀
  • 性能:較快
  • 使用場景:PostgreSQL 預設級別

3. Repeatable Read(可重複讀)

  • 特點:防止髒讀、不可重複讀
  • 可能發生:幻讀(MySQL InnoDB 實際上也防止了)
  • 性能:中等
  • 使用場景:MySQL 預設級別

4. Serializable(可序列化)

  • 特點:防止所有異常,如同依序執行
  • 性能:最慢
  • 使用場景:銀行等高度敏感系統

隔離級別對照表

隔離級別髒讀不可重複讀幻讀性能
Read Uncommitted可能可能可能最快
Read Committed不可能可能可能
Repeatable Read不可能不可能可能*中等
Serializable不可能不可能不可能最慢

*註:MySQL InnoDB 透過 Next-Key Locking 實際上也防止了幻讀

💡 Django 解決方案

Django 提供了多種工具來處理交易和並發問題:

1. transaction.atomic():保證原子性

確保一組操作要麼全部成功,要麼全部失敗。

裝飾器用法

from django.db import transaction

@transaction.atomic
def transfer_money(from_account_id, to_account_id, amount):
    from_account = Account.objects.get(id=from_account_id)
    to_account = Account.objects.get(id=to_account_id)
    
    from_account.balance -= amount
    from_account.save()
    
    to_account.balance += amount
    to_account.save()
    
    # 如果任何步驟失敗,整個交易會回滾

上下文管理器用法

def create_order(user, items):
    try:
        with transaction.atomic():
            # 創建訂單
            order = Order.objects.create(user=user, total=0)
            
            total = 0
            for item in items:
                # 檢查庫存
                product = Product.objects.get(id=item['product_id'])
                if product.stock < item['quantity']:
                    raise InsufficientStockError(f"{product.name} 庫存不足")
                
                # 創建訂單項目
                OrderItem.objects.create(
                    order=order,
                    product=product,
                    quantity=item['quantity'],
                    price=product.price
                )
                
                # 扣減庫存
                product.stock -= item['quantity']
                product.save()
                
                total += product.price * item['quantity']
            
            # 更新訂單總額
            order.total = total
            order.save()
            
    except InsufficientStockError:
        # 交易會自動回滾
        raise

2. select_for_update():悲觀鎖防止並發修改

在查詢時鎖定資料列,防止其他交易修改。

基本用法

from django.db import transaction

@transaction.atomic
def withdraw_money(account_id, amount):
    # 鎖定帳戶,其他交易必須等待
    account = Account.objects.select_for_update().get(id=account_id)
    
    if account.balance < amount:
        raise InsufficientFundsError("餘額不足")
    
    account.balance -= amount
    account.save()
    
    return account.balance

處理死鎖

@transaction.atomic
def transfer_with_deadlock_prevention(from_id, to_id, amount):
    # 始終按照 ID 順序鎖定,避免死鎖
    if from_id < to_id:
        account1 = Account.objects.select_for_update().get(id=from_id)
        account2 = Account.objects.select_for_update().get(id=to_id)
        from_account, to_account = account1, account2
    else:
        account1 = Account.objects.select_for_update().get(id=to_id)
        account2 = Account.objects.select_for_update().get(id=from_id)
        from_account, to_account = account2, account1
    
    # 執行轉帳
    from_account.balance -= amount
    to_account.balance += amount
    
    from_account.save()
    to_account.save()

3. F() 表達式:避免競爭條件

直接在資料庫層級執行更新,避免 Read-Modify-Write 競爭。

問題範例(有競爭條件):

# 危險!可能造成遺失更新
product = Product.objects.get(id=1)
product.view_count += 1  # 讀取、修改
product.save()  # 寫入

# 如果兩個請求同時執行:
# 請求1:讀取 view_count = 100
# 請求2:讀取 view_count = 100
# 請求1:設定 view_count = 101
# 請求2:設定 view_count = 101
# 結果:只增加了 1 次,而非 2 次!

解決方案(使用 F() 表達式):

from django.db.models import F

# 安全!在資料庫層級原子性更新
Product.objects.filter(id=1).update(
    view_count=F('view_count') + 1
)

# 更複雜的例子:扣減庫存
from django.db import transaction

@transaction.atomic
def purchase_product(product_id, quantity):
    # 使用 F() 表達式原子性扣減庫存
    updated = Product.objects.filter(
        id=product_id,
        stock__gte=quantity  # 確保庫存足夠
    ).update(
        stock=F('stock') - quantity
    )
    
    if not updated:
        raise InsufficientStockError("庫存不足或商品不存在")
    
    # 如果需要返回更新後的值
    product = Product.objects.get(id=product_id)
    return product

🎯 實戰:組合使用

單獨使用 atomic() 往往不夠,需要結合其他機制:

秒殺系統範例

from django.db import transaction
from django.db.models import F
from django.core.cache import cache
import uuid

class SeckillService:
    @staticmethod
    @transaction.atomic
    def create_order(user_id, product_id, quantity=1):
        # 1. 檢查用戶是否已購買(使用快取加速)
        cache_key = f"seckill:{product_id}:user:{user_id}"
        if cache.get(cache_key):
            raise AlreadyPurchasedError("您已經購買過此商品")
        
        # 2. 使用 select_for_update 鎖定商品
        try:
            product = Product.objects.select_for_update(
                nowait=True  # 不等待,立即失敗
            ).get(id=product_id, is_seckill=True)
        except Product.DoesNotExist:
            raise ProductNotFoundError("商品不存在")
        except DatabaseError:
            # nowait=True 時,如果無法立即獲得鎖會拋出異常
            raise SystemBusyError("系統繁忙,請稍後再試")
        
        # 3. 檢查庫存
        if product.stock < quantity:
            raise InsufficientStockError("商品已售罄")
        
        # 4. 使用 F() 表達式扣減庫存
        Product.objects.filter(id=product_id).update(
            stock=F('stock') - quantity,
            sold_count=F('sold_count') + quantity
        )
        
        # 5. 創建訂單
        order = Order.objects.create(
            order_no=str(uuid.uuid4()),
            user_id=user_id,
            product=product,
            quantity=quantity,
            total_amount=product.seckill_price * quantity,
            status='pending_payment'
        )
        
        # 6. 標記用戶已購買
        cache.set(cache_key, True, 3600)  # 快取 1 小時
        
        return order

庫存管理最佳實踐

class InventoryService:
    @staticmethod
    def adjust_stock(product_id, quantity_change, reason):
        """
        調整庫存的統一入口
        quantity_change: 正數表示入庫,負數表示出庫
        """
        with transaction.atomic():
            # 鎖定產品
            product = Product.objects.select_for_update().get(id=product_id)
            
            # 計算新庫存
            new_stock = product.stock + quantity_change
            
            # 驗證庫存不能為負
            if new_stock < 0:
                raise InsufficientStockError(
                    f"庫存不足,當前庫存:{product.stock},"
                    f"嘗試扣減:{abs(quantity_change)}"
                )
            
            # 使用 F() 表達式更新,確保原子性
            Product.objects.filter(id=product_id).update(
                stock=F('stock') + quantity_change,
                updated_at=timezone.now()
            )
            
            # 記錄庫存變動歷史
            StockHistory.objects.create(
                product=product,
                change_quantity=quantity_change,
                after_quantity=new_stock,
                reason=reason,
                created_at=timezone.now()
            )
            
            return new_stock

🚨 常見陷阱與解決方案

陷阱 1:長交易

問題:交易時間過長會鎖定資源,影響並發性能。

解決方案

# 錯誤示範
@transaction.atomic
def process_large_order(items):
    for item in items:  # 假設有 1000 個項目
        # 複雜的業務邏輯
        validate_item(item)
        calculate_discount(item)
        update_inventory(item)
        send_notification(item)  # 耗時的外部調用!
    
# 正確做法
def process_large_order(items):
    # 1. 先在交易外準備資料
    prepared_items = []
    for item in items:
        validated = validate_item(item)
        discount = calculate_discount(item)
        prepared_items.append({
            'item': item,
            'validated': validated,
            'discount': discount
        })
    
    # 2. 只在交易中執行資料庫操作
    with transaction.atomic():
        for prepared in prepared_items:
            update_inventory(prepared['item'], prepared['discount'])
    
    # 3. 交易完成後執行耗時操作
    for item in items:
        send_notification.delay(item)  # 使用 Celery 異步執行

陷阱 2:忽視資料庫預設隔離級別

問題:不同資料庫的預設隔離級別不同。

解決方案

# settings.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        # PostgreSQL 預設是 READ COMMITTED
        'OPTIONS': {
            'isolation_level': psycopg2.extensions.ISOLATION_LEVEL_REPEATABLE_READ,
        },
    }
}

# 或在需要時動態設定
from django.db import connection

with transaction.atomic():
    cursor = connection.cursor()
    cursor.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
    # 執行需要最高隔離級別的操作

陷阱 3:過度使用悲觀鎖

問題:過度使用 select_for_update() 會嚴重影響並發性能。

解決方案

# 使用樂觀鎖替代
class Product(models.Model):
    name = models.CharField(max_length=100)
    stock = models.IntegerField()
    version = models.IntegerField(default=0)  # 版本號
    
    def optimistic_update(self, **kwargs):
        """樂觀鎖更新"""
        # 記錄當前版本
        current_version = self.version
        
        # 更新資料和版本號
        kwargs['version'] = current_version + 1
        
        # 只更新版本號匹配的記錄
        updated = Product.objects.filter(
            id=self.id,
            version=current_version
        ).update(**kwargs)
        
        if not updated:
            raise ConcurrentModificationError("資料已被其他用戶修改")
        
        # 更新本地物件
        for key, value in kwargs.items():
            setattr(self, key, value)

📋 最佳實踐總結

  1. 交易要短小精悍

    • 只包含必要的資料庫操作
    • 耗時操作放到交易外
  2. 選擇合適的隔離級別

    • 大部分情況 Read Committed 就夠了
    • 金融相關才需要 Serializable
  3. 正確組合使用工具

    • atomic() 保證原子性
    • select_for_update() 處理關鍵資源
    • F() 表達式避免競爭條件
  4. 監控和優化

    • 監控慢查詢和鎖等待
    • 定期檢查死鎖日誌
    • 壓力測試驗證並發處理

🎯 結語

並發控制是每個 Django 開發者必須掌握的技能。記住:

  • 單用 atomic() 是不夠的,它只保證原子性,不能防止並發問題
  • 根據業務場景選擇合適的工具組合
  • 在正確性和性能之間找到平衡

掌握這些知識,你就能構建出既正確又高效的應用系統!

0%