如何在会话操作中避免电商购物车的竞态条件问题?
问题描述
我正在开发一款具备购物车与商品功能的电商网站,商品主键会被存入用户会话数据的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
相关产品推荐
相关产品推荐

