优惠券系统架构设计文档
版本:v1.1 适用场景:电商/O2O 类平台的优惠券发放、领取、叠加计算与核销
目录
- 背景与目标
- 需求概述
- 总体架构
- 核心领域模型
- 规则引擎设计
- 运营管理台设计
- 叠加规则设计详解
- 最优叠加方案计算(核心算法)
- 数据存储设计
- 服务拆分与 API 设计
- 高并发与一致性保障
- 可扩展性设计
- 监控与可观测性
- 总结与后续演进
1. 背景与目标
优惠券系统看似简单(发一张券、抵扣一点钱),但在实际业务中会迅速演变成规则最复杂、最容易出资损事故的模块之一,原因是:
- 券的类型多(满减、折扣、立减、免邮、赠品券……),每种的计算方式不同;
- 券与券之间存在复杂的叠加/互斥关系,且这些关系会随运营活动频繁调整;
- 用户手里可能同时持有多张可用券,系统需要在毫秒级内帮用户/自动挑出"最划算"的组合;
- 一旦规则出错(该互斥的没互斥、该叠加的没叠上),直接影响 GMV 和资损。
本设计的核心目标是:把"规则判定"与"组合寻优"两个能力做成独立、可配置、可插拔的引擎层,业务方(运营)只需要配置规则,不需要改代码就能上线新的叠加策略。
2. 需求概述
2.1 功能性需求
| 能力 | 说明 |
|---|---|
| 券模板管理 | 创建满减/折扣/立减/免邮/赠品等类型的券模板,设置门槛、适用范围、有效期、库存 |
| 发放与领取 | 支持主动领取、系统发放(下单赠送、会员权益)、定向发放 |
| 可用券查询 | 给定订单(商品、金额、店铺),查出用户当前可用的券列表 |
| 叠加规则判定 | 判断任意两张/多张券是否可以同时使用 |
| 最优叠加方案计算 | 在所有可用券中,自动计算出使订单实付最低(或折扣最大)的合法组合 |
| 核销 | 下单时锁定券、支付成功后核销、订单取消/退款后回滚 |
| 规则配置化 | 运营后台可视化配置叠加规则、互斥组,无需发版 |
2.2 非功能性需求
- 低延迟:查询可用券 + 计算最优组合需在结算页同步返回,目标 P99 < 100ms;
- 强一致性:券的锁定/核销/库存扣减不能超卖、不能被重复核销;
- 高并发:大促场景下领券、查询是脉冲式流量;
- 可扩展:新增券类型、新增叠加规则不应侵入核心链路代码;
- 可审计:每一次核销、每一次规则判定要可追溯(为什么这两张券不能一起用)。
3. 总体架构
整体采用分层架构,核心是把"规则引擎"和"叠加计算引擎"作为独立的领域服务下沉,供交易链路调用:
┌─────────────────────────┐
│ 接入层 │
│ API Gateway / BFF │
└────────────┬─────────────┘
│
┌───────────────┬────────────┼────────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌───────────────┐ ┌───────────┐ ┌───────────┐
│ 券模板/发放 │ │ 用户券包服务 │ │ 叠加计算服务 │ │ 核销服务 │ │ 运营后台API │
│ TemplateSvc │ │ WalletSvc │ │ StackingSvc │ │ RedeemSvc │ │ AdminSvc │
└──────┬──────┘ └──────┬──────┘ └───────┬───────┘ └─────┬─────┘ └─────┬─────┘
│ │ │ │ │
└───────────────┴───────┬────────┴───────────────┴─────────────┘
▼
┌───────────────────────────┐
│ 规则引擎 RuleEngine │
│ 领取规则 / 门槛规则 / 叠加规则 │
│ / 计价规则 │
└─────────────┬──────────────┘
▼
┌───────────────────────────┐
│ 数据与缓存层 │
│ MySQL(模板/实例/规则配置) │
│ Redis(库存原子扣减/热点缓存) │
│ 规则配置中心(可选:Nacos/Apollo)│
└───────────────────────────┘
关键设计原则:
- StackingSvc(叠加计算服务)不直接依赖交易系统,只依赖"订单快照 + 候选券列表 + 规则",可以独立测试、独立压测、甚至可以离线跑批对账。
- RuleEngine 是纯函数式的判定组件,输入上下文、输出通过/不通过,不产生副作用,便于单元测试和规则回归。
- 核销(资损敏感操作)与计算(读多写少)物理拆分,核销走强一致链路,计算可以适度做缓存和降级。
4. 核心领域模型
// ============ 券模板:运营配置的"发放方案" ============
type CouponTemplate struct {
ID string
Name string
Type CouponType // 满减/折扣/立减/免邮/赠品
DiscountValue decimal.Decimal // 折扣值:满减金额 / 折扣率(0.8) / 立减金额
Threshold decimal.Decimal // 使用门槛,如满 100 元可用
Scope ApplicableScope // 适用范围:全场/指定类目/指定商品/指定店铺
Category string // 券分类:platform(平台券)/merchant(商家券)/logistics(运费券)
ExclusionGroups []string // 所属互斥组 ID 列表
Priority int // 结算优先级,数值越小越先应用
ValidFrom time.Time
ValidTo time.Time
StockTotal int64
}
type CouponType string
const (
TypeFullReduction CouponType = "FULL_REDUCTION" // 满减,如满100减20
TypeDiscount CouponType = "DISCOUNT" // 折扣,如8折
TypeFixedOff CouponType = "FIXED_OFF" // 无门槛立减
TypeFreeShipping CouponType = "FREE_SHIPPING" // 免邮
TypeGift CouponType = "GIFT" // 赠品券
)
// ApplicableScope 适用范围,用于判断某张券对某个订单/商品是否生效
type ApplicableScope struct {
ScopeType string // ALL / CATEGORY / SKU / SHOP
TargetIDs []string
}
// ============ 券实例:用户实际持有的一张券 ============
type CouponInstance struct {
ID string
TemplateID string
Template *CouponTemplate // 运行时关联,便于计算
UserID string
Status CouponStatus // UNUSED / LOCKED / USED / EXPIRED / RETURNED
ReceivedAt time.Time
UsedAt *time.Time
OrderID *string
}
type CouponStatus string
const (
StatusUnused CouponStatus = "UNUSED"
StatusLocked CouponStatus = "LOCKED" // 下单时锁定,占用但未核销
StatusUsed CouponStatus = "USED"
StatusExpired CouponStatus = "EXPIRED"
StatusReturned CouponStatus = "RETURNED" // 订单取消/退款后回滚
)
// ============ 订单(结算上下文) ============
type Order struct {
ID string
UserID string
ShopID string
Items []OrderItem
OriginalAmount decimal.Decimal
ShippingFee decimal.Decimal
}
type OrderItem struct {
SKUID string
CategoryID string
Price decimal.Decimal
Quantity int
}
设计要点:CouponTemplate 承载"规则性"属性(门槛、适用范围、互斥组、优先级),CouponInstance 只承载"状态性"属性。叠加判定始终发生在模板层面,因为运营配置的互斥关系是针对"这一类券",而不是针对某一张具体的券实例——这个区分对后面第 8 节的性能优化很关键。
5. 规则引擎设计
5.1 规则分类
把规则按"作用阶段"分为四类,每一类对应结算链路的一个环节:
| 规则类型 | 作用阶段 | 典型规则 |
|---|---|---|
| 领取规则 Eligibility | 用户领券时 | 用户身份/等级、库存是否充足、是否已达领取上限、时间窗口 |
| 门槛规则 Threshold | 判断券对某订单是否可用 | 订单金额是否满足满减门槛、商品是否在适用范围内 |
| 叠加规则 Stacking | 多券组合判定 | 互斥组校验、每类型最大叠加张数、全局最大叠加张数、依赖关系 |
| 计价规则 Pricing | 结算金额计算 | 折扣计算公式、多券应用顺序、封顶金额 |
5.2 规则引擎接口设计
// RuleContext 规则执行上下文
type RuleContext struct {
Order *Order
User *UserProfile
Coupon *CouponInstance
Template *CouponTemplate
SelectedSet []*CouponInstance // 叠加校验时:当前已选定的券组合
Extra map[string]interface{}
}
// RuleResult 规则执行结果
type RuleResult struct {
Passed bool
Reason string // 不通过时的原因,便于前端提示 & 审计追溯
}
// Rule 所有规则类型的统一接口
type Rule interface {
ID() string
Type() RuleType
Evaluate(ctx *RuleContext) RuleResult
}
type RuleType string
const (
RuleEligibility RuleType = "ELIGIBILITY"
RuleThreshold RuleType = "THRESHOLD"
RuleStacking RuleType = "STACKING"
RulePricing RuleType = "PRICING"
)
// RuleEngine 规则引擎:按类型维护规则链,支持短路执行
type RuleEngine struct {
rulesByType map[RuleType][]Rule
}
func (e *RuleEngine) EvaluateChain(ruleType RuleType, ctx *RuleContext) RuleResult {
for _, r := range e.rulesByType[ruleType] {
if res := r.Evaluate(ctx); !res.Passed {
return res // 短路:任一规则不通过,整体不通过
}
}
return RuleResult{Passed: true}
}
5.3 规则的存储与编译
规则不能硬编码在服务里,必须存成数据/配置,才能被第 6 节的管理台驱动。存储层面每条规则是一个 RuleDefinition,支持两种表达方式:
type RuleDefinition struct {
ID string
Name string // 给运营看的可读名称,如"新人券与大促券互斥"
Type RuleType // ELIGIBILITY/THRESHOLD/STACKING/PRICING
Mode string // "expression" | "structured"
Expression string // Mode=expression 时:如 "order.amount >= 100"
Structured json.RawMessage // Mode=structured 时:反序列化为 ExclusionGroupDef 等具体结构
Priority int // 决定规则链中的执行顺序
Enabled bool
Version int
Scope PublishScope // 灰度范围:全量 / 指定店铺 / 按用户哈希的流量百分比
}
type PublishScope struct {
Full bool
ShopIDs []string
TrafficPct int // 按 userID 哈希取模命中的灰度百分比
}
区分两种模式是有意为之:
- structured(结构化规则):互斥组、数量上限这类规则结构固定、字段有限,直接用强类型 struct 表示,管理台用表单/勾选框配置,不需要运营理解表达式语法,也不存在表达式注入风险;
- expression(表达式规则):门槛类规则字段组合多变(订单金额、用户等级、商品类目……),用轻量表达式引擎(如
expr、cel-go)在运行时解析执行,换取灵活性,代价是需要做安全沙箱(禁止调用任意函数、限制执行时间和内存,防止运营配置出一条死循环或恶意表达式)。
两者通过统一的 RuleCompiler 接口编译成运行时的 Rule 对象:
type RuleCompiler interface {
Compile(def RuleDefinition) (Rule, error)
}
type CompilerRegistry struct {
compilers map[string]RuleCompiler // key: "expression" / "structured"
}
func (r *CompilerRegistry) CompileAll(defs []RuleDefinition) (*RuleEngine, error) {
engine := &RuleEngine{rulesByType: map[RuleType][]Rule{}}
for _, def := range defs {
if !def.Enabled {
continue
}
compiler, ok := r.compilers[def.Mode]
if !ok {
return nil, fmt.Errorf("未知规则模式: %s", def.Mode)
}
rule, err := compiler.Compile(def)
if err != nil {
return nil, fmt.Errorf("规则[%s]编译失败: %w", def.ID, err)
}
engine.rulesByType[def.Type] = append(engine.rulesByType[def.Type], rule)
}
return engine, nil
}
关键点:CompileAll 是一次性把所有规则定义编译成一整个新的 RuleEngine 实例,而不是逐条热更新单条规则。这样能保证"要么这批规则全部生效,要么一条都不生效(编译报错就整体拒绝这次发布)",避免中间状态。
5.4 热加载与灰度发布
规则变更不能靠重启服务生效,用一个原子指针持有当前生效的引擎实例:
type RuleEngineHolder struct {
current atomic.Value // 存 *RuleEngine
}
func (h *RuleEngineHolder) Get() *RuleEngine {
return h.current.Load().(*RuleEngine)
}
func (h *RuleEngineHolder) Swap(newEngine *RuleEngine) {
h.current.Store(newEngine) // 原子替换,正在处理中的请求继续用旧指针跑完
}
配置中心(Nacos/Apollo 或自建)推送变更事件 → 拉取最新 []RuleDefinition → CompileAll → 编译成功才 Swap。编译失败必须保留旧引擎并告警,绝不能因为一条写错的规则让整个叠加计算服务瘫掉——这是最容易被忽视但最重要的一条容错原则。
灰度发布依赖 RuleDefinition.Scope:规则在 Evaluate 前先检查 RuleContext 里的 shopID/userID 是否命中灰度范围,未命中则该条规则视为不生效,走老逻辑。这样一条新的互斥规则可以先在 5% 流量上跑几天,观察拒绝率、核销率没有异常波动,再放量到 100%。
5.5 规则冲突检测
规则一多,最容易出的问题是新规则和已有规则逻辑矛盾,比如"A 必须和 B 同时使用"却又把 A、B 划进了同一个互斥组。这类矛盾必须在提交时就拦下来,而不是等线上出问题:
func DetectConflicts(policy *StackingPolicy) []ConflictWarning {
var warnings []ConflictWarning
groupOf := buildGroupIndex(policy.ExclusionGroups)
for _, dep := range policy.DependencyRules {
for _, req := range dep.RequireTemplateIDs {
if intersects(groupOf[dep.SourceTemplateID], groupOf[req]) {
warnings = append(warnings, ConflictWarning{
Level: "ERROR",
Msg: fmt.Sprintf("模板 %s 与 %s 既要求必须同时使用,又被划入同一互斥组",
dep.SourceTemplateID, req),
})
}
}
for _, conflict := range dep.ConflictTemplateIDs {
if contains(dep.RequireTemplateIDs, conflict) {
warnings = append(warnings, ConflictWarning{
Level: "ERROR",
Msg: fmt.Sprintf("模板 %s 对 %s 既要求必须同时使用,又标记为冲突", dep.SourceTemplateID, conflict),
})
}
}
}
return warnings
}
ERROR 级别直接阻断发布,WARNING 级别(比如同一模板出现在两个不同互斥组,逻辑上不冲突但容易让运营自己搞混)允许发布但需要二次确认。
6. 运营管理台设计
管理台不是简单地"把数据库字段暴露成表单",它要解决的核心问题是:让不懂表达式、不懂代码的运营,能安全地配置出正确的规则,并且在出问题之前就发现问题。对应四个功能模块。
6.1 向导式配置(覆盖 80% 的常规场景)
互斥组、数量上限这类结构化规则,直接做成表单/勾选交互:
- "互斥组"页面:多选框勾选若干券模板,点击"设为互斥",前端直接组装成
ExclusionGroupDef提交,运营全程不接触任何表达式; - "叠加上限"页面:滑块/输入框配置
MaxTotalCoupons、每个分类的MaxPerCategory; - "依赖关系"页面:下拉选择"模板 A"+"关系(必须同时使用/不能同时使用)"+"模板 B",生成
DependencyRule。
这一层运营用不到 RuleCompiler 里 expression 的能力,后端也不需要为这类规则做安全沙箱,因为结构是强类型、封闭的。
6.2 高级模式:条件构建器与决策表(覆盖剩下的灵活场景)
对于门槛/资格类规则,字段组合多变,提供可视化的条件构建器:运营用"字段(下拉:订单金额/用户等级/商品类目)+ 操作符(≥/=/in)+ 值"拼条件,支持 AND/OR 嵌套,前端拼装成 AST,后端转成 expr 表达式存库——运营看到的是搭积木式的 UI,后端存的才是表达式,两边都不用"裸写代码"。
再复杂一点、天然是矩阵关系的规则(比如"券类型 × 店铺类型"决定能不能叠加),用决策表编辑器更直观:界面类似 Excel,一行是一条规则,列是条件字段和结果字段,运营像填表格一样配置,后端解析成一组 ExpressionRule。这是很多规则引擎产品(如 Drools 的 decision table)证明过好用的交互方式,比一条条写表达式对运营更友好。
6.3 规则试算器(上线前自测,最关键的一环)
管理台内置一个"沙箱"页面:运营构造一个虚拟订单(金额、店铺、商品类目)+ 手动勾选几张候选券模板,点"试算",直接调用草稿版本的 RuleEngine.EvaluateChain 和 StackingResolver.Resolve(不影响线上生效版本),返回:
- 每张券通过/被拒绝的具体原因(对应
RuleResult.Reason,精确到是哪一条规则挡住了); - 试算出的最优组合和折扣金额。
没有这一步,规则配置错了只能等线上用户投诉或者数据异常才发现;有了这一步,运营在发布前就能自己核对"我配的规则,效果是不是我想要的"。
6.4 版本、审批与发布闭环
配置流程是:草稿 → 提交审核 → 灰度发布 → 全量发布,每次发布生成一个不可变的 Version(历史版本的 RuleDefinition 一直保留,回滚就是重新编译成旧版本并 Swap,不需要额外的回滚逻辑)。
对资损敏感的规则(互斥组、数量上限)要求走审批——比如提交人和审批人不能是同一人,避免运营手滑把某个互斥组删掉导致大量券可以无限叠加。审批通过后才写入 rule_config 并推送到配置中心。
发布之后,管理台要能立刻看到这条规则线上的实际表现:命中次数、拒绝率、Top 拒绝原因、计算耗时 P99、降级比例(对应第 13 节的监控指标),运营才能判断"这次改动是不是达到了预期",形成"配置 → 试算验证 → 发布 → 观察数据"的闭环,而不是配完就完事。
整体链路是:管理台(表单/条件构建器/决策表/试算器) → AdminService(结构校验 + 冲突检测 + 审批流) → 写入 rule_config 表并推送配置中心 → 各服务的 RuleEngineHolder 监听变更、编译、原子替换 → 线上 StackingService/RedeemService 始终读取最新生效的规则,全程不需要重启或发版。
7. 叠加规则设计详解
叠加规则是运营方最关心、也最容易表达不清的部分。实践中"能否叠加"通常可以用三层约束表达:
7.1 互斥组(Exclusion Group)
同一个互斥组内的券模板,最多只能选一张。例如"新人专享券"和"大促满减券"属于同一互斥组,二选一。互斥组是最常用、表达能力最强的机制,大多数叠加规则都可以归约成互斥组划分。
7.2 类型级别/全局限制
除了互斥组,通常还有"数量上限"约束,例如:
- 平台券最多叠加 1 张;
- 商家券最多叠加 3 张;
- 一笔订单总共最多用 4 张券。
7.3 补充依赖规则
少数场景无法用"互斥组"这种对称关系表达,例如"运费券必须搭配满减券才能用""A 券不能和 C 券同时使用,但 A 和 B 可以"这种非对称/局部冲突关系,用补充的依赖规则表达:
// StackingPolicy 叠加策略:一份完整的叠加规则配置
type StackingPolicy struct {
MaxTotalCoupons int // 全局最多叠加张数
MaxPerCategory map[string]int // 每个券分类最多叠加张数,如 {"platform":1,"merchant":3}
ExclusionGroups [][]string // 互斥组:每个子数组内的模板ID最多选1个
DependencyRules []DependencyRule // 补充的依赖/局部冲突规则
}
// DependencyRule 非对称依赖/局部冲突,互斥组表达不了的场景用这个补充
type DependencyRule struct {
SourceTemplateID string
RequireTemplateIDs []string // 必须同时存在才可使用(为空表示无强制依赖)
ConflictTemplateIDs []string // 不能与之同时使用(局部冲突,不构成对称互斥组)
}
// MutualExclusionRule 实现 Rule 接口,校验互斥组约束
type MutualExclusionRule struct {
policy *StackingPolicy
}
func (r *MutualExclusionRule) Evaluate(ctx *RuleContext) RuleResult {
candidateGroups := groupsOf(ctx.Coupon.TemplateID, r.policy.ExclusionGroups)
for _, selected := range ctx.SelectedSet {
selectedGroups := groupsOf(selected.TemplateID, r.policy.ExclusionGroups)
if intersects(candidateGroups, selectedGroups) {
return RuleResult{Passed: false, Reason: "与已选券存在互斥关系"}
}
}
return RuleResult{Passed: true}
}
// StackLimitRule 实现 Rule 接口,校验数量上限约束
type StackLimitRule struct {
policy *StackingPolicy
}
func (r *StackLimitRule) Evaluate(ctx *RuleContext) RuleResult {
if len(ctx.SelectedSet)+1 > r.policy.MaxTotalCoupons {
return RuleResult{Passed: false, Reason: "已达全局最大叠加张数"}
}
category := ctx.Template.Category
limit, ok := r.policy.MaxPerCategory[category]
if ok {
used := countByCategory(ctx.SelectedSet, category)
if used+1 > limit {
return RuleResult{Passed: false, Reason: "已达该类型最大叠加张数:" + category}
}
}
return RuleResult{Passed: true}
}
MutualExclusionRule、StackLimitRule、DependencyRule 校验都注册进 RuleEngine 的 RuleStacking 规则链,依次短路执行——业务上"是否可叠加"这一个问题,被拆成了三条独立、互不感知的小规则,新增一种叠加约束只需要新增一个 Rule 实现并注册,不改动已有代码。
8. 最优叠加方案计算(核心算法)
8.1 问题建模
给定用户在某订单下的候选券集合 C = {c1, c2, ..., cn},以及第 7 节的叠加约束,求一个子集 S ⊆ C,满足:
S中任意两张券都不违反互斥组/依赖规则;S中每个分类的数量不超过上限,总数不超过全局上限;- 按结算优先级依次应用
S中的券后,订单实付金额最小(折扣总额最大)。
这本质上是一个带约束的组合优化问题:如果只有"互斥组 + 数量上限"两类约束,它接近分组背包问题(每组最多选一个 + 容量限制),多数场景下可以用动态规划精确求解;但一旦引入 DependencyRule 这种非对称的局部冲突,退化为通用图上的最大权独立集问题,理论上是 NP-Hard。
工程上的应对策略是:候选券数量在真实业务里通常很小(用户在一个订单下能同时满足门槛的券,过滤后一般不超过 15~20 张),因此用回溯 + 剪枝(branch and bound)做精确搜索完全可以在毫秒级完成;为极端情况兜底再加一个贪心降级方案 + 超时熔断。
8.2 计算流程
候选券过滤(领取规则+门槛规则)
│
▼
排序(按单张券折扣额降序,利于剪枝)
│
▼
回溯搜索(叠加规则做可行性剪枝,已选+理论上限做最优性剪枝)
│
├─ 在时间预算内求得精确解 ──▶ 返回最优组合
│
└─ 超时 ──▶ 贪心兜底(按折扣密度贪心选取) ──▶ 返回近似解(标记 Degraded)
8.3 Go 伪代码实现
// StackingResolver 负责计算给定候选券集合下的最优(折扣最大)组合
type StackingResolver struct {
engine *RuleEngine
priceCalc *PriceCalculator
timeBudget time.Duration // 单次计算的时间预算,如 30ms
}
const exactSearchLimit = 25 // 候选数超过该值直接走贪心,避免最坏情况指数爆炸
type ResolveResult struct {
SelectedCoupons []*CouponInstance
FinalPrice decimal.Decimal
TotalDiscount decimal.Decimal
Degraded bool // 是否因超时/候选过多而降级为启发式解
}
func (s *StackingResolver) Resolve(order *Order, candidates []*CouponInstance) *ResolveResult {
filtered := s.filterApplicable(order, candidates) // 过滤:过领取规则+门槛规则
if len(filtered) > exactSearchLimit {
return s.greedySearch(order, filtered)
}
deadline := time.Now().Add(s.timeBudget)
if best := s.exactSearch(order, filtered, deadline); best != nil {
return best
}
return s.greedySearch(order, filtered) // 精确搜索超时,兜底
}
// exactSearch 回溯 + 剪枝,寻找折扣总额最大的可行组合
func (s *StackingResolver) exactSearch(order *Order, candidates []*CouponInstance, deadline time.Time) *ResolveResult {
// 按单张券理论折扣从大到小排序,使剪枝尽早生效
sort.Slice(candidates, func(i, j int) bool {
return s.priceCalc.SingleDiscount(order, candidates[i]).
GreaterThan(s.priceCalc.SingleDiscount(order, candidates[j]))
})
var best *ResolveResult
var current []*CouponInstance
timeout := false
var backtrack func(idx int, upperBound decimal.Decimal)
backtrack = func(idx int, upperBound decimal.Decimal) {
if timeout || time.Now().After(deadline) {
timeout = true
return
}
currentDiscount := s.priceCalc.SimulateDiscount(order, current)
// 最优性剪枝:当前折扣 + 剩余候选券理论总折扣上限 都追不上 best,放弃该分支
if best != nil && currentDiscount.Add(upperBound).LessThanOrEqual(best.TotalDiscount) {
return
}
if idx == len(candidates) {
if best == nil || currentDiscount.GreaterThan(best.TotalDiscount) {
snapshot := append([]*CouponInstance{}, current...)
best = s.buildResult(order, snapshot, currentDiscount)
}
return
}
next := candidates[idx]
remainAfter := upperBound.Sub(s.priceCalc.SingleDiscount(order, next))
// 分支一:尝试选中 next(先过叠加规则可行性校验)
if s.isCompatible(order, current, next) {
current = append(current, next)
backtrack(idx+1, remainAfter)
current = current[:len(current)-1]
}
// 分支二:不选 next
backtrack(idx+1, remainAfter)
}
totalUpperBound := s.priceCalc.SumSingleDiscounts(order, candidates)
backtrack(0, totalUpperBound)
if timeout {
return nil
}
return best
}
// isCompatible 调用规则引擎的叠加规则链,判断 next 是否可与 current 组合
func (s *StackingResolver) isCompatible(order *Order, current []*CouponInstance, next *CouponInstance) bool {
ctx := &RuleContext{Order: order, Coupon: next, Template: next.Template, SelectedSet: current}
return s.engine.EvaluateChain(RuleStacking, ctx).Passed
}
// greedySearch 启发式降级:按折扣密度贪心选取,牺牲全局最优换取确定性延迟
func (s *StackingResolver) greedySearch(order *Order, candidates []*CouponInstance) *ResolveResult {
sort.Slice(candidates, func(i, j int) bool {
return s.priceCalc.SingleDiscount(order, candidates[i]).
GreaterThan(s.priceCalc.SingleDiscount(order, candidates[j]))
})
var selected []*CouponInstance
for _, c := range candidates {
if s.isCompatible(order, selected, c) {
selected = append(selected, c)
}
}
discount := s.priceCalc.SimulateDiscount(order, selected)
res := s.buildResult(order, selected, discount)
res.Degraded = true
return res
}
PriceCalculator 负责把"选定的一组券"转成"实际扣了多少钱"。因为满减/折扣的计算是顺序敏感的(先扣满减再打折,和先打折再扣满减,结果不同),所以结算顺序不是搜索出来的,而是由 CouponTemplate.Priority 固定配置的(通常约定:立减 → 满减 → 折扣 → 免邮),回溯只搜索"选哪些券",不搜索"以什么顺序应用":
type PriceCalculator struct{}
// SimulateDiscount 按固定优先级顺序依次应用券,返回折扣总额
func (p *PriceCalculator) SimulateDiscount(order *Order, coupons []*CouponInstance) decimal.Decimal {
ordered := sortByPriority(coupons) // 按 Template.Priority 升序
price := order.OriginalAmount
totalDiscount := decimal.Zero
for _, c := range ordered {
tpl := c.Template
var d decimal.Decimal
switch tpl.Type {
case TypeFullReduction:
if price.GreaterThanOrEqual(tpl.Threshold) {
d = tpl.DiscountValue
}
case TypeFixedOff:
d = decimal.Min(tpl.DiscountValue, price)
case TypeDiscount:
d = price.Mul(decimal.NewFromFloat(1).Sub(tpl.DiscountValue))
case TypeFreeShipping:
d = order.ShippingFee
}
price = price.Sub(d)
totalDiscount = totalDiscount.Add(d)
}
return totalDiscount
}
8.4 工程优化:模板级缓存
回溯搜索虽然快,但既然是同步链路,能不算就不算更好。关键观察是:叠加判定和折扣计算只依赖 CouponTemplate,不依赖 CouponInstance——也就是说,不同用户只要持有的模板组合相同、订单金额落在同一区间,最优解就是一样的。
因此可以在模板维度做缓存:
- 对"高频券模板组合 × 订单金额区间"做离线/懒加载预计算,把结果(最优模板子集)缓存进 Redis;
- 线上请求先查缓存命中的"最优模板子集",再映射回用户手里对应的具体券实例;
- 只有缓存未命中(长尾组合、新上线的券模板)才走实时回溯搜索。
这个优化通常能把大促高峰期的平均计算耗时从个位数毫秒进一步降到亚毫秒级,同时显著降低 CPU 消耗。
9. 数据存储设计
9.1 核心表结构(MySQL)
-- 券模板表
CREATE TABLE coupon_template (
id VARCHAR(32) PRIMARY KEY,
name VARCHAR(128) NOT NULL,
type VARCHAR(32) NOT NULL,
discount_value DECIMAL(12,2) NOT NULL,
threshold DECIMAL(12,2) DEFAULT 0,
scope_type VARCHAR(16),
scope_target_ids TEXT, -- JSON 数组
category VARCHAR(32),
priority INT DEFAULT 0,
valid_from DATETIME,
valid_to DATETIME,
stock_total BIGINT,
stock_remain BIGINT,
created_at DATETIME,
updated_at DATETIME
);
-- 券实例表(用户券包),按 user_id 分库分表
CREATE TABLE coupon_instance (
id VARCHAR(32) PRIMARY KEY,
template_id VARCHAR(32) NOT NULL,
user_id VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL,
received_at DATETIME,
used_at DATETIME,
order_id VARCHAR(32),
INDEX idx_user_status (user_id, status)
);
-- 叠加策略表(一份策略可关联多个互斥组/依赖规则)
CREATE TABLE stacking_policy (
id VARCHAR(32) PRIMARY KEY,
max_total_coupons INT,
max_per_category TEXT, -- JSON: {"platform":1,"merchant":3}
updated_at DATETIME
);
CREATE TABLE exclusion_group (
id VARCHAR(32) PRIMARY KEY,
policy_id VARCHAR(32) NOT NULL,
template_ids TEXT -- JSON 数组,组内模板互斥
);
CREATE TABLE dependency_rule (
id VARCHAR(32) PRIMARY KEY,
policy_id VARCHAR(32) NOT NULL,
source_template_id VARCHAR(32),
require_template_ids TEXT,
conflict_template_ids TEXT
);
-- 规则配置表:对应 RuleDefinition,支持版本与灰度
CREATE TABLE rule_config (
id VARCHAR(32) PRIMARY KEY,
name VARCHAR(128),
rule_type VARCHAR(32), -- ELIGIBILITY/THRESHOLD/STACKING/PRICING
mode VARCHAR(16), -- expression / structured
expression TEXT,
structured TEXT, -- JSON
priority INT,
enabled TINYINT,
version INT,
scope_full TINYINT,
scope_shop_ids TEXT, -- JSON 数组
scope_traffic_pct INT,
status VARCHAR(16), -- DRAFT/PENDING_APPROVAL/APPROVED/PUBLISHED/ROLLED_BACK
created_by VARCHAR(32),
approved_by VARCHAR(32),
created_at DATETIME,
updated_at DATETIME
);
-- 规则发布审批流水,记录每一次状态流转
CREATE TABLE rule_publish_log (
id VARCHAR(32) PRIMARY KEY,
rule_id VARCHAR(32) NOT NULL,
version INT NOT NULL,
action VARCHAR(16), -- SUBMIT / APPROVE / REJECT / PUBLISH / ROLLBACK
operator VARCHAR(32),
remark TEXT,
created_at DATETIME
);
-- 核销流水,用于对账和审计
CREATE TABLE redemption_log (
id VARCHAR(32) PRIMARY KEY,
coupon_id VARCHAR(32) NOT NULL,
order_id VARCHAR(32) NOT NULL,
action VARCHAR(16), -- LOCK / REDEEM / ROLLBACK
created_at DATETIME
);
9.2 缓存策略(Redis)
| 缓存内容 | 用途 | 策略 |
|---|---|---|
| 券模板库存 | 大促场景下的原子扣减 | Redis + Lua 脚本保证扣减原子性,异步回写 MySQL |
| 用户券包(可用券列表) | 减少结算页查询延迟 | 领取/核销时主动失效,TTL 兜底 |
| 模板级最优组合结果(见 8.4) | 减少重复计算 | 按"模板组合签名 + 金额区间"作 key,定期刷新 |
| 叠加策略/规则配置 | 规则引擎加载 | 本地缓存 + 配置中心推送更新(Nacos/Apollo) |
10. 服务拆分与 API 设计
| 服务 | 职责 |
|---|---|
TemplateService |
券模板 CRUD、库存管理 |
WalletService |
用户领券、查询用户券包 |
StackingService |
可用券过滤 + 最优组合计算(第 8 节核心逻辑) |
RedeemService |
下单锁定、支付核销、取消/退款回滚 |
RuleAdminService |
运营后台:规则的向导配置、条件构建器、试算、审批与发布 |
关键接口(REST 风格示例):
GET /coupons/available?orderId=xxx
→ 返回该订单下用户所有"可用"的券(已过领取+门槛规则)
POST /coupons/optimal-combination
body: { orderId, candidateCouponIds[] }
→ 调用 StackingResolver.Resolve,返回最优组合 + 折扣金额
POST /coupons/lock
body: { orderId, couponIds[] }
→ 下单时锁定,状态 UNUSED → LOCKED
POST /coupons/redeem
body: { orderId }
→ 支付成功后核销,状态 LOCKED → USED
POST /coupons/rollback
body: { orderId }
→ 订单取消/退款,状态回滚为 UNUSED
-- 以下为 RuleAdminService,管理台专用 --
POST /admin/rules/draft
body: RuleDefinition (status=DRAFT)
→ 保存草稿,同时触发 DetectConflicts 做冲突检测,返回 warnings
POST /admin/rules/dry-run
body: { draftRuleIds[], mockOrder, mockCouponTemplateIds[] }
→ 用草稿规则编译出临时 RuleEngine,试算通过/拒绝原因及最优组合,不影响线上
POST /admin/rules/submit-approval
body: { ruleId, version }
→ DRAFT → PENDING_APPROVAL,写入 rule_publish_log
POST /admin/rules/approve
body: { ruleId, version, approverId }
→ 校验审批人≠提交人,PENDING_APPROVAL → APPROVED
POST /admin/rules/publish
body: { ruleId, version, scope }
→ APPROVED → PUBLISHED,写入 rule_config 并推送配置中心,支持先灰度后全量
POST /admin/rules/rollback
body: { ruleId, targetVersion }
→ 重新编译并 Swap 到目标历史版本
11. 高并发与一致性保障
11.1 库存扣减防超卖
采用 Redis + Lua 脚本做原子扣减(检查剩余库存 + 扣减在一条 Lua 脚本内完成,避免竞态),扣减成功后异步写入 MySQL 流水,定期对账修正。
11.2 核销幂等与状态机
券的生命周期建模为显式状态机:
UNUSED ──锁定(下单)──▶ LOCKED ──核销(支付成功)──▶ USED
▲ │
└──────回滚(取消/退款)───┘
每次状态流转都要求携带订单号做幂等校验(同一订单重复调用核销接口不能重复核销),并记录 redemption_log 用于审计。
11.3 跨服务一致性
下单锁券、支付核销、库存扣减往往涉及多个服务,推荐用 本地消息表 + 事务消息(如 RocketMQ 事务消息) 或 Saga 模式 保证最终一致性:支付成功事件驱动"核销"和"库存正式扣减"两个动作,任一失败都有补偿流程(重试 + 人工介入告警)。
12. 可扩展性设计
- 规则可插拔:新增规则类型只需实现
Rule接口并注册进对应的RuleType链,不侵入RuleEngine和StackingResolver(详见第 5 节); - 规则可配置:高频调整的门槛/叠加参数由管理台驱动,通过
RuleDefinition+ 配置中心热推送,不需要发版(详见第 5、6 节); - 券类型可扩展:新增券类型(如"阶梯满减")只需在
PriceCalculator.SimulateDiscount的 switch 分支中新增计算逻辑,并在CouponType枚举中注册; - 计算引擎可替换:
StackingResolver对外只暴露Resolve(order, candidates)接口,内部精确搜索/贪心/未来可能引入的整数规划求解器(如小规模场景接入开源 ILP 求解器)可以自由替换,不影响调用方。
13. 监控与可观测性
| 指标 | 说明 | 告警阈值建议 |
|---|---|---|
| 核销成功率 | 核销失败可能意味着资损或体验问题 | 失败率 > 0.1% 告警 |
| 库存扣减异常 | Redis/MySQL 库存不一致 | 出现即告警 |
| 最优组合计算耗时 P99 | 影响结算页体验 | P99 > 100ms 告警 |
| 降级(Degraded)比例 | 精确搜索超时走贪心的比例 | 比例持续 > 5% 需要排查候选券过多的原因 |
| 规则判定拒绝原因分布 | 定位哪条叠加规则最常触发拒绝,辅助运营优化规则 | 用于分析,非告警 |
| 规则编译失败次数 | 配置中心推送后编译失败,引擎未生效 | 出现即告警(见 5.4) |
建议在 RuleResult.Reason 和 ResolveResult.Degraded 上打点上报,便于事后分析"为什么用户没凑出更优惠的组合"。
14. 总结与后续演进
本设计的核心思路是把优惠券系统拆成三条相对独立的能力线:
- 规则引擎回答"能不能"的问题(领取资格、使用门槛、能否叠加),并通过存储与编译分离、热加载、灰度发布、冲突检测,支撑安全的规则变更;
- 运营管理台把规则配置能力开放给不懂代码的运营,通过向导表单、条件构建器/决策表、试算器、审批发布闭环,让"配错规则"在上线前就被拦下;
- 叠加计算引擎回答"怎样最优"的问题,建模为带约束的组合优化,用回溯剪枝求精确解、贪心兜底求近似解,并通过模板级缓存把大部分请求前置到亚毫秒级。
后续可演进方向:
- 当叠加规则进一步复杂化(如涉及跨订单、跨店铺的联合满减)时,可以考虑引入更通用的 ILP(整数线性规划)求解器替换手写回溯;
- 结合用户历史行为,在多个"折扣相同"的最优解之间做个性化选择(如优先消耗即将过期的券);
- 引入 A/B 测试能力,在规则引擎层面支持"规则版本"灰度发布,并与管理台的试算器联动,让运营可以直接对比新旧版本在真实流量下的效果差异。