You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在会话操作中避免电商购物车的竞态条件问题?

问题描述

我正在开发一款具备购物车与商品功能的电商网站,商品主键会被存入用户会话数据的cart_content字典中,该字典以商品主键为键、商品数量为值。

我成功复现了一个竞态条件bug:同时发起两次添加商品请求,每次添加的数量大到仅单次请求就能售罄该商品(例如库存剩余3件,两次各添加3件)。此时两个请求均会增加购物车数量,但实际库存不足(示例中库存仅3件,购物车却累计到6件)。

请问如何防止此类场景或多用户环境下的竞态条件发生?是否存在锁机制可阻止add_to_cart()异步执行?

编辑:我已从SQLite切换至PostgreSQL,用于测试select_for_update()


核心模型代码(core/models.py)

class Product(models.Model):
    …
    number_in_stock = models.IntegerField(default=0)
    
    @property
    def number_on_hold(self):
        result = 0
        for s in Session.objects.all():
            amount = s.get_decoded().get('cart_content', {}).get(str(self.pk))
            if amount is not None:
                result += int(amount)
        return result
    …

购物车视图代码(cart/views.py)

def add_to_cart(request):
    if (request.method == "POST"):
        pk = request.POST.get('pk', None)
        amount = int(request.POST.get('amount', 0))
        if pk and amount:
            p = Product.objects.get(pk=pk)
            if amount > p.number_in_stock - p.number_on_hold:
                return HttpResponse('1')
            if not request.session.get('cart_content', None):
                request.session['cart_content'] = {}
            if request.session['cart_content'].get(pk, None):
                request.session['cart_content'][pk] += amount
            else:
                request.session['cart_content'][pk] = amount
            request.session.modified = True
            return HttpResponse('0')
    return HttpResponse('', status=404)

购物车URL配置(cart/urls.py)

urlpatterns = [
    …
    path("add-to-cart", views.add_to_cart, name="cart-add-to-cart"),
    …
]

前端模板代码(cart/templates/cart/html/atoms/add_to_cart.html)

<div class="form-element add-to-cart-amount">
    <label for="addToCartAmount{{ product_pk }}"> {% translate "Amount" %} : </label>
    <input type="number" id="addToCartAmount{{ product_pk }}" />
</div>
<div class="form-element add-to-cart">
    <button class="btn btn-primary button-add-to-cart" data-product-pk="{{ product_pk }}" data-href="{% url 'cart-add-to-cart' %}"><span> {% translate "Add to cart" %} </span></button>
</div>

前端JS代码(cart/static/cart/js/main.js)

$(".button-add-to-cart").click(function(event) {
    event.preventDefault();
    let product_pk = $(this).data("productPk");
    let amount = $(this).parent().parent().find("#addToCartAmount" + product_pk).val();
    $.ajax({
        url: $(this).data("href"),
        method: 'POST',
        data: {
            pk: product_pk,
            amount: amount
        },
        success: function(result) {
            switch (result) {
                case '1':
                    alert(gettext('Amount exceeded'));
                    break;
                case '0':
                    alert(interpolate(gettext('Successfully added %s items to the cart.'), [amount]))
                    break;
                default:
                    alert(gettext('Unknown error'));
            }
        }
    });
});

竞态条件复现代码

async function test() {
    document.getElementsByClassName('button-add-to-cart')[0].click();
}

test(); test();

解决方案

1. 数据库层面加行锁(基于PostgreSQL的select_for_update())

当前number_on_hold遍历所有Session的方式低效且无原子性保障,需结合事务和行锁控制并发:

from django.db import transaction

def add_to_cart(request):
    if request.method == "POST":
        pk = request.POST.get('pk')
        amount = int(request.POST.get('amount', 0))
        if not (pk and amount > 0):
            return HttpResponse('', status=404)
        
        # 开启原子事务,锁定当前商品行
        with transaction.atomic():
            p = Product.objects.select_for_update().get(pk=pk)
            
            # 计算当前用户购物车中该商品的数量
            current_cart_amount = request.session.get('cart_content', {}).get(pk, 0)
            # 计算全平台该商品的总持有量
            total_on_hold = 0
            for s in Session.objects.all():
                cart = s.get_decoded().get('cart_content', {})
                total_on_hold += cart.get(str(p.pk), 0)
            
            # 实际可购买数量 = 库存 - 全平台持有量 + 当前用户已持有量
            available = p.number_in_stock - total_on_hold + current_cart_amount
            if amount > available:
                return HttpResponse('1')
            
            # 更新购物车
            cart_content = request.session.get('cart_content', {})
            cart_content[pk] = cart_content.get(pk, 0) + amount
            request.session['cart_content'] = cart_content
            request.session.modified = True
            
            return HttpResponse('0')
    return HttpResponse('', status=404)

2. 重构购物车存储(彻底解决Session遍历问题)

用独立数据库表存储购物车项,替代Session字典,提升性能与原子性:

# core/models.py
class CartItem(models.Model):
    session_key = models.CharField(max_length=40)
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    quantity = models.IntegerField(default=1)
    
    class Meta:
        unique_together = ('session_key', 'product')

修改商品模型的number_on_hold属性:

# core/models.py
from django.db.models import Sum

class Product(models.Model):
    …
    number_in_stock = models.IntegerField(default=0)
    
    @property
    def number_on_hold(self):
        return CartItem.objects.filter(product=self).aggregate(total=Sum('quantity'))['total'] or 0
    …

更新购物车视图:

# cart/views.py
from django.db import transaction

def add_to_cart(request):
    if request.method == "POST":
        pk = request.POST.get('pk')
        amount = int(request.POST.get('amount', 0))
        if not (pk and amount > 0):
            return HttpResponse('', status=404)
        
        # 确保会话存在
        session_key = request.session.session_key
        if not session_key:
            request.session.create()
            session_key = request.session.session_key
        
        with transaction.atomic():
            p = Product.objects.select_for_update().get(pk=pk)
            total_on_hold = p.number_on_hold
            
            # 获取当前用户已持有数量,重新计算可用库存
            current_quantity = CartItem.objects.filter(session_key=session_key, product=p).values_list('quantity', flat=True).first() or 0
            available = p.number_in_stock - total_on_hold + current_quantity
            
            if amount > available:
                return HttpResponse('1')
            
            # 更新或创建购物车项
            cart_item, created = CartItem.objects.get_or_create(session_key=session_key, product=p)
            cart_item.quantity += amount
            cart_item.save()
            
            return HttpResponse('0')
    return HttpResponse('', status=404)

3. 前端防重复提交

点击按钮后立即禁用,直到请求完成,减少无效并发:

$(".button-add-to-cart").click(function(event) {
    event.preventDefault();
    const $button = $(this);
    // 禁用按钮防止重复触发
    $button.prop('disabled', true);
    
    let product_pk = $button.data("productPk");
    let amount = $button.parent().parent().find("#addToCartAmount" + product_pk).val();
    $.ajax({
        url: $button.data("href"),
        method: 'POST',
        data: {
            pk: product_pk,
            amount: amount
        },
        success: function(result) {
            switch (result) {
                case '1':
                    alert(gettext('Amount exceeded'));
                    break;
                case '0':
                    alert(interpolate(gettext('Successfully added %s items to the cart.'), [amount]))
                    break;
                default:
                    alert(gettext('Unknown error'));
            }
        },
        complete: function() {
            // 请求结束后恢复按钮状态
            $button.prop('disabled', false);
        }
    });
});

关键说明

  • 数据库行锁是核心:select_for_update()会锁定商品行,直到事务结束,确保同一时间只有一个请求能操作该商品的库存关联数据。
  • 独立购物车表是基础:替代Session存储后,number_on_hold计算无需遍历所有会话,性能和原子性大幅提升。
  • 前端防重复是辅助:减少不必要的并发请求,但不能替代后端的原子性控制,必须配合使用。

内容的提问来源于stack exchange,提问作者Zatigem

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.04 07:15:57