Django 面試準備 08-1:庫存扣減問題

深入理解高並發場景下的庫存扣減與解決方案

08-1. 庫存扣減問題

📌 問題場景

在電商系統中,當多個用戶同時購買同一商品時,如何確保庫存扣減的正確性?

典型場景

# 商品庫存為 10
# 用戶 A 購買 5 件
# 用戶 B 購買 6 件
# 同時請求,會發生什麼?

🔴 錯誤示範:天真的實現

# ❌ 有問題的代碼
from django.db import models
from django.http import JsonResponse

class Product(models.Model):
    name = models.CharField(max_length=200)
    stock = models.IntegerField(default=0)  # 庫存數量

def purchase(request, product_id, quantity):
    # 1. 查詢商品
    product = Product.objects.get(id=product_id)

    # 2. 檢查庫存
    if product.stock >= quantity:
        # 3. 扣減庫存
        product.stock -= quantity
        product.save()
        return JsonResponse({'status': 'success'})
    else:
        return JsonResponse({'status': 'insufficient_stock'})

⚠️ 問題分析

時間線演示

時刻    用戶 A                      用戶 B                  庫存
T1      讀取庫存 = 10
T2                                  讀取庫存 = 10           10
T3      檢查:10 >= 5 ✓
T4                                  檢查:10 >= 6 ✓         10
T5      計算:10 - 5 = 5
T6                                  計算:10 - 6 = 4        10
T7      保存庫存 = 5                                        5
T8                                  保存庫存 = 4            4 ⚠️

結果:庫存從 10 變成 4,但實際賣出 11 件(5 + 6)!

這就是典型的 競態條件(Race Condition)


✅ 解決方案 1:數據庫級別的原子更新

使用 F() 表達式

from django.db.models import F
from django.db import transaction

def purchase_v1(request, product_id, quantity):
    """
    使用 F() 表達式在數據庫層面進行原子操作
    """
    with transaction.atomic():
        # 直接在數據庫層面進行扣減
        affected_rows = Product.objects.filter(
            id=product_id,
            stock__gte=quantity  # 確保庫存充足
        ).update(
            stock=F('stock') - quantity
        )

        if affected_rows > 0:
            return JsonResponse({'status': 'success'})
        else:
            return JsonResponse({'status': 'insufficient_stock'})

🔍 工作原理

生成的 SQL:

UPDATE product
SET stock = stock - 5
WHERE id = 123 AND stock >= 5;

這條 SQL 是原子操作,數據庫會:

  1. 加鎖
  2. 檢查條件
  3. 更新數據
  4. 釋放鎖

一氣呵成,不會被打斷


✅ 解決方案 2:悲觀鎖(Pessimistic Locking)

def purchase_v2(request, product_id, quantity):
    """
    使用 select_for_update 實現悲觀鎖
    """
    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'})
        else:
            return JsonResponse({'status': 'insufficient_stock'})

🔍 工作原理

-- PostgreSQL/MySQL
SELECT * FROM product WHERE id = 123 FOR UPDATE;

FOR UPDATE 會:

  • 鎖定該行記錄
  • 其他事務必須等待當前事務完成
  • 避免並發修改

⚖️ 優缺點

優點:

  • 簡單直觀
  • 確保數據一致性

缺點:

  • 性能較低(等待鎖)
  • 高並發下容易形成鎖競爭
  • 可能產生死鎖

✅ 解決方案 3:樂觀鎖(Optimistic Locking)

class Product(models.Model):
    name = models.CharField(max_length=200)
    stock = models.IntegerField(default=0)
    version = models.IntegerField(default=0)  # 版本號

def purchase_v3(request, product_id, quantity):
    """
    使用版本號實現樂觀鎖
    """
    max_retries = 3

    for attempt in range(max_retries):
        try:
            with transaction.atomic():
                product = Product.objects.get(id=product_id)
                old_version = product.version

                # 檢查庫存
                if product.stock < quantity:
                    return JsonResponse({'status': 'insufficient_stock'})

                # 嘗試更新(帶版本檢查)
                affected_rows = Product.objects.filter(
                    id=product_id,
                    version=old_version,
                    stock__gte=quantity
                ).update(
                    stock=F('stock') - quantity,
                    version=F('version') + 1
                )

                if affected_rows > 0:
                    return JsonResponse({'status': 'success'})

                # 版本不匹配,說明被其他事務修改了,重試
                continue

        except Exception as e:
            if attempt == max_retries - 1:
                return JsonResponse({'status': 'error', 'message': str(e)})

    return JsonResponse({'status': 'retry_exceeded'})

🔍 工作原理

用戶 A:                         用戶 B:
讀取:stock=10, version=1       讀取:stock=10, version=1
扣減 5 件                       扣減 6 件
更新 WHERE version=1 ✓          更新 WHERE version=1 ✗ (version已經變成2)
version -> 2                    重試...
                                讀取:stock=5, version=2
                                檢查失敗(庫存不足)✓

⚖️ 優缺點

優點:

  • 無需加鎖,性能好
  • 適合讀多寫少的場景

缺點:

  • 需要重試機制
  • 高並發下重試次數多

📊 方案對比

方案並發性能實現複雜度適用場景
F() 表達式⭐⭐⭐⭐⭐簡單扣減操作
悲觀鎖⭐⭐⭐⭐⭐對一致性要求極高
樂觀鎖⭐⭐⭐⭐⭐⭐⭐衝突較少的場景

🎯 實戰建議

1. 首選 F() 表達式

對於簡單的庫存扣減,F() 表達式是最優解

# 推薦寫法
Product.objects.filter(
    id=product_id,
    stock__gte=quantity
).update(stock=F('stock') - quantity)

2. 添加監控和日誌

import logging

logger = logging.getLogger(__name__)

def purchase_with_logging(request, product_id, quantity):
    with transaction.atomic():
        affected_rows = Product.objects.filter(
            id=product_id,
            stock__gte=quantity
        ).update(stock=F('stock') - quantity)

        if affected_rows > 0:
            logger.info(f'Product {product_id} stock reduced by {quantity}')
            return JsonResponse({'status': 'success'})
        else:
            logger.warning(f'Product {product_id} insufficient stock')
            return JsonResponse({'status': 'insufficient_stock'})

3. 考慮業務邏輯

def purchase_complete(request, product_id, quantity):
    """
    完整的購買流程
    """
    with transaction.atomic():
        # 1. 扣減庫存(使用 F() 表達式)
        affected_rows = Product.objects.filter(
            id=product_id,
            stock__gte=quantity
        ).update(stock=F('stock') - quantity)

        if affected_rows == 0:
            return JsonResponse({'status': 'insufficient_stock'})

        # 2. 創建訂單
        order = Order.objects.create(
            product_id=product_id,
            quantity=quantity,
            user=request.user
        )

        # 3. 扣減用戶餘額等其他操作...

        return JsonResponse({
            'status': 'success',
            'order_id': order.id
        })

💡 面試要點

Q1: 為什麼會出現庫存超賣?

答: 因為 讀取-檢查-更新 這三個步驟不是原子操作,在高並發下會出現競態條件。

Q2: F() 表達式為什麼能解決問題?

答: F() 表達式在數據庫層面執行 UPDATE stock = stock - N WHERE stock >= N,這是一條原子 SQL,避免了應用層的競態條件。

Q3: 悲觀鎖和樂觀鎖的區別?

答:

  • 悲觀鎖:假設會發生衝突,提前加鎖(SELECT FOR UPDATE
  • 樂觀鎖:假設不會發生衝突,通過版本號檢測衝突後重試

Q4: 高並發場景如何選擇方案?

答:

  1. 簡單扣減 → F() 表達式
  2. 複雜業務邏輯 → 悲觀鎖(select_for_update)
  3. 讀多寫少 → 樂觀鎖(版本號)
  4. 超高並發 → 分布式鎖 + Redis

🔗 下一篇

在下一篇文章中,我們將探討 秒殺系統設計,學習如何在超高並發場景下設計一個完整的秒殺系統。

閱讀時間:12 分鐘

0%