
本文介绍在 Django 电商场景中,通过数据库级悲观锁(select_for_update)结合事务,安全地处理多请求并发添加商品到购物车时的库存校验与会话更新,避免因竞态导致超卖。
本文介绍在 django 电商场景中,通过数据库级悲观锁(select_for_update)结合事务,安全地处理多请求并发添加商品到购物车时的库存校验与会话更新,避免因竞态导致超卖。
在构建基于会话(Session)的购物车系统时,一个典型且危险的竞态条件(race condition)场景是:两个并发请求同时向同一用户的购物车添加同一商品,而该商品库存仅剩 N 件——每个请求都成功读取“当前可售数量 = N”,各自执行 +N 操作,最终导致购物车中累计 N+N 件,远超实际库存。您已复现此问题,并正确迁移到 PostgreSQL 以支持行级锁,这是解决该问题的关键前提。
核心思路是:将库存检查与购物车更新逻辑置于原子事务中,并对参与校验的共享状态(如用户会话或用户账户)加数据库行锁,确保同一用户的所有并发请求串行化执行。
✅ 正确做法:使用 select_for_update() + @transaction.atomic
Django 的 select_for_update() 在 PostgreSQL(及 MySQL InnoDB)上会获取行级写锁,阻塞其他事务对同一行的 SELECT ... FOR UPDATE 或写操作,直到当前事务提交或回滚。由于您的购物车数据存储在 request.session 中,而 Django 默认的数据库后端会话(django.contrib.sessions.backends.db)将 session 数据持久化到 django_session 表,因此我们可以锁定当前用户的 session 记录:
from django.db import transaction
from django.contrib.sessions.models import Session
from django.http import HttpResponse
from core.models import Product
@transaction.atomic
def handle_add_to_cart(request, product_id, amount):
# 1. 获取商品(只读,不加锁)
product = Product.objects.get(pk=product_id)
# 2. 关键:锁定当前用户的 session 记录(串行化该用户的全部购物车操作)
session = Session.objects.select_for_update().get(session_key=request.session.session_key)
# 3. 重新计算「已被本用户会话占用的数量」(即 number_on_hold)
# 注意:必须在锁内重新读取 session 数据,确保一致性
cart_data = session.get_decoded().get('cart_content', {})
held_by_this_user = int(cart_data.get(str(product_id), 0))
# 4. 校验库存(实时、准确)
if amount > product.number_in_stock - held_by_this_user:
return HttpResponse('1') # 库存不足
# 5. 更新会话数据(注意:修改的是 session 对象的 decoded 数据,非直接改 request.session)
cart_data[str(product_id)] = cart_data.get(str(product_id), 0) + amount
session.session_data = Session.get_session_store().encode(cart_data)
session.save() # 这会触发 UPDATE,且在锁保护下完成
return HttpResponse('0')⚠️ 重要说明:
- 不要直接操作
request.session并设request.session.modified = True—— 因为request.session是运行时副本,其底层session_key对应的数据库记录未被锁定,仍可能被其他请求并发修改。- 必须显式通过
Session.objects.select_for_update()获取并更新Session实例,才能保证锁生效且数据一致。number_on_hold属性原实现遍历所有 session 效率极低且非原子,应废弃;改为在锁内仅读取当前用户自己的 session,既高效又准确。
? 推荐优化:改用认证用户模型(更优实践)
若用户已登录(request.user.is_authenticated),强烈建议弃用 Session 锁,转而锁定 User 或专属 Cart 模型:
# 更健壮的设计:为每个用户创建 Cart 模型(推荐)
class Cart(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class CartItem(models.Model):
cart = models.ForeignKey(Cart, on_delete=models.CASCADE)
product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.PositiveIntegerField(default=1)此时加锁目标变为 Cart 实例:
cart = Cart.objects.select_for_update().get(user=request.user) # 后续校验与更新均在此锁保护下进行
优势:语义清晰、可扩展(支持订单预占、优惠券等)、避免 session 表成为性能瓶颈。
? 注意事项与总结
-
数据库要求:
select_for_update()仅在 PostgreSQL、MySQL InnoDB 等支持行锁的引擎中有效;SQLite 不支持,故您切换至 PostgreSQL 是必要且正确的决策。 -
锁粒度权衡:锁定
Session表虽简单,但高并发下易造成请求排队(降低吞吐)。生产环境应优先采用User或Cart模型加锁。 -
事务边界:务必用
@transaction.atomic包裹整个逻辑,确保锁与数据更新在同一事务中,否则锁会提前释放。 - 前端配合:JS 层应禁用重复点击(如按钮置灰 + loading 状态),作为用户体验层的辅助防护,但绝不能替代服务端锁机制。
通过以上方案,您不仅能彻底杜绝示例中的“双倍加购超卖”问题,还能构建出可扩展、可维护的购物车并发安全体系。


















