
做电商系统的同学几乎没有不被优惠券过期这事恶心过的。用户领了一堆券你总得让它到点消失用户在订单页纠结半天结账时券偏偏过期了运营拍脑袋说“过期前发条短信提醒一下”结果提醒发早了用户没收到发晚了用户已经炸毛。所以看到“瞧瞧别人家的优惠券过期方案那叫一个优雅”这句话我太有共鸣了——因为我自己就在这个坑里爬了好几年。这篇文章想把优惠券过期方案彻底讲透从定时扫描、惰性检查、延迟队列、时间轮这些主流姿势到状态机设计、过期提醒、并发边界和消息堆积排查把原理、选型和真实踩坑都摊开。不管你是做电商交易、营销中台还是会员权益都建议对号入座看看。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, start0, num500) 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_timeNOW() 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 过期前的唤醒策略优秀的团队还会在过期前做唤醒动作。比如券快过期但没有核销记录时可以触发运营策略给用户追加一张门槛更低的新券、发一张“即将过期”专享折扣、在购物车顶部展示“可用券即将失效”的提示条。这些策略本质上是把被动过期变成主动转化。实现上可以在过期前T1天的提醒事件里顺便查一下用户最近是否有浏览或加购行为如果有且没下单就生成一条任务推给推荐或运营系统。这部分不是必须做的但如果你的营销系统想提升券核销率这就是性价比很高的增量。这里要提醒一句唤醒活动的库存要单独计算别把快要过期、已经锁定给用户的券当成可用库存不然后台配置活动时容易超卖。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加状态机加幂等入口加兜底扫描加用户触达”这五个点入手先把整个链路画出来再去选中间件大概率不会走我之前那些弯路。