Day 12 | 优惠券、秒杀与拼团:电商三把刀

摘要:商城的订单(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 块]name=models.CharField(max_length=100)coupon_type=models.CharField(max_length=20,choices=COUPON_TYPE_CHOICES)threshold=models.DecimalField(max_digits=10,decimal_places=2,default=0)discount_value=models.DecimalField(max_digits=10,decimal_places=2)total_count=models.IntegerField()# 发行总量remaining_count=models.IntegerField()# 剩余可领数量valid_start=models.DateTimeField()# 有效期开始valid_end=models.DateTimeField()# 有效期结束

用户领券,用乐观锁防超领——原理和 Day10 的库存扣减一模一样(核心逻辑):

defclaim_coupon(user_id,coupon_id):"""用户领取优惠券——乐观锁防超领"""withtransaction.atomic():# 原子操作:remaining_count > 0 才减 1,领完则 affected = 0affected=Coupon.objects.filter(id=coupon_id,remaining_count__gt=0,).update(remaining_count=F('remaining_count')-1)ifaffected==0:raiseValidationError('优惠券已被领完')# 创建用户券实例——注意,这是独立的一张券returnUserCoupon.objects.create(user_id=user_id,coupon_id=coupon_id,status='unused')

核销是优惠券系统最复杂的部分。一张券从"领了"到"用了",要过五关斩六将(核心框架):

第 1 关:行锁锁定(select_for_update)—— 防止同一张券被并发用两次 第 2 关:状态校验 —— 只有 unused 才能用 第 3 关:时间窗口 —— 在有效期内吗?过期了顺手标记 expired 防脏数据 第 4 关:计算实际优惠 —— 满足使用门槛吗?discount <= 0 则拒绝 第 5 关:标记 used + 绑定订单 —— 返回 discount

优惠金额的计算逻辑,三种券各有算法:

defcalculate_discount(self,order_amount):ifself.coupon_type=='fixed':# 满减券:够门槛才减returnself.discount_valueiforder_amount>=self.thresholdelse0elifself.coupon_type=='discount':# 折扣券:够门槛打折iforder_amount>=self.threshold:returnround(order_amount*(1-self.discount_value),2)return0else:# 无门槛券:直接减,但不超过订单金额returnmin(self.discount_value,order_amount)

别忘了过期处理。用户领了 100 张券,99 张没用,不能一直挂着"未使用"。写个定时任务每天扫一遍:

defexpire_coupons_batch():"""批量过期(Celery Beat 定时任务,建议每天凌晨跑一次)"""now=timezone.now()returnUserCoupon.objects.filter(status='unused',coupon__valid_end__lt=now).update(status='expired')

优惠券系统的核心教训:每张券都是独立实例,有自己的生命周期。不要想着用一张"模板券"加个数量字段就完事——核销、退款、对账都会出问题。领出去 1000 张,就得有 1000 条独立记录。


动手:秒杀

秒杀是整个电商系统里并发压力最大的场景。设计思路四步。

第一步:库存预热

活动开始前 5-10 分钟,把库存从数据库加载到 Redis。为什么?因为秒杀开始时不能有数据库查询——每一毫秒都珍贵。核心就一句:SET flash_sale:{id}:stock <剩余库存>,带过期时间。

第二步:限流——挡住恶意请求

总有人写脚本刷接口。同一用户 60 秒内最多 3 次尝试:

rate_limit_key=f'flash_sale:{flash_sale_id}:user:{user_id}:count'current_count=self.redis.get(rate_limit_key)ifcurrent_countandint(current_count)>=3:return{'success':False,'message':'操作过于频繁,请稍后再试'}# 用 pipeline 保证原子性:计数 + 设置过期时间pipe=self.redis.pipeline()pipe.incr(rate_limit_key)pipe.expire(rate_limit_key,60)pipe.execute()

第三步:Redis 原子预减库存

这是秒杀最核心的操作。用DECR命令——它是原子的,单线程执行,天然不会并发冲突:

stock_key=f'flash_sale:{flash_sale_id}:stock'remaining=self.redis.decr(stock_key)# 原子扣减,返回扣减后的值ifremaining<0:self.redis.incr(stock_key)# 库存不够,把多扣的 1 个加回去return{'success':False,'message':'已售罄'}

为什么用DECR而不是"先读再减"?

因为 Redis 虽然是单线程,但如果你分成两步——先GET读到库存是 1,然后准备SET为 0——在这两步之间,另一个请求也GET到了 1,也准备扣减。结果两个请求都认为自己买到了最后一件。这就是经典的竞态条件。

而DECR是一条命令,Redis 保证原子性。10000 个DECR排着队执行,每个都安全。

第四步:异步下单

预减库存成功不代表订单创建成功。数据库操作太慢,不能同步执行。把任务丢进异步队列,让后台 Worker 慢慢处理:

预减成功 → 创建排队记录(status='queued') → 放入异步队列(实际项目用 Celery.send_task) → 立即返回前端"抢购成功,正在排队处理"

后台 Worker 处理队列时,数据库层面要再次校验库存(秒杀场景必须用锁),Redis 和 DB 之间可能不一致——比如 Redis 重启了、网络抖动了、Worker 崩了。Redis 预减成功了,数据库还得再验一次。

秒杀的核心教训:不要信任任何单一环节。Redis 预减 + 数据库二次校验,双层才能万无一失。用户那边看到的是"排队处理中",最终结果是"成功"或"失败",体验流畅。


动手:拼团

拼团的难点不在技术复杂度,在于边界情况特别多。你每加一个功能,就多三种异常场景。

先定义拼团实例的状态机:

forming(成团中) → success(成团成功)→ 发货 forming(成团中) → failed(超时/失败)→ 退款

开团:团长发起一个新团,自动成为第一个成员。校验活动在有效期内,事务里创建GroupBuyInstance(status=‘forming’,current_members=1)+ 创建团长成员记录。

参团——最容易出 bug 的地方。核心框架:

defjoin_group(self,user_id,group_id):withtransaction.atomic():# 行锁锁定,防止两个人同时参团导致人数计算错误group=GroupBuyInstance.objects.select_for_update().get(id=group_id)# 校验 1:团的状态(只有 forming 才能加人)# 校验 2:是否超时(超时则标记 failed,自动关闭)# 校验 3:人数是否已满# 校验 4:是否重复参团# 加入 + 人数加 1(用 F 表达式)GroupBuyMember.objects.create(group=group,user_id=user_id)group.current_members=F('current_members')+1group.save()# 关键!refresh_from_db 获取数据库真实值group.refresh_from_db()ifgroup.current_members>=group.activity.min_members:group.status='success'group.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 分钟执行一次)"""now=timezone.now()expired_groups=GroupBuyInstance.objects.filter(status='forming',expire_at__lt=now)forgroupinexpired_groups:withtransaction.atomic():# 双重校验:防止查询和执行之间被其他进程处理了fresh=GroupBuyInstance.objects.select_for_update().get(id=group.id)iffresh.status=='forming':fresh.status='failed'fresh.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 个最合适的,效率就很高了。


学完来练练手

  1. 优惠券叠加:支持多张券叠加使用,但有优先级(先算满减再算折扣)。注意:叠加后总金额不能为负数。

  2. 秒杀预热倒计时:给秒杀接口加"预热期"——活动开始前 5 分钟,用户访问返回倒计时信息,同时自动预热 Redis 库存。

  3. 拼团模拟退团:成团中允许团员退出,退出后current_members - 1。如果团长退出,把团长身份转移给最早参团的团员。

  4. 秒杀实时监控:用 Redis 的GET读取剩余库存,写个接口返回"已售 X 件,剩余 Y 件",前端做成进度条。


配套文件day12_promotion.py(优惠券 + 秒杀 + 拼团完整实现)可参考学习。

本系列为梅雅达编程笔记原创,首发于 CSDN。有问题?评论区见

相关新闻

小模型在昇腾NPU上的推理部署与AISBENCH性能调优实战

小模型在昇腾NPU上的推理部署与AISBENCH性能调优实战

刚接手一个边缘推理项目时&#xff0c;我差点被“小模型”三个字带偏。模型参数量不大、计算量也不高&#xff0c;按道理在昇腾NPU上做推理部署应该是顺手的事&#xff0c;真正跑起来才发现&#xff1a;模型小&#xff0c;不等于部署省心&#xff1b;相反&#xff0c;小模型在N…

2026/10/20 12:53:45 阅读更多 →
订单交付周期压缩专项整改

订单交付周期压缩专项整改

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 12:53:45 阅读更多 →
从ChatBot到Agent:一文讲透AI智能体的循环原理与工程实现

从ChatBot到Agent:一文讲透AI智能体的循环原理与工程实现

你有没有想过&#xff0c;同样是打开一个对话框&#xff0c;为什么有人管它叫 ChatBot&#xff0c;有人却叫它 Agent&#xff1f;我的理解很直白&#xff1a;ChatBot 是替你答疑解惑的问答工具&#xff0c;Agent 是接了任务能自己跑完整个闭环的执行者。一字之差&#xff0c;背…

2026/10/20 12:53:45 阅读更多 →

最新新闻

科研场景下大模型技能包配置指南:论文写作、数据可视化与汇报材料三大核心技能包

科研场景下大模型技能包配置指南:论文写作、数据可视化与汇报材料三大核心技能包

1. 科研场景下大模型技能包的真实需求拆解科研工作流和普通的内容创作、代码开发有本质区别。一篇论文从选题到最终发表&#xff0c;中间要经历文献调研、实验设计、数据处理、图表绘制、论文撰写、同行评审回复、会议汇报等多个环节&#xff0c;每个环节对AI辅助的需求都不一样…

2026/10/20 14:19:19 阅读更多 →
多智能体协作翻车实录:32倍耗时13倍Token换来准确率下降

多智能体协作翻车实录:32倍耗时13倍Token换来准确率下降

1. 从一次翻车的多智能体协作实验说起去年下半年&#xff0c;我接手了一个内部知识库问答系统的重构任务。需求本身不算复杂&#xff1a;把散落在多个文档仓库里的技术资料整合起来&#xff0c;做一个能回答"某个接口怎么调用""某个配置项默认值是多少"这类…

2026/10/20 14:19:19 阅读更多 →
2026年9月GitHub十大热门项目盘点:本地推理、数据管道与终端效率工具

2026年9月GitHub十大热门项目盘点:本地推理、数据管道与终端效率工具

1. 榜单背后的筛选逻辑&#xff1a;为什么这十个项目能上榜每个月月初我都会花两三个小时翻一遍 GitHub Trending 和几个技术社区的月度汇总&#xff0c;时间久了慢慢摸出一些门道。2026 年 9 月这一期的十大热门项目&#xff0c;乍看之下题材跨度很大——有做本地推理的、有做…

2026/10/20 14:19:19 阅读更多 →
多播与广播编程实战:从原理到代码排障

多播与广播编程实战:从原理到代码排障

我最早接触多播和广播&#xff0c;是在调一个局域网内多台设备的同步程序时被逼出来的。当时用单播一条条发指令&#xff0c;设备一多&#xff0c;数据包就像早高峰的地铁&#xff0c;挤在一条链路上&#xff0c;延迟和丢包全都来了。后来换成广播&#xff0c;一台发、全网收&a…

2026/10/20 14:19:19 阅读更多 →
AI芯片选型指南:从TOPS参数到功耗生态的实用过滤方法

AI芯片选型指南:从TOPS参数到功耗生态的实用过滤方法

年初有位朋友做边缘计算设备选型&#xff0c;一开始列了个 AI 芯片候选清单&#xff0c;拢共 57 款&#xff0c;觉得已经覆盖得很全面了。结果调研越做越深&#xff0c;发现同一个厂家在不同产品线、不同代际、甚至不同封装下都会分出好几款&#xff0c;最后这份清单硬生生整理…

2026/10/20 14:19:18 阅读更多 →
多模态数据扩散模型做预测:原理、实操与工程落地全解析

多模态数据扩散模型做预测:原理、实操与工程落地全解析

打开扩散模型相关论文的时候&#xff0c;我第一反应也是“又一个做图像生成的”。直到我自己把扩散模型接到一个多模态预测项目上&#xff0c;才发现这条路的价值被严重低估了。传统的预测模型&#xff0c;不管是LSTM、Transformer还是各种树模型&#xff0c;本质上都在学“给定…

2026/10/20 14:18:12 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:45 阅读更多 →