做电商系统的同学,几乎没有不被优惠券过期这事恶心过的。用户领了一堆券,你总得让它到点消失;用户在订单页纠结半天,结账时券偏偏过期了;运营拍脑袋说“过期前发条短信提醒一下”,结果提醒发早了用户没收到,发晚了用户已经炸毛。所以看到“瞧瞧别人家的优惠券过期方案,那叫一个优雅”这句话,我太有共鸣了——因为我自己就在这个坑里爬了好几年。这篇文章想把优惠券过期方案彻底讲透:从定时扫描、惰性检查、延迟队列、时间轮这些主流姿势,到状态机设计、过期提醒、并发边界和消息堆积排查,把原理、选型和真实踩坑都摊开。不管你是做电商交易、营销中台还是会员权益,都建议对号入座看看。
1. 优惠券过期这事,到底难在哪
1.1 一张优惠券的完整生命周期
先把一张券从生到死的链路捋明白,不然后面所有设计都落不了地。一张券通常会经历这些状态:用户领取时生成记录并进入券包,状态是“待使用”;用户下单预占时变成“已锁定”;支付成功后核销成“已使用”;到期后变成“已过期”;如果订单退款,还可能从“已使用”回滚到“待使用”或“已过期”。
这里有个容易被忽略的关键点:过期不是一个孤立动作,它是整个状态机里的一条流转边。很多人做过期方案时只想着“怎么把过期状态的券筛出来”,却忽略了它跟使用、锁定、退款这些状态之间的关系。结果方案一遇上并发就到处漏风。举个例子,用户正好在过期前一秒点击“使用券”,后台的过期检查也同时执行,你到底是算它过期,还是算它用掉了?业务上这可不是小事,后面我会专门展开。
再看数据量。一个中型电商平台的用户券包表通常有几千万行,大促活动一个周期就能新增上亿张券。在这种量级下,过期方案跑不跑得动、会不会拖垮线上,直接决定了营销中台的稳定性。所以我一直觉得,优惠券过期根本不是“写个定时任务”的小需求,而是一个需要认真设计的数据一致性问题。
1.2 传统过期方案的三大痛点
早期很多系统的做法,就是搞一个定时任务,每几分钟扫一次表,把过期时间小于当前时间的券批量改成“已过期”。数据量小的时候完全没问题,量级上来之后,痛点非常集中。
第一个痛点是扫描压力大。一张券表动辄上亿行,给过期时间字段建索引后,每次扫“WHERE expire_time < NOW()”虽然能走索引,但如果是大促后的高峰,过期券数量暴增,慢查询和锁竞争会直接拖累线上交易。第二个痛点是时间粒度粗糙。定时任务分钟级跑一次,用户券已经过期两分钟了你还没处理,这不致命,但配上一堆“过期提醒”需求,就很容易出现提醒晚到、用户自己都发现了你才发短信的尴尬局面。第三个痛点是状态流转缺失。很多任务脚本只做UPDATE,不记录流转、不触发通知、不处理失败重试。一旦脚本中间挂掉一批,就会出现“用户已经下单但券状态还是待使用”的脏数据,后续对账时头大。
这三个痛点基本就是所有“不够优雅”过期方案的病根。所谓优雅,本质上是把“过期”从一个孤立任务,升级为一套带事件、带状态、带补偿的机制。下面几种主流姿势,逐个拆开看。
2. 主流过期检查姿势,谁更优雅
2.1 定时扫描:最朴素也最容易翻车
先别急着否定定时任务,它在很多场景下依然是合理选择。券数量在百万级以内、过期精度要求不高、团队没有多余中间件,用定时任务加索引分批扫完全够用。我早期项目里就是半小时跑一次,把已过期券打上标记,顺带发几条站内信,代码量不大,也稳定跑了好长时间。
但要注意它的上限。当数据量到千万级以上,或者高峰期集中过期(比如一批72小时券都在深夜24点失效),定时扫描就必须要做精细控制了。控制点有几个:分批大小要限制,不能一次UPDATE一万条;扫描要和业务低谷错峰;要加状态过滤条件,不要让任务反复扫已经过期的券。最怕的是多实例并发跑同一个任务,没有分布式锁,就会出现重复扫描甚至重复发提醒。用XXL-JOB这类调度平台时,一定要配置好分片参数和幂等逻辑。
我个人的实践结论是:定时扫描适合做兜底,不适合做主力。所谓兜底,是处理那些因为消息丢失、程序宕机而没有及时过期的漏网之鱼。主力交给主动过期机制,兜底交给定时任务,两者配合才不容易出大事。如果只靠定时扫描,那不是在选方案,是在给自己埋雷。
2.2 读时检查:把成本摊到访问路径上
读时检查,也叫惰性检查,思路很朴素:不在后台主动扫,而是在用户查询券包、下单校验券时,顺带检查一下过期时间,发现过期就实时置成过期状态。这套思路在大厂里非常常见,因为它的成本和用户访问量挂钩。券多但没人看,就不会有扫描消耗;券被人翻到了,顺手清理,返回给用户的状态还永远是最新的。
实现上,只需要在查询券列表、校验可用券、计算最优券这三个入口,统一走一个“券对象获取”的逻辑,里面做判断:当前时间超过endTime且状态为待使用时,先打过期标记再返回。但这里有个重要提醒:这个打标记的操作不要和查询放在同一个大事务里,最好先把“已过期”返回给上层,再异步发事件去更新状态。不然每次用户刷券包都触发一次UPDATE,热点照样会把数据库打爆。
惰性检查有个明显短板:如果用户一直不访问,券就会一直“赖”在未过期状态。不少系统就是被这个短板坑过——用户没打开App,过期券一直挂着,等运营跑统计时发现总账怎么都对不上。所以业界通常把它和主动过期配合使用:惰性负责用户体验和实时纠偏,主动负责最终一致性。两者一走,过期方案才算是站住了。
2.3 主动过期:延迟队列与时间轮
主动过期的目标,是让券在到期那一刻或者到期后很短时间里完成状态切换。最常用的手段是延迟队列和时间轮两种。
延迟队列的经典实现里,基于Redis ZSET的方案我最推荐。把过期时间戳作为score、用户券ID作为member,启动一个常驻任务,每秒取score小于当前时间的成员,批量处理过期。这个方案成本低、可控性好,十几行代码就能跑起来,还能天然处理“不同券不同过期时间”的乱序问题。RabbitMQ的TTL加死信队列也可以做:发消息时设置TTL为券剩余有效期,到期后消息进死信队列,由消费端处理过期逻辑。好处是天然支持重试和削峰,坏处是不同过期时间的券会把队列拆得很碎,而且消息级别的TTL存在队列头阻塞问题,得按时间桶拆队列才能用好。
时间轮则是另一种思路。它把时间切成槽位,每个槽位放一批到期任务,指针每走一个槽位就处理一批。Netty的HashedWheelTimer、Kafka内部的延迟操作都用了这个模型。优点是纯内存操作、精度高、适合大量短时任务;缺点也明显,它是单机内存结构,分布式环境需要自己协调,而且进程一旦崩溃,内存里的任务全丢。所以时间轮适合做“分钟级精度、可容忍丢失后有兜底恢复”的模块,不适合当唯一权威来源。
我给的选型建议很直接:中小团队优先用Redis ZSET做延迟队列,成本低、可观测性最好;已经深度使用RabbitMQ并且不介意拆队列复杂度,可以上死信队列方案;时间轮不要单独扛大旗,除非你券量极大且能接受内存抖动带来的风险。我自己项目里是把Redis ZSET当主力,另加半小时一次的定时扫描做兜底,Redis就算短暂不可用,兜底任务也能把过期状态补齐。
3. 把优雅方案落地的关键细节
3.1 先定一个前提:过期精度要求到底多高
不管你选哪个方案,第一步永远是跟产品对齐过期精度。很多工程师上来就直接搞延迟队列,结果产品说“误差不超过5分钟就行”,一套复杂方案全白搭了。
电商场景里,优惠券过期精度其实很少需要到秒级。用户感知层面,误差1分钟内几乎没人察觉;运营统计口径,10分钟级延迟也能接受;但如果你在券规则里向用户承诺了精确过期时间,那就要谨慎对待每一秒的误差。我见过一个卡券案例,规则写明“过期时间精确到秒”,结果系统提前3秒给用户置为已过期,最后被投诉到消协。所以方案设计前,先写清楚精度SLA,再反推技术选型。
可以给一个简单的SLA对照表:
| 精度要求 | 推荐方案 | 兜底方案 |
|---|---|---|
| 分钟级/小时级 | 定时扫描 | 读时检查 |
| 秒级/准实时 | Redis ZSET延迟队列 | 定时扫描 |
| 秒级且量极大 | 时间轮+延迟队列混合 | 定时扫描+归档 |
这里还有个细节:所有过期判断用系统时间还是统一用一个业务时钟服务?如果服务器时间漂移,不同实例对同一张券是否过期可能判断不一致。至少要做NTP同步,最好把实例间的时钟误差纳入监控,否则你方案再精巧,时间基准先歪了,一切都白搭。
3.2 状态机设计:过期不是一个动作,是一次流转
优雅的过期方案,在数据模型上一定不是简单一个字段UPDATE完事,而是一个明确的状态机。我把券的状态拆成几个关键节点:待使用、已锁定、已使用、已过期、已失效(比如运营主动作废)。过期只允许从“待使用”或“已锁定”流转到“已过期”,其他状态一律不允许转。
为什么“已锁定”也要能过期?因为用户把券加入购物车时,很多系统会预占锁定。如果锁定就一直存在,用户不下单,券就一直占着库存,运营配置的总量就废了。所以锁定状态必须有过期时间,锁定期满自动释放名额或回流库存。
状态流转还一定要考虑幂等。过期事件可能被重复投递,比如消息重试、任务重跑,接收方必须能识别“这张券已经是已过期,再来一次就忽略”。最稳妥的做法是统一走一个“过期服务”入口,内部先用状态CAS去更新,比如执行UPDATE ... WHERE status='待使用',影响行数为0就说明已经被处理过了,直接返回成功。幂等键用券ID加过期事件ID来组合,基本就不会出问题。
这里顺便聊聊“已锁定”状态的作用,它不只是给购物车用的,也是给并发控制用的缓冲带。后面第四部分我会专门展开,这是整个方案里最体现功底的地方。
3.3 核心实现思路与代码骨架
下面给出一套基于Redis ZSET延迟队列加消费处理的核心骨架,仅供参考。
# 券创建时,把过期任务写入延迟队列 def schedule_expire(user_coupon_id: int, expire_ts: int): redis.zadd("coupon_expire_queue", {f"{user_coupon_id}": expire_ts}) # 常驻消费者:每秒拉取一次到期任务 def consume_expire_queue(): while True: now = int(time.time()) # 取所有 score <= now 的任务,每次最多500个 tasks = redis.zrangebyscore("coupon_expire_queue", 0, now, start=0, num=500) if tasks: pipeline = redis.pipeline() for task in tasks: pipeline.zrem("coupon_expire_queue", task) pipeline.execute() # 先移除,再处理,避免重复消费 for task in tasks: process_expire(int(task)) time.sleep(1) # 过期处理入口:幂等状态流转 def process_expire(user_coupon_id: int): coupon = get_coupon(user_coupon_id) if coupon.status not in ("待使用", "已锁定"): return rows = db.update( "UPDATE user_coupon SET status='已过期', expire_time=NOW() " "WHERE id=%s AND status IN ('待使用','已锁定')", user_coupon_id, ) if rows > 0: # 状态流转成功,发事件:过期通知、释放名额、更新统计 event_bus.publish("coupon_expired", {"user_coupon_id": user_coupon_id})这里有几个关键点要解释一下。ZSET的score是过期时间戳,秒级精度完全够用;消费时先ZREM再处理,是为了防止处理异常导致同一任务被反复投递,但ZREM之后如果消费者挂了,这条任务就彻底没了,所以必须有兜底任务扫漏网之鱼;每次批量数量限制在500不是拍脑袋,是为了控制单次处理耗时,避免消费者被一批重任务阻塞。
3.4 数据模型与索引设计
数据模型直接影响方案能不能落地。券表通常要包含券模板ID、用户ID、状态、领取时间、过期时间、使用订单ID、锁定时间、过期来源(系统自动或人工作废)等字段。关键索引两个:一个是(状态, 过期时间),给定时任务兜底扫描用;另一个是(用户ID, 状态),给用户查询券包用。
过期的历史数据不要留在热表里。过期30天以上的券可以定期归档到冷表,否则热表越堆越大,任何查询都会变慢。我见过一张券表堆到十亿行,索引比数据还大,这时候再谈优雅方案,基础已经打不牢了。归档策略可以和定时任务结合,每天凌晨把过期超过N天的券搬到归档表,业务查询默认走热表,需要查历史时再走归档表。
4. 过期之后那摊事:提醒、补偿和一致性
4.1 过期提醒:别让用户的券突然就没了
“优雅”的另一个体现,是用户感知层面的细腻。优质方案通常会在券过期前做1到3次触达,而不是默默到期。常见的节奏是:T-3天站内信,T-1天推送或短信,过期前30分钟再做一次弹窗提醒。这里有个很容易踩的坑:提醒不能只靠延迟队列串行处理,因为一个用户可能有几十张券,每张都发提醒就是骚扰。一定要聚合,按用户维度合并同一批到期券,发一条“你有3张优惠券将于明天过期”这样的内容。
提醒通道的选择也要分层。高价值券用短信,普通券用站内信或推送;如果用户最近30天没打开过App,短信转化率也会很低,可以省下这笔成本。这个分层逻辑不复杂,但很多团队忘了做,导致站内信成本是省了,却因为短信轰炸收到一叠投诉工单。营销系统是直接面向用户的,每一封触达都代表品牌态度。
4.2 过期前的唤醒策略
优秀的团队还会在过期前做唤醒动作。比如券快过期但没有核销记录时,可以触发运营策略:给用户追加一张门槛更低的新券、发一张“即将过期”专享折扣、在购物车顶部展示“可用券即将失效”的提示条。这些策略本质上是把被动过期变成主动转化。
实现上,可以在过期前T+1天的提醒事件里,顺便查一下用户最近是否有浏览或加购行为,如果有且没下单,就生成一条任务推给推荐或运营系统。这部分不是必须做的,但如果你的营销系统想提升券核销率,这就是性价比很高的增量。这里要提醒一句:唤醒活动的库存要单独计算,别把快要过期、已经锁定给用户的券当成可用库存,不然后台配置活动时容易超卖。
4.3 并发边界:过期的瞬间用户正在下单怎么办
这是所有过期方案里最容易翻车的地方。用户手里有一张券,下单时系统校验说“可用”,跳转支付页,支付完成后核销。就在这个窗口期,后台过期任务可能已经把券置为“已过期”;反过来,核销先到、过期任务后到,把已经使用的券又标成过期,订单享受了优惠,券状态却错了。
处理思路可以总结成三个原则。第一,状态流转全部走条件更新,用UPDATE ... WHERE status='待使用'做CAS,保证只有一个动作能成功。第二,核销和过期之间用一个“已锁定”状态作缓冲:用户发起下单请求时先把券从“待使用”改成“已锁定”,过期任务看到“已锁定”就直接跳过;支付成功后锁定的券再转“已使用”,支付失败则回滚为“待使用”。第三,订单使用券和券状态变更不要跨越分布式事务,尽量通过事件加本地消息表去保证最终一致;如果必须强一致,就要引入事务型消息或者状态机加补偿。
用“已锁定”作为缓冲的好处是:过期任务不用跟正在交易的用户抢一把锁,用户也不用阻塞等待过期检查结果。这个细节我觉得才是“优雅”二字的精髓——通过状态机设计,让本该冲突的两个动作自然错开,而不是靠锁把性能打下去。
5. 常见问题与排查技巧实录
5.1 延迟消息堆积了,系统还怎么扛
我真实遇到过:一次万人团购活动,几千张券在同一秒过期,Redis ZSET里瞬间涌出一批到期任务,消费端一次拉了500个,处理时还同步调外部通知接口,RT一高,队列越积越多,最后积压了几万条消息。
排查思路分几步:先看消费速率和堆积量的曲线,确认是处理慢还是入队过快;再看日志里每笔处理的耗时分布,找慢的瓶颈。我那次瓶颈是每笔处理都同步发了短信,短信通道限流导致RT飙到3秒,500笔排队就是25分钟。后来改成:状态流转成功就发内部事件,通知服务异步消费短信队列,消费端只做轻量状态变更,积压问题立刻消失。
这也是个通用经验:延迟队列的消费者只做状态流转和发内部事件,一切外部IO全部异步化,否则方案再优雅也扛不住突发流量。
5.2 时间轮和分布式环境的取舍
时间轮在单机上很香,但一旦上了分布式,它“内存即权威”的特点就成了软肋。我见过一个团队用HashedWheelTimer做券包过期,部署了两台实例,每台内存里只有各自生成的部分任务,过期判断还得依赖任务生成那台机器;后来一台机器发布重启,任务全丢,只能靠定时任务兜底补。最终他们把时间轮降级成“预计算提醒时间”的工具,真正的过期状态还是交给Redis延迟队列做权威。
这里的经验是:分布式系统里,过期方案最好只有一个权威来源。Redis、MQ、数据库都是可恢复的权威来源,进程内存不是。时间轮可以用在局部模块提醒这类可丢失、可容忍的场景,不要用它承载用户核心资产的状态变更。
5.3 一张券表扫出的血泪教训
最后分享一个印象最深的坑。我早期负责过一个积分商城,券表只有一千万行,但后台每5分钟全量扫“WHERE expire_time < NOW()”,没有加状态过滤,也没有limit。平时还能扛,但有一次运营发了一批90天券,积压到第二个月某个整点集中过期,全表扫描直接把数据库CPU干到100%,线上交易接口大量超时。
从那以后我定了三条铁律:第一,所有定时扫描必须带状态过滤和limit分批;第二,扫描任务必须挂慢SQL监控,超过阈值自动告警;第三,过期高峰期自动错峰,整点过期的大批量券可以做分钟级随机偏移,反正业务上允许误差。很多人觉得优惠券过期是个小需求,不值得上纲上线,但根据我多年经验,营销系统里出过的大事,十有八九是从这种“小需求”开始埋雷的。
最后说点个人体会。优惠券过期方案根本没有一劳永逸的银弹,所谓“优雅”其实是朴素的工程道理组合:该异步的异步、该幂等的幂等、该兜底的兜底、该让用户感知的提前感知。方案要和数据量、精度要求、团队运维能力匹配,而不是一味追求高级技术。如果你也在设计或重构过期方案,建议从“精度SLA加状态机加幂等入口加兜底扫描加用户触达”这五个点入手,先把整个链路画出来,再去选中间件,大概率不会走我之前那些弯路。