资讯详情

餐饮 SaaS 优惠券系统架构演进(四):一次大促优惠券事故复盘——库存、一致性与补偿体系设计

📅 2026/10/6 2:47:59 | 华诺云谱 👁 阅读
餐饮 SaaS 优惠券系统架构演进(四):一次大促优惠券事故复盘——库存、一致性与补偿体系设计
优惠券系统技术复盘 · 第四篇这一篇不再停留在“领券接口慢不慢”而是回到一次典型大促事故的真实工程问题库存守得住吗、跨服务回滚能收敛吗、异步链路能兜底吗以及系统应该如何按优先级演进。复盘定位从故障现象切入梳理主动领券、批量发券、生命周期消息、订单用券四条核心链路。关注重点库存仲裁边界、事务边界、补偿闭环、最终一致性、架构演进优先级。写作原则严格区分“当前实现”和“建议演进”避免把未来方案写成现状能力。目录1. 事故背景问题不只是“领券慢”2. Symptoms故障表象与深层信号3. Investigation还原当前系统实现3.1 主动领券数据库强一致路径3.2 批量发券另一套库存路径3.3 生命周期MQ Reconcile3.4 订单用券跨服务一致性4. Root Cause三类边界同时暴露5. 当前架构评价该保留什么6. 演进方案先止血再重构再扩容7. 目标架构同步收缩异步外放8. Lessons Learned真正要带走的经验1. 事故背景真正的问题不是“领券慢”而是“高峰流量下正确性与吞吐同时承压”餐饮 SaaS 场景下的优惠券并不是一个孤立功能。它直接影响订单优惠金额、用户权益兑现和营销转化效率因此本质上是一个带强约束、强一致需求、并且必须可审计和可恢复的领域系统。到了新品首发、开业活动、会员拉新等营销时点优惠券流量会在极短时间窗口内集中爆发系统承受的不只是单接口高并发而是多条写入口叠加后的综合压力在线主动领取、运营后台批量发券、会员赠券以及订单端的优惠券使用与归还。这也是为什么第四篇不能只讨论“接口快不快”。真正重要的问题是在活动流量冲高时库存还能不能守住用户权益会不会丢异步状态能不能自动收敛跨服务异常出现后系统还能不能恢复回正确状态。图 1大促故障时间线——用紧凑的信息图展示“流量进入高峰 → 故障暴露 → 排查止血”的完整过程。从系统治理角度看“慢”只是表象真正需要复盘的是热点竞争点在哪里、库存规则是否跨入口统一、主事务与异步副作用是否解耦、最终一致性是否具备第二条兜底通道。2. Symptoms故障表象是接口变慢深层现象是系统边界开始失真领券接口 RT 明显上升热点券在峰值时段开始排队用户感知为点击后等待变长甚至领取失败。数据库层的锁等待增加说明瓶颈并不只是资源不足而是热点数据竞争加剧。优惠券生命周期状态推进逐步更多依赖巡检任务兜底说明异步链路的及时性与主链路负载开始出现耦合。从运营视角看最危险的信号不是“慢”而是库存、限领、订单补偿是否还遵守同一套正确性规则。因此这次复盘不能停在监控曲线上而要顺着现象继续往下拆接口为什么慢库存为什么可能失真订单与优惠券之间为什么要靠补偿以及这些问题分别属于性能问题、领域问题还是事务边界问题。3. Investigation先还原当前系统实现再谈哪里需要升级3.1 在线主动领券当前系统最“正统”的强一致路径主动领取链路使用的是数据库事务 模板行锁模型。方法进入Transactional后会先对优惠券模板执行FOR UPDATE拿到模板行锁以后再做活动状态、会员资格、每人限领与总领取量检查随后构建领取时规则快照写入user_claim并安排未来过期的延迟消息。Transactional(rollbackFor Exception.class) public Long receiveCoupon(Long couponId, String source) { Coupon coupon couponMapper.selectOne( new LambdaQueryWrapperCoupon() .eq(Coupon::getId, couponId) .eq(Coupon::getAppId, appId) .eq(Coupon::getDeleted, false) .last(FOR UPDATE) ); long receivedCount claimMapper.selectCount(...); // 每人限领 long totalClaimed claimMapper.selectCount(...); // 总领取量 CouponRuleSnapshot snapshot buildSnapshot(coupon); claimMapper.insert(userClaim); delayedProducer.publishUserClaimExpireEvent(...); return userClaim.getId(); }这套实现最大的优点是正确性容易证明。同一张券的并发领取最终会在模板行锁上串行化后进入的事务会看到前一个事务提交后的统计结果因此主动领取这条路径本身并不容易出现超发。图 2主动领取核心链路——图中分成“事务入口 / 规则与库存检查 / 权益生成 / 事务提交”四层比单线流程图更能体现热点路径结构。3.2 批量发券性能优化做了但库存仲裁没有完全统一后台批量发券不是逐条查询数据库而是先批量加载每张券的已领取数量、每个用户的领取次数把结果放进BatchSendContext。这种做法对减少 SQL 很有效因为它把“查询压力”从循环内部提到了循环外部。但问题在于stockRemaining和userClaimCounts本质上只是当前任务进程内的一次快照。它们能减少本次批量任务内部的重复计算却不能天然约束另一个后台任务更无法与同时发生的在线领取共享同一套库存原子仲裁。图 3库存双路径对比——左侧是 DB 锁 实时统计右侧是批量上下文快照这张图本质上是“并发控制对照图”不是简单的业务流程图。3.3 生命周期推进RabbitMQ Reconcile Job 是当前最成熟的一块模板开始、模板结束和用户券过期三类事件都通过延迟消息推进。消费者在更新状态时不仅依赖消息本身还会校验状态前提和真实时间条件因此重复消息、提前消息都不至于把状态推进错。更关键的是系统还提供了巡检补偿任务。也就是说消息不是唯一真相源即便延迟消息投递或消费异常巡检任务仍然会在后续扫描数据库并修正状态。这是一个非常典型的“Fast Path 追求及时性Slow Path 保证最终正确性”的工程设计。图 4生命周期一致性架构——将消息生产、延迟队列、消费推进与巡检兜底合并为一张图便于说明“及时性 最终正确性”的双路径。3.4 订单使用优惠券简单可跑但本质上已经进入跨服务事务问题当前订单使用优惠券的实现很直白下单成功时订单服务远程调用用户服务的useCoupon()把 claim 从可用改成已使用并写入usedOrderId如果支付超时、订单取消或流程中断再调用returnCoupon()按订单号精确归还。它的优点是简单、路径短、容易落地它的限制也很清晰订单事务和优惠券事务并不在同一个本地事务里。因此系统不能依赖一个Transactional解决全部回滚问题而必须依赖取消补偿和超时补偿来收敛状态。图 5订单与优惠券跨服务一致性——图的重点是“事务域分离 补偿收敛”而不是简单画两个接口请求箭头。4. Root Cause这不是一个根因而是三类边界同时暴露4.1 根因一热点券竞争不可避免但锁后临界区偏长模板行锁本身不是问题相反它是主动领取这条链路正确性的基础。真正需要优化的是锁后做了哪些事情活动资格判断、总量统计、每人限领统计、范围查询、快照构建甚至还包括 MQ publish。对于热点券来说锁后动作越多后续事务的等待时间就越长。锁后动作业务目的工程上的隐患活动状态 / 会员资格检查保证可领条件正确可前置的部分应尽量前置减少热点锁持有时间总领取量 COUNT控制总发行量claim 数据增大后会抬高热点路径成本每人领取次数 COUNT控制每人限领要求合适索引否则高峰时容易放大延迟门店 / 商品 / 分类范围查询生成领取时 snapshot查询链条过长会继续拉长事务MQ publish安排未来过期事件这是异步副作用不应强耦合到主路径可用性4.2 根因二库存规则没有跨入口统一主动领取依赖“模板行锁 实时统计”批量发券依赖“内存快照 本地递减”。只看各自内部这两套方案都说得通但放在一个系统里它们其实没有共享同一个库存原子仲裁点。问题因此不再是某个 SQL 快不快而是系统的库存不变量并没有被一个统一组件守住。4.3 根因三跨服务事务与异步边界天然复杂订单侧调用 useCoupon 成功后优惠券事务已经在另一个服务中独立提交如果订单本地后续失败系统只能依赖 returnCoupon 补偿。类似地如果领券主事务中的 MQ publish 失败也会把一个原本可延后修复的异步动作反向耦合进主路径可用性。归纳起来这次问题不是“数据库性能不够”这么简单而是三类边界一起暴露锁临界区边界、库存仲裁边界、事务与补偿边界。真正的优化对象不是单个 SQL而是系统的一致性设计。5. 当前架构评价它并不差但它需要更清晰的演进方向在做优化前先正面评价当前方案非常重要。因为一个系统只要能长期稳定运行说明里面一定已经有不少正确的工程判断。当前设计至少有四个值得保留的优点主动领取数据库强一致模型清晰、稳定适合作为第一版可靠落地方案。快照固化把规则范围冻结到 snapshot 中避免配置变化影响历史已领权益。生命周期MQ Reconcile 的双路径成熟说明系统已经具备“最终一致性意识”。订单归还通过 usedOrderId 精确归还避免误归还其他订单已经消费的券。真正需要升级的部分不是“推翻重写”而是让这些优点在更高并发、更多入口和更复杂异常条件下仍然成立。6. 演进方案先止血再重构再扩容6.1 第一阶段先修正确性与可用性不急着上 Redis缩短主动领取锁后临界区把可前置的资格校验尽量前置把可缓存的配置读取尽量前移。审计与user_claim相关的索引让总量统计和每人限领统计在高峰时可控。把 MQ publish 从主事务路径上解耦至少改为 after-commit再逐步走向 Outbox。对热点券单独做限流、监控和告警不让一张热点券拖垮整条领券链路。6.2 第二阶段库存从“统计推导”升级成“显式聚合”系统中期最值得做的是引入显式库存聚合例如coupon_inventory使用条件 UPDATE 做原子仲裁。这样总量控制就不再依赖反复扫描历史 claim而可以沉淀成一个真正的库存领域对象。coupon_inventory ----------------------------- coupon_type coupon_id total_stock available_stock granted_stock version updated_at UPDATE coupon_inventory SET available_stock available_stock - 1, granted_stock granted_stock 1 WHERE coupon_type ? AND coupon_id ? AND available_stock 0;这样做之后不论是主动领取、后台批量发券还是后续新增的会员赠券、注册赠券都必须经过同一个 Inventory Aggregate库存不变量才真正被统一。6.3 第三阶段批量发券任务化而不是“大事务同步跑完”后台批量发券更适合演进成任务系统API 只负责创建batch_taskWorker 按 chunk 执行每个 chunk 一个小事务。这样不仅能降低大事务风险也更适合失败重试、任务恢复和进度可视化。6.4 第四阶段只在极端热点下再引入 Redis ReservationRedis 并不是这一阶段的第一优先级。只有当 DB 原子库存也承受不住秒级极端热点时才应该让 Redis 进入主路径做 Reservation。并且即使到了那个阶段Redis 也应该只负责“预留”最终 claim、库存流水和事件事实仍然要在数据库层可审计地落下来。7. 目标架构同步链路收缩异步链路可审计最终一致性可恢复目标架构的关键不是组件数量而是职责边界更干净同步事务里只保留最小不可分割的事实——Eligibility、Inventory、Claim、Outbox通知、过期调度、审计等外部副作用全部转入异步链路而对账、修复和告警则成为系统的第二条正确性通道。图 6目标架构——采用上下分层布局而非超长横向图突出“同步主链路 异步副作用 对账修复”的职责分工。图 7演进路线图——按 P0 到 P4 分阶段推进强调“先做对再做快最后做极端热点能力”。演进原则先统一库存仲裁与事务边界再治理异步可靠性只有在极端热点场景下才值得把 Redis Reservation 引入主路径。8. Lessons Learned高并发系统优化本质上是重新划分一致性边界第一高并发优化首先不是引入某个组件而是先找到谁在做并发仲裁谁在定义业务不变量。第二主动领取链路安全不代表整个优惠券系统安全批量发券、会员赠券、订单补偿都必须纳入同一套不变量治理。第三真正成熟的异步系统一定有两条路径一条追求及时性一条负责最终收敛。第四跨服务事务没有魔法必须通过状态机与补偿来实现可恢复而不能靠本地事务想象全局回滚。第五当系统可以自证“不超发、不丢权益、可对账、可修复”时性能优化才真正建立在正确基础上。最终判断第四篇真正要表达的不是“上 Redis 就能解决问题”而是优惠券系统的演进核心在于统一库存仲裁、梳理事务边界、建立补偿闭环、让最终一致性可观测且可恢复。这些边界梳理清楚后再谈极端热点优化系统才不会一边提速、一边失真。下一篇预告《优惠券系统生产故障 RCA超发、孤儿券、MQ 丢事件与订单补偿失败》。下一篇将完全切到 Postmortem 结构Symptoms → Investigation → Root Cause → Resolution → Verification → Lessons Learned。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑