优惠券系统:券模板、核销与防套利的架构设计 — editorial cover photo

优惠券系统:券模板、核销与防套利的架构设计

一张优惠券从领取到核销,背后是库存、互斥、叠加规则与反套利的复杂系统。

2025年6月11日·返回文章列表
文章大纲

优惠券是营销活动最通用的载体。满减、折扣、立减、新人券,剥掉花哨的名字都是优惠券的变体。听起来只是发券加核销两步,真正做起来会发现,发券是高并发库存问题,核销是分布式状态机问题,叠加规则是组合爆炸问题,防套利是风控问题。这几样揉在一个系统里,做得粗糙一场活动能把利润亏光。

我在营销线做过几年,最深的教训是优惠券的复杂度全在规则和金额这两个不起眼的地方。规则做死了,运营每改一次活动都要研发排期发版,业务节奏全被研发拖住。金额算错了,看起来是几分钱的精度问题,放大到千万笔订单就是实打实的资损。优惠券系统的设计目标只有一个,让规则可配置、让金额算得清、让套利堵得住,其余都是实现细节。

三层模型,把规则和实例分开

PlantUML
正在加载图表…
PlantUML diagram

图:优惠券三层模型——券模板定义基因、用户券是领取实例、核销记录是使用历史,三层分离保证灵活性与事务边界清晰

优惠券的数据模型必须分三层。券模板定义一张券的基因,面额多少、用什么门槛、有效期多长、总共发多少张。用户券是模板被某个用户领取后的实例,记录谁领的、什么状态、什么时候过期。核销记录是券被某个订单使用后的历史,记录哪张券抵扣了哪个订单多少金额。

把这三层分开,是后续一切灵活性的基础。运营改模板的门槛规则,不影响已经发出的实例。统计一张券的实际核销率,查核销记录就行,不用扫全量用户券。如果实例和核销混在一张表,一个退款回退操作就要同时改状态、改金额、改历史,事务边界糊成一团。

模板设计的关键词是可配置。面额、门槛、适用商品范围、互斥组、叠加白名单,全部做成配置项,由规则引擎解释执行。运营改规则不该改代码,这条做到了,营销系统才算成熟。我在 Matrix 营销系统里做这块时,坚持规则全部 DSL 化,运营在后台拖拽配置就能上线新活动类型,研发只在新增规则算子时介入。

领券,高并发下的库存控制

PlantUML
正在加载图表…
PlantUML diagram

图:领券流程——限领校验、Redis 原子 DECR 库存、DB 异步落库,写失败补偿回滚并配对账兜底

大促发券的那几秒,几万甚至几十万用户同时抢一张限量券,本质和秒杀是同一类问题。库存控制必须前置到 Redis 做原子扣减,DB 只负责异步落库。

做法是券模板的总库存存在 Redis 里,领券请求进来先走一个原子的 DECR。库存大于零才能扣成功,扣成功才允许写用户券记录。每人限领的校验用 Redis 的 Set 或者计数器,记录这个用户领过这张模板没有,防止同一用户刷券。领券接口必须幂等,重复请求不重复发券,靠一个领券流水号或者用户加模板的联合唯一键去重。

这里有个一致性的坑。Redis 库存扣成功了,但写 DB 用户券记录失败了,于是 Redis 少了一份库存,却没有对应的实例落地。结果就是这张券对外显示已发完,实际只发出了一部分,库存被凭空吃掉。解法是 DB 写失败必须补偿回滚 Redis 库存,再配一个定时对账任务,统计 DB 里这个模板实际发出的实例数,和 Redis 剩余库存反推的已发数比对,差异修正并告警。这套对账是防资损的兜底,我在大促前一定会验一遍对账逻辑能正确检出差异。

核销,两阶段状态机

PlantUML
正在加载图表…
PlantUML diagram

图:券核销两阶段状态机——下单选券进入已锁定预占,支付成功才真正核销,订单取消则原样退回未使用

券核销不是一次性动作,是一个状态流转。未使用到已锁定到已核销,中间还可能回退到未使用或者走到已作废。

下单时用户选了一张券,这张券要进入已锁定状态。锁定的含义是预占,防止同一个用户在另一个订单里再用这张券。支付成功才真正核销,把状态推进到已核销。如果订单取消了或者超时未支付,锁定的券要回退回未使用,让用户还能再用。

为什么要分锁定和核销两步,而不是下单就直接核销。因为下单到支付之间有一段时间窗口,这段时间内订单可能取消。如果下单即核销,订单取消后这张券就直接作废了,用户用都没用上就没了,体验灾难。两阶段让券在锁定期内属于这个订单但不消耗,订单成了才真消耗,订单黄了原样退回。

锁定期必须有超时兜底。用户下单锁了券然后关掉应用不支付,如果不释放,这张券就一直卡在已锁定状态,谁都看不见也用不了。定时任务扫所有超时的已锁定券,强制回退到未使用。这个任务的周期要短,几分钟一轮,否则用户重新打开应用会发现自己刚才选的券还卡着。

叠加互斥,组合爆炸的规则引擎

一笔订单能用几张券,券能不能和满减活动叠加,这是营销系统最头疼的地方。难点不在于规则本身,而在于规则的组合。

互斥组解决同类券的排他问题,所有满减券归一组,组内只能选一张。叠加白名单解决跨类型的组合,配置哪类券可以和哪类活动叠加,规则引擎查这张矩阵表。计算顺序影响结果,先算商品级优惠还是先算订单级优惠,最终金额可能不一样,这个顺序也要配置化。

最复杂的是最优组合推荐。用户购物车里有多件商品、账户里有多张可用券,系统要算出让用户最省的组合。这是一个组合搜索问题,券数量多的时候暴力遍历会爆炸。生产里要么用缓存把热门组合的结果算好,要么用贪心加剪枝,先把明显劣解的券组合剪掉,只在剩下的空间里搜索。

规则别硬编码,上引擎

叠加互斥规则用代码写死是优惠券系统最常见的技术债。一开始规则少还好,运营每次提新规则都要研发改代码发版,半年后这块代码会变成谁都不敢动的屎山。用 Drools 或者自研 DSL 把规则外置,研发只维护规则算子,规则组合由运营在后台配置。这块投入越早做越省事,我经历过把硬编码规则重构成引擎的过程,迭代周期从两周缩到两天。

防套利,营销系统的红线

优惠券最大的风险是套利,羊毛党用各种手段把券变现。常见的套利路径有几条,多账号批量领券、凑单凑到门槛用券再退款套现、用券买低价商品转卖。

多账号领券靠设备指纹、实名认证、风控规则拦截。一个设备、一个实名身份在活动期内有领券次数上限。凑单套现是退款规则必须堵的口子,用户用券下单付款再退款,如果券原路退回,等于免费循环用券。多数系统的规则是用券订单退款时券作废不退回,从源头堵死这条路。低价商品刷券靠券的适用商品门槛,设置一个最低消费金额或者限定品类,不让券用在几毛钱的商品上。

退款是套利重灾区,这里要多说一句。整单退款相对简单,券作废就行。部分退款最麻烦,订单里有多个商品分摊了优惠,退其中一个商品,优惠怎么按比例回退,券要不要部分返还,这些规则要和业务、财务一起定清楚,写进系统逻辑,绝不能让研发自己拍。

金额计算,用分不用元

优惠金额的计算必须严谨,这里出问题就是资损。几条铁律。

全程用分作为单位,整数计算,绝不用元做浮点运算。0.1 加 0.2 在浮点数里不等于 0.3 这种问题,放大到千万笔订单就是真金白银的窟窿。Java 里用 BigDecimal 或者直接用 long 存分,二选一,不要混用。

优惠不能倒贴。一张 20 元的券用在 15 元的商品上,优惠封顶为商品价格,实付为零,绝不能算成负数让平台倒找钱。这个封顶逻辑要写进计算引擎,作为一道硬约束。

订单级优惠要分摊到每个商品。一张满 100 减 20 的券用在三个商品上,这 20 元优惠要按商品金额比例分摊到每个商品,记录每个商品的实付。这样退款一个商品时,能准确算出这个商品分摊了多少优惠,退多少钱。不分摊的话,部分退款就没法算。

下单时算的金额和支付时核对的金额必须一致。下单算了一遍优惠,传给支付,支付回来核对,两边对不上支付就该失败。这听起来理所当然,但中间只要计算逻辑有任何分支差异,比如某个规则在下单链路判断了支付链路漏了,就会对不上。

// 部分退款按商品分摊比例回退优惠
// 订单 100 元, 用了 20 元券, 退款一个 30 元商品
// 该商品分摊的优惠 = 30 * (20/100) = 6 元
// 实际退款 = 30 - 6 = 24 元

这块的资损风险最高,测试要覆盖所有退款组合,整单退、部分退、多次部分退、退到只剩一个商品。我见过一个案例,部分退款的分摊精度用四舍五入,多次部分退款后剩下的商品实付对不上,单这一条每月漏几万块。后来全改成截断加最后一项兜底,把误差收敛到最后一个商品上,才堵住。

Continue Reading

关联文档推荐

查看全部
营销活动引擎:规则引擎与活动叠加互斥 — editorial cover photo风控规则引擎:从硬编码到 DSL 的演进 — editorial cover photo短链系统 — editorial cover photo
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。