
摘要商城的订单Day10和支付Day11都搞定了今天上促销三板斧——优惠券、秒杀、拼团。优惠券搞定生成/领取/核销/过期的完整生命周期秒杀用 Redis 预减库存加异步下单扛住高并发拼团处理成团、失败、超时的状态流转。三套系统三种不同的并发挑战一篇讲透。代码全部可运行配套文件day12_promotion.py。为什么电商一定要做促销你打开任何一个电商 App首页最显眼的位置永远不是商品列表——而是各种促销入口。“限时秒杀”、“新人优惠券”、“3 人拼团享低价”……为什么因为促销是电商增长的核心武器。没有促销的电商就像没有打折季的商场用户进来逛逛就走了不会掏钱。优惠券降低决策门槛。用户本来犹豫要不要买一看有张 20 块的券“用掉算了”——成交。秒杀制造紧迫感。“还剩 3 件”、“倒计时 02:15”——人类对错过的恐惧远大于对得到的渴望。拼团社交裂变。一个人买 99 块三个人拼团 69 块——你不去拉人别人就替你拉了。每个用户都变成了你的免费推广员。但问题来了——这三个功能每一个都是技术深坑。优惠券要处理时间窗口、叠加规则、防止超领秒杀要扛住瞬时高并发、防止超卖、保证数据一致性拼团要处理成团逻辑、超时退款、并发参团。任何一个没做好都是线上事故。我在做商城的时候促销模块返工了四次。不是功能做不出来是边界情况太多——你以为处理完了测试一跑又冒出个新 bug。今天把这三把刀的核心逻辑和踩过的坑一次性讲清楚。原理速览优惠券一张券的一生优惠券的生命周期看起来很简单生成 → 领取 → 核销使用→ 归档 ↓ 过期没用就自动过期了听起来没什么难度对吧但细节是魔鬼。领取要防超领。券只发了 1000 张结果领了 1001 张——财务月底对不上账整个团队加班排查。核销要校验的东西太多了——时间窗口对不对使用门槛够不够这张券是不是已经被用过了订单金额扣掉优惠后是不是变成负数了过期用户领了不用怎么办系统要自动标记过期不然用户拿着过期券来下单前端显示已优惠后端计算发现券过期了金额对不上——又是一场事故。核心思路每张券都是独立实例有自己独立的状态流转。不是数据库里改个字段就完事的。秒杀在洪峰中活下来秒杀的技术挑战和其他模块完全不在一个量级。平时你的系统可能每秒处理 100 个请求数据库轻松应对。但秒杀开始的瞬间——比如早上 10:00 整——可能有 10000 个请求同时涌入抢 10 件商品。如果用常规方式直接操作数据库扣库存10000 个 UPDATE 同时打到同一行数据 → 数据库行锁争抢 → 请求排队等待 → 连接池耗尽 → 超时 → 整个系统被拖垮 → 不仅秒杀挂了连正常购物也挂了这就是所谓的秒杀效应——一个功能拖垮整个系统。所以秒杀的设计原则只有一个把压力挡在数据库之外。三层防线层级手段作用第一层限流过滤掉重复请求和恶意脚本减轻后端压力第二层Redis 预减库存用内存扛并发单线程原子操作QPS 轻松 10 万第三层异步下单削峰填谷保护数据库不被瞬时流量击穿拼团一场限时社交实验拼团本质上是一个多人协作加时间约束的状态机开团 → 成团中等待更多人加入 ├── 人数达标 → 成团成功 → 发货 ├── 超时了 → 成团失败 → 退款 └── 人数满了但还不够 → 继续等直到超时关键问题有三个并发参团两个人同时点参团但团只能再进 1 个人谁进谁被拒必须用锁。超时处理怎么知道一个团什么时候超时不能靠用户触发必须有后台定时任务主动扫描。失败退款团散了已经付了钱的人怎么退要关联 Day11 的支付退款接口。动手优惠券先定义三种券类型满减券、折扣券、无门槛券。每种券计算逻辑不一样但状态流转一样。模型核心字段classCoupon(models.Model):优惠券模型定义券的规则和有效期COUPON_TYPE_CHOICES[(fixed,满减券),# 满 100 减 20(discount,折扣券),# 满 200 打 85 折(no_threshold,无门槛券),# 直接减 5 块]namemodels.CharField(max_length100)coupon_typemodels.CharField(max_length20,choicesCOUPON_TYPE_CHOICES)thresholdmodels.DecimalField(max_digits10,decimal_places2,default0)discount_valuemodels.DecimalField(max_digits10,decimal_places2)total_countmodels.IntegerField()# 发行总量remaining_countmodels.IntegerField()# 剩余可领数量valid_startmodels.DateTimeField()# 有效期开始valid_endmodels.DateTimeField()# 有效期结束用户领券用乐观锁防超领——原理和 Day10 的库存扣减一模一样核心逻辑defclaim_coupon(user_id,coupon_id):用户领取优惠券——乐观锁防超领withtransaction.atomic():# 原子操作remaining_count 0 才减 1领完则 affected 0affectedCoupon.objects.filter(idcoupon_id,remaining_count__gt0,).update(remaining_countF(remaining_count)-1)ifaffected0:raiseValidationError(优惠券已被领完)# 创建用户券实例——注意这是独立的一张券returnUserCoupon.objects.create(user_iduser_id,coupon_idcoupon_id,statusunused)核销是优惠券系统最复杂的部分。一张券从领了到用了要过五关斩六将核心框架第 1 关行锁锁定select_for_update—— 防止同一张券被并发用两次 第 2 关状态校验 —— 只有 unused 才能用 第 3 关时间窗口 —— 在有效期内吗过期了顺手标记 expired 防脏数据 第 4 关计算实际优惠 —— 满足使用门槛吗discount 0 则拒绝 第 5 关标记 used 绑定订单 —— 返回 discount优惠金额的计算逻辑三种券各有算法defcalculate_discount(self,order_amount):ifself.coupon_typefixed:# 满减券够门槛才减returnself.discount_valueiforder_amountself.thresholdelse0elifself.coupon_typediscount:# 折扣券够门槛打折iforder_amountself.threshold:returnround(order_amount*(1-self.discount_value),2)return0else:# 无门槛券直接减但不超过订单金额returnmin(self.discount_value,order_amount)别忘了过期处理。用户领了 100 张券99 张没用不能一直挂着未使用。写个定时任务每天扫一遍defexpire_coupons_batch():批量过期Celery Beat 定时任务建议每天凌晨跑一次nowtimezone.now()returnUserCoupon.objects.filter(statusunused,coupon__valid_end__ltnow).update(statusexpired)优惠券系统的核心教训每张券都是独立实例有自己的生命周期。不要想着用一张模板券加个数量字段就完事——核销、退款、对账都会出问题。领出去 1000 张就得有 1000 条独立记录。动手秒杀秒杀是整个电商系统里并发压力最大的场景。设计思路四步。第一步库存预热活动开始前 5-10 分钟把库存从数据库加载到 Redis。为什么因为秒杀开始时不能有数据库查询——每一毫秒都珍贵。核心就一句SET flash_sale:{id}:stock 剩余库存带过期时间。第二步限流——挡住恶意请求总有人写脚本刷接口。同一用户 60 秒内最多 3 次尝试rate_limit_keyfflash_sale:{flash_sale_id}:user:{user_id}:countcurrent_countself.redis.get(rate_limit_key)ifcurrent_countandint(current_count)3:return{success:False,message:操作过于频繁请稍后再试}# 用 pipeline 保证原子性计数 设置过期时间pipeself.redis.pipeline()pipe.incr(rate_limit_key)pipe.expire(rate_limit_key,60)pipe.execute()第三步Redis 原子预减库存这是秒杀最核心的操作。用DECR命令——它是原子的单线程执行天然不会并发冲突stock_keyfflash_sale:{flash_sale_id}:stockremainingself.redis.decr(stock_key)# 原子扣减返回扣减后的值ifremaining0:self.redis.incr(stock_key)# 库存不够把多扣的 1 个加回去return{success:False,message:已售罄}为什么用DECR而不是先读再减因为 Redis 虽然是单线程但如果你分成两步——先GET读到库存是 1然后准备SET为 0——在这两步之间另一个请求也GET到了 1也准备扣减。结果两个请求都认为自己买到了最后一件。这就是经典的竞态条件。而DECR是一条命令Redis 保证原子性。10000 个DECR排着队执行每个都安全。第四步异步下单预减库存成功不代表订单创建成功。数据库操作太慢不能同步执行。把任务丢进异步队列让后台 Worker 慢慢处理预减成功 → 创建排队记录statusqueued → 放入异步队列实际项目用 Celery.send_task → 立即返回前端抢购成功正在排队处理后台 Worker 处理队列时数据库层面要再次校验库存秒杀场景必须用锁Redis 和 DB 之间可能不一致——比如 Redis 重启了、网络抖动了、Worker 崩了。Redis 预减成功了数据库还得再验一次。秒杀的核心教训不要信任任何单一环节。Redis 预减 数据库二次校验双层才能万无一失。用户那边看到的是排队处理中最终结果是成功或失败体验流畅。动手拼团拼团的难点不在技术复杂度在于边界情况特别多。你每加一个功能就多三种异常场景。先定义拼团实例的状态机forming成团中 → success成团成功→ 发货 forming成团中 → failed超时/失败→ 退款开团团长发起一个新团自动成为第一个成员。校验活动在有效期内事务里创建GroupBuyInstancestatus‘forming’current_members1 创建团长成员记录。参团——最容易出 bug 的地方。核心框架defjoin_group(self,user_id,group_id):withtransaction.atomic():# 行锁锁定防止两个人同时参团导致人数计算错误groupGroupBuyInstance.objects.select_for_update().get(idgroup_id)# 校验 1团的状态只有 forming 才能加人# 校验 2是否超时超时则标记 failed自动关闭# 校验 3人数是否已满# 校验 4是否重复参团# 加入 人数加 1用 F 表达式GroupBuyMember.objects.create(groupgroup,user_iduser_id)group.current_membersF(current_members)1group.save()# 关键refresh_from_db 获取数据库真实值group.refresh_from_db()ifgroup.current_membersgroup.activity.min_members:group.statussuccessgroup.save()这里有个大坑——refresh_from_db()那行。F(current_members) 1在 Python 层面并没有改变current_members的值它只是生成一条 SQL 表达式current_members current_members 1。所以save()之后Python 对象里的值还是旧的。如果你不做refresh_from_db()直接拿group.current_members判断是否成团——3 个人同时参团3 个人读到的都是旧值3 个人都觉得差一个人就成团了结果团的实际人数已经够了但没人触发成团逻辑。超时处理——定时任务扫描defcheck_and_close_expired_groups(self):定时任务扫描超时未成团的拼团建议每 5 分钟执行一次nowtimezone.now()expired_groupsGroupBuyInstance.objects.filter(statusforming,expire_at__ltnow)forgroupinexpired_groups:withtransaction.atomic():# 双重校验防止查询和执行之间被其他进程处理了freshGroupBuyInstance.objects.select_for_update().get(idgroup.id)iffresh.statusforming:fresh.statusfailedfresh.save()# TODO: 关联 Day11 的退款接口给所有参团成员退款拼团的核心教训永远不要信任内存中的状态。在分布式环境下你内存里读到的值可能 0.1 秒前就已经被别的请求改了。select_for_update()加锁、refresh_from_db()刷新——这两个操作在并发场景下就是生命线。用 AI 设计促销规则的状态转换三个系统里拼团的状态转换最复杂。成团中 → 成功触发发货成团中 → 失败触发退款。这些是基础流程。但现实的边界情况远不止这些团长在成团中退出怎么处理整个团解散还是换团长团员在成团中退出人数不够了团还能继续吗成团成功后有人申请退款会不会影响其他团员活动结束了但团还没满是等它自然超时还是立刻关闭这些规则从零设计光画状态转换图就得一下午还不一定能想全。我实际用 AI 试了一下prompt 是这样的你是一个电商系统架构师。请为拼团系统设计完整的状态转换规则。 基本状态forming成团中、success成团成功、failed成团失败 需要考虑的边界情况 1. 团长在成团中退出怎么处理 2. 团员在成团中退出怎么处理 3. 成团成功后有人申请退款怎么处理 4. 活动结束但团还没满怎么处理 5. 系统并发导致的人数异常怎么处理 输出完整的状态转换图 每个转换的触发条件和副作用 异常降级策略AI 给出了一个很完整的方案其中有一个我完全没想到的设计成团成功后的部分退款——增加success_partial_refund子状态团还是成功的其他团员正常发货只有退款那个人走退款流程。这个思路确实很好。但我最终还是没用——因为商城的业务规则是成团后不支持单个退款只能整团退。这就是 AI 辅助的边界AI 能帮你穷举可能性但最终的业务决策必须你来做。AI 加速心得状态转换设计特别适合让 AI 穷举边界情况。人类思维天然容易遗漏如果 A 和 B 同时发生怎么办这种组合场景但 AI 不会。把 AI 当成边界情况检查器——用它查漏补缺而不是照搬方案。它给 10 个方案你选 3 个最合适的效率就很高了。学完来练练手优惠券叠加支持多张券叠加使用但有优先级先算满减再算折扣。注意叠加后总金额不能为负数。秒杀预热倒计时给秒杀接口加预热期——活动开始前 5 分钟用户访问返回倒计时信息同时自动预热 Redis 库存。拼团模拟退团成团中允许团员退出退出后current_members - 1。如果团长退出把团长身份转移给最早参团的团员。秒杀实时监控用 Redis 的GET读取剩余库存写个接口返回已售 X 件剩余 Y 件前端做成进度条。配套文件day12_promotion.py优惠券 秒杀 拼团完整实现可参考学习。本系列为梅雅达编程笔记原创首发于 CSDN。有问题评论区见