摘要:购物车功能,看起来不就是"加个商品、改个数量、删掉不要"嘛?三张表、五个接口,半天搞定。但真正写起来你会发现:游客没登录怎么存购物车?登录后怎么跟数据库合并?并发加入购物车会不会丢数据?价格变了按哪个算?Redis 和数据库怎么配合才不会数据不一致?今天从数据模型设计到 Redis 缓存,从游客合并到并发安全,一步步拆解购物车的每一个坑。成本:0。
购物车谁没写过?但你考虑过这些情况吗
说实话,我见过太多人觉得购物车"很简单"了。
“不就是增删改查嘛,写个 Model,搞个 ViewSet,完事了。”
然后面试的时候被问到以下问题:
- 用户没登录就往购物车里加了东西,怎么办?
- 用户加完购物车才登录,游客购物车怎么合并到账号里?
- 两个人同时买最后一件商品,购物车怎么防超卖?
- 商品价格变了,购物车里还按旧价格显示吗?
- 购物车频繁读写,每次都查数据库,扛得住吗?
是不是突然觉得没那么简单了?
先说结论:购物车的核心挑战不在 CRUD,而在三个边界问题——游客态、缓存一致性、并发安全。把这三个问题想清楚了,代码自然就写出来了。
我举一个真实踩过的坑。有一次给客户做电商项目,购物车就是简单的"存数据库、查数据库"。上线第一周没事,第二周开始搞大促,购物车接口响应时间直接从 50ms 飙到 2 秒。一查,全是购物车列表查询把数据库连接池打满了。后来加了 Redis 缓存,响应时间立刻回到 30ms 以内。这就是为什么购物车不能"简单写"——你对性能的要求,比你想象的高得多。
先想清楚两个核心设计决策
购物车数据存哪?
第一个关键问题:存数据库还是 Redis?
答案是:都要用,但分工不同。
┌─────────────────────────────────────────────────┐ │ 购物车数据分层存储 │ ├─────────────────────────────────────────────────┤ │ Redis(热数据) MySQL(冷数据) │ │ ┌────────────────┐ ┌────────────────┐ │ │ │ 已登录用户购物车 │ │ 游客购物车备份 │ │ │ │ 高频读写 │ │ 数据持久化 │ │ │ │ 会话级缓存 │ │ 订单归档依据 │ │ │ └────────────────┘ └────────────────┘ │ │ ↕ 读写穿透 ↕ 异步落库 │ └─────────────────────────────────────────────────┘为什么用 Redis 而不是直接写数据库?
三个字:快、快、快。
购物车这个场景有个特点——读多写多,但"有效性"短。用户打开购物车页面,每 3 秒刷新一次价格,这种频率如果每次都打 MySQL,数据库早就扛不住了。而 Redis 的单线程模型 + 内存操作,读写延迟在微秒级,天然适合。
但 Redis 不是万能的——它是内存数据库,数据随时可能丢失。所以核心商品数据还是得落 MySQL,Redis 只是作为缓存层。
游客购物车怎么办?
用户没登录就往购物车加东西,这在电商里太常见了。
我们的方案:用 session_key 标识游客购物车,登录后合并到用户账号。
游客状态: session_key = "abc123xyz" → Redis key: cart:session:abc123xyz ↓ 用户登录 已登录状态:user_id = 42 → Redis key: cart:user:42 ↓ 合并操作 合并完成: 游客购物车商品 → 合并到 → 用户购物车合并的时候还有讲究——如果游客购物车和登录账号里都有同一个商品,数量要累加而不是覆盖。这个细节后面代码里会体现。
动手:从数据模型到 Redis 缓存
数据模型设计
购物车模型看起来简单,但字段设计有讲究。核心设计要点(关键片段):
classCartItem(models.Model):""" 设计要点: 1. user 可为空 —— 游客购物车不关联用户 2. session_key —— 游客购物车的标识 3. 唯一约束 —— 同一用户/session 下同一商品只能有一行 4. 不存价格!价格从商品表实时读取,避免数据不一致 """user=models.ForeignKey('auth.User',on_delete=models.CASCADE,null=True,blank=True,related_name='cart_items')session_key=models.CharField(max_length=64,blank=True,default='',db_index=True)goods=models.ForeignKey('goods.Goods',on_delete=models.CASCADE)quantity=models.PositiveIntegerField(default=1,validators=[MinValueValidator(1)])selected=models.BooleanField(default=True)# 结算时只算选中的商品classMeta:constraints=[# 登录用户:同一商品一行models.UniqueConstraint(fields=['user','goods'],condition=models.Q(user__isnull=False),name='unique_user_goods'),# 游客:同一 session 同一商品一行models.UniqueConstraint(fields=['session_key','goods'],condition=models.Q(user__isnull=True),name='unique_session_goods'),]几个坑:
不存价格。商品价格会随时变化(促销、调价),如果购物车里存一份价格,就可能出现"购物车显示 99,结算变成 199"的尴尬。价格永远从商品表实时读取。
unique_together不够用。游客购物车 user 是 null,Django 的unique_together对 null 值不生效(SQL 标准中 NULL ≠ NULL)。所以改用UniqueConstraint+condition分别约束登录用户和游客。selected 字段。用户可以在购物车里勾选/取消勾选商品,这个状态要持久化。
Redis 缓存层
这是购物车性能的关键。核心思路(关键片段):
# Key 前缀规范CART_USER_PREFIX='cart:user:'# 已登录用户: cart:user:{user_id}CART_SESSION_PREFIX='cart:session:'# 游客购物车: cart:session:{session_key}CART_EXPIRE_SECONDS=30*24*3600# 30 天过期def_get_cart_key(user=None,session_key=None):"""登录用户用 user_id,游客用 session_key"""ifuseranduser.is_authenticated:returnf'{CART_USER_PREFIX}{user.id}'elifsession_key:returnf'{CART_SESSION_PREFIX}{session_key}'raiseValueError('必须提供 user 或 session_key')为什么用 Hash 而不是 String?
购物车的数据结构是{goods_id: {quantity, selected}},如果用 String,每次修改一个商品就得把整个购物车序列化/反序列化。用 Hash 的话,HSET cart:user:42 1001 '{"quantity": 2, "selected": true}'就能单独修改商品 1001,其他商品完全不受影响。
性能对比:
- String 结构:修改 1 个商品 → 读取全部 → 修改 → 写回全部(O(N))
- Hash 结构:修改 1 个商品 → 直接写(O(1))
购物车里如果有 50 个商品,Hash 的性能优势非常明显。
还有一个细节——过期时间。设了 30 天过期,不是永久。大部分用户加了购物车不会立刻买,但也不可能永远留着。30 天覆盖大多数用户的购买决策周期,又不让 Redis 堆僵尸数据。快消品可缩到 7 天,大件家电可延到 90 天。
游客合并:最容易出 Bug 的地方
merge_cart的合并逻辑,看着简单,其实是购物车模块最容易出 Bug 的地方。核心逻辑(关键片段):
defmerge_cart(session_key,user):"""将游客购物车合并到登录用户的购物车"""session_cart=get_cart_from_redis(session_key=session_key)user_cart=get_cart_from_redis(user=user)forgoods_id,item_datainsession_cart.items():ifgoods_idinuser_cart:# 已有该商品,数量累加(不是覆盖!)new_quantity=user_cart[goods_id]['quantity']+item_data['quantity']update_quantity_in_redis(user=user,goods_id=goods_id,quantity=new_quantity)else:add_to_redis(user=user,goods_id=goods_id,quantity=item_data['quantity'])# 合并完成,清空游客购物车redis_client.delete(f'{CART_SESSION_PREFIX}{session_key}')最常见的三种 Bug:
Bug 1:覆盖而不是累加。游客购物车有 3 件商品 A,登录后账号里已经有 2 件商品 A。如果直接覆盖,用户就丢了 3 件——用户肯定骂娘。正确做法是累加成 5 件。
Bug 2:合并后没清游客购物车。合并完了游客购物车还在。下次用户用同一个浏览器(session 还没过期),又合并一次——商品数量翻倍了。
Bug 3:合并时没校验库存。游客加购物车时有 100 件库存,登录时只剩 3 件了。如果无脑合并 50 件进去,后面下单肯定出问题。所以合并后需要标记一下"部分商品库存不足",让用户自己决定。
DRF 接口层
数据模型和缓存层搞定了,写 API。以加购接口为例,核心流程(关键片段):
@api_view(['POST'])@permission_classes([AllowAny])# 游客也能加购物车defcart_add(request):goods_id=request.data.get('goods_id')quantity=request.data.get('quantity',1)# ① 校验商品存在且在售try:goods=Goods.objects.get(id=goods_id,is_on_sale=True)exceptGoods.DoesNotExist:returnResponse({'error':'商品不存在或已下架'},status=404)# ② 库存预检(只检查,不锁定——真正扣库存留给订单系统)ifgoods.stock<quantity:returnResponse({'error':f'库存不足,当前库存{goods.stock}'},status=400)# ③ 确定购物车归属(登录用户 or 游客)user=request.user session_key=request.session.session_keyifnotsession_key:request.session.create()session_key=request.session.session_key# ④ 写入 Redis:已存在则数量累加,且再校验一次库存# ⑤ 异步同步到数据库(生产用 Celery 异步,这里简化为同步)returnResponse({'message':'已添加到购物车'},status=201)cart_list的关键点:价格从数据库实时读取,不用缓存中的旧价格,批量filter(id__in=...)查商品避免 N+1,下架商品跳过。
关于数据一致性——“Redis 缓存 + MySQL 持久化"必然面对两边不一致的问题。答案不复杂:以 Redis 为准,MySQL 做兜底。购物车是"临时状态”,最终要变成订单才算数。加购、改数量这些高频操作只写 Redis(快),再异步同步 MySQL(稳)。Redis 挂了,MySQL 还有上次同步的数据兜底。但异步窗口期内 MySQL 写入失败要有重试——生产环境用 Celery + Redis 队列,失败自动重试三次,三次都失败写告警日志人工介入。
用 AI 生成购物车的单元测试
购物车模块写完了,接下来写单元测试。这部分强烈建议用 AI 辅助——购物车测试用例比较模式化(每个接口都有正常流程和若干异常分支),人工写容易遗漏,AI 特别擅长"给定规则,穷举分支"。
给 AI 的 Prompt:
请为购物车模块生成单元测试,覆盖以下场景: 1. cart_add: - 正常添加商品 - 商品不存在 - 库存不足 - 重复添加(数量应累加) - 游客添加(无登录态) 2. cart_update: - 正常修改数量 - 数量小于 1 - 超出库存 - 商品不在购物车中 3. cart_remove: - 正常删除 - 删除不存在的商品(应幂等) 4. cart_merge: - 正常合并 - 重复商品数量累加 - 合并后游客购物车清空 - 未登录调用(应 403) 使用 pytest + Django test client + mock RedisAI 会直接生成完整测试文件,几个好处:
覆盖全面。Prompt 里列的场景 AI 一个不落。手动写大概率会漏掉"删除不存在的商品应幂等"这种边界情况。
Mock 合理。AI 自动识别要 Mock Redis,还用
side_effect模拟多次调用返回不同数据。这个细节很多人写测试会忽略——直接连真实 Redis,导致测试依赖外部服务,CI 跑起来经常挂。可以迭代。AI 漏了场景就补一句"加上对下架商品的处理测试",不用从头写。通常两轮下来覆盖率能到 90% 以上。
省时间。手动写这套测试(fixture、mock、断言)大概 2-3 小时,AI 辅助从 Prompt 到调试通过 30 分钟。省下的时间拿去想业务边界条件,比写样板代码有价值得多。
AI 加速不是让 AI 帮你写全部业务代码,而是在测试、文档、样板代码这些高重复度、高规则性的环节发力。业务逻辑的判断、边界条件的设计,还是得靠你自己。AI 是放大器,不是替代品。
还有个小技巧:写 Prompt 时把你已确定的规则全部列出来,别指望 AI 自己猜。比如"游客不能调用合并接口"这种规则不写在 Prompt 里,AI 可能就不测这个场景。你的领域知识 + AI 的生成能力,才是最佳组合。
学完来练练手
全选/取消全选功能。加一个接口一键全选/取消全选。提示:在 Redis 中批量修改所有 item 的
selected字段。购物车商品数量上限。很多电商限制购物车最多 50 种商品,在
cart_add里加上校验。过期商品自动清理。已下架商品不该继续显示在购物车里。写个定时任务清理 Redis 中对应失效商品。
价格变动提醒。购物车商品降价了,在列表接口返回
price_dropped标记和原价字段。
配套文件day09_cart.py(Model + Redis 缓存层 + DRF 接口 + 单元测试)可参考学习。
本系列为梅雅达编程笔记原创,首发于 CSDN。有问题?评论区见