
本文介绍通过数据库级悲观锁(select_for_update())结合事务控制,安全地实现多请求并发添加商品到购物车,避免库存超卖和会话数据不一致问题。
本文介绍通过数据库级悲观锁(`select_for_update()`)结合事务控制,安全地实现多请求并发添加商品到购物车,避免库存超卖和会话数据不一致问题。
在电商场景中,当多个请求几乎同时向用户购物车添加同一商品时,极易触发竞态条件:两个请求各自读取当前购物车状态(如 cart_content = {'123': 2}),各自计算并写入 {'123': 5},最终导致重复累加、库存校验失效(例如仅剩 3 件却加入 6 件)。根本原因在于——会话数据(request.session)本身不提供并发保护,且 number_on_hold 的计算逻辑(遍历所有 Session)既非原子、也不可扩展。
✅ 正确解法:数据库事务 + 悲观锁
Django 的 select_for_update() 是解决此类问题的首选方案,它在 PostgreSQL/MySQL 等支持行级锁的数据库中,对指定记录加排他锁,阻塞其他并发事务直到当前事务提交或回滚。关键点如下:
-
必须在数据库事务中使用(
@transaction.atomic); -
锁定对象需具备数据库持久化能力:Session 模型虽可锁,但更推荐锁定
User(若已登录)或独立的Cart模型(最佳实践); -
避免锁全局资源(如
Session.objects.all()),否则严重降低吞吐量。
以下是重构后的安全实现(基于已切换至 PostgreSQL 的前提):
# cart/views.py
from django.db import transaction
from django.contrib.sessions.models import Session
from django.contrib.auth.models import User
from django.http import HttpResponse
from core.models import Product
@transaction.atomic
def handle_add_to_cart(request, product_id: int, amount: int) -> HttpResponse:
# 1. 锁定当前用户关联的 Session(或更优:锁定 User 实例)
# 注意:确保 SESSION_ENGINE 使用 database-backed backend(如 'django.contrib.sessions.backends.db')
try:
# 方案 A:锁 Session(适用于未登录用户场景)
session = Session.objects.select_for_update().get(session_key=request.session.session_key)
# 方案 B(推荐):若用户已登录,锁 User(更语义清晰、性能更优)
# user = request.user
# if user.is_authenticated:
# user = User.objects.select_for_update().get(pk=user.pk)
except Session.DoesNotExist:
return HttpResponse('0', status=400) # 会话无效
# 2. 重新获取 Product(确保在锁作用域内读取最新库存)
product = Product.objects.select_for_update().get(pk=product_id)
# 3. 原子化校验:实时计算“已被本用户暂扣”的数量(不再依赖不可靠的 number_on_hold)
# → 改为直接从当前 session.cart_content 中解析(更准确、无竞态)
cart_data = request.session.get('cart_content', {})
current_hold = int(cart_data.get(str(product_id), 0))
# 校验:新增量 + 当前暂扣量 ≤ 总库存
if amount + current_hold > product.number_in_stock:
return HttpResponse('1') # 库存不足
# 4. 安全更新会话
if 'cart_content' not in request.session:
request.session['cart_content'] = {}
cart = request.session['cart_content']
cart[str(product_id)] = str(current_hold + amount) # 统一转为字符串,避免类型混用
request.session.modified = True
return HttpResponse('0')
def add_to_cart(request):
if request.method == "POST":
pk = request.POST.get('pk')
amount_str = request.POST.get('amount')
if pk and amount_str.isdigit():
try:
return handle_add_to_cart(request, int(pk), int(amount_str))
except (Product.DoesNotExist, ValueError):
pass
return HttpResponse('', status=404)⚠️ 重要注意事项
-
数据库要求:
select_for_update()仅在 PostgreSQL、MySQL(InnoDB)、Oracle 等支持行锁的后端有效;SQLite 不支持(这也是你切换数据库的正确决策)。 -
避免 N+1 查询陷阱:原
number_on_hold属性遍历全部 Session 是严重性能瓶颈且无法加锁,应彻底弃用。改为在单次事务中从当前会话提取数据,既准确又高效。 -
锁粒度选择:
- 锁
Session可覆盖游客用户,但高并发下可能成为瓶颈; - 锁
User更合理(已登录用户占比高),且天然隔离不同用户; -
终极方案:引入专用
Cart模型(外键关联 User/Session),对Cart记录加锁,彻底解耦会话与业务逻辑。
- 锁
- 前端防重复提交:JavaScript 层需禁用按钮、添加 loading 状态,作为用户体验层防护(非替代后端锁)。
✅ 总结
防止购物车竞态的核心是 将“读-校验-写”三步收缩至单个数据库事务,并用 select_for_update() 锁定共享资源。放弃对内存态会话或跨会话聚合计算(如 number_on_hold)的依赖,转而信任事务内数据库的一致性视图。此方案兼顾正确性、可维护性与生产环境可扩展性。


















