跳至主要内容

优惠券系统架构设计

优惠券系统架构设计文档

版本:v1.1 适用场景:电商/O2O 类平台的优惠券发放、领取、叠加计算与核销


目录

  1. 背景与目标
  2. 需求概述
  3. 总体架构
  4. 核心领域模型
  5. 规则引擎设计
  6. 运营管理台设计
  7. 叠加规则设计详解
  8. 最优叠加方案计算(核心算法)
  9. 数据存储设计
  10. 服务拆分与 API 设计
  11. 高并发与一致性保障
  12. 可扩展性设计
  13. 监控与可观测性
  14. 总结与后续演进

1. 背景与目标

优惠券系统看似简单(发一张券、抵扣一点钱),但在实际业务中会迅速演变成规则最复杂、最容易出资损事故的模块之一,原因是:

  • 券的类型多(满减、折扣、立减、免邮、赠品券……),每种的计算方式不同;
  • 券与券之间存在复杂的叠加/互斥关系,且这些关系会随运营活动频繁调整;
  • 用户手里可能同时持有多张可用券,系统需要在毫秒级内帮用户/自动挑出"最划算"的组合;
  • 一旦规则出错(该互斥的没互斥、该叠加的没叠上),直接影响 GMV 和资损。

本设计的核心目标是:把"规则判定"与"组合寻优"两个能力做成独立、可配置、可插拔的引擎层,业务方(运营)只需要配置规则,不需要改代码就能上线新的叠加策略。


2. 需求概述

2.1 功能性需求

能力 说明
券模板管理 创建满减/折扣/立减/免邮/赠品等类型的券模板,设置门槛、适用范围、有效期、库存
发放与领取 支持主动领取、系统发放(下单赠送、会员权益)、定向发放
可用券查询 给定订单(商品、金额、店铺),查出用户当前可用的券列表
叠加规则判定 判断任意两张/多张券是否可以同时使用
最优叠加方案计算 在所有可用券中,自动计算出使订单实付最低(或折扣最大)的合法组合
核销 下单时锁定券、支付成功后核销、订单取消/退款后回滚
规则配置化 运营后台可视化配置叠加规则、互斥组,无需发版

2.2 非功能性需求

  • 低延迟:查询可用券 + 计算最优组合需在结算页同步返回,目标 P99 < 100ms;
  • 强一致性:券的锁定/核销/库存扣减不能超卖、不能被重复核销;
  • 高并发:大促场景下领券、查询是脉冲式流量;
  • 可扩展:新增券类型、新增叠加规则不应侵入核心链路代码;
  • 可审计:每一次核销、每一次规则判定要可追溯(为什么这两张券不能一起用)。

3. 总体架构

整体采用分层架构,核心是把"规则引擎"和"叠加计算引擎"作为独立的领域服务下沉,供交易链路调用:

                         ┌─────────────────────────┐
                         │        接入层             │
                         │  API Gateway / BFF        │
                         └────────────┬─────────────┘
                                      │
        ┌───────────────┬────────────┼────────────────┬──────────────┐
        ▼               ▼            ▼                ▼              ▼
 ┌─────────────┐ ┌─────────────┐ ┌───────────────┐ ┌───────────┐ ┌───────────┐
 │ 券模板/发放   │ │ 用户券包服务  │ │ 叠加计算服务    │ │ 核销服务    │ │ 运营后台API │
 │ TemplateSvc │ │ WalletSvc   │ │ StackingSvc   │ │ RedeemSvc │ │ AdminSvc  │
 └──────┬──────┘ └──────┬──────┘ └───────┬───────┘ └─────┬─────┘ └─────┬─────┘
        │               │                │               │             │
        └───────────────┴───────┬────────┴───────────────┴─────────────┘
                                 ▼
                    ┌───────────────────────────┐
                    │        规则引擎 RuleEngine    │
                    │  领取规则 / 门槛规则 / 叠加规则 │
                    │  / 计价规则                  │
                    └─────────────┬──────────────┘
                                  ▼
                    ┌───────────────────────────┐
                    │         数据与缓存层           │
                    │  MySQL(模板/实例/规则配置)     │
                    │  Redis(库存原子扣减/热点缓存)  │
                    │  规则配置中心(可选:Nacos/Apollo)│
                    └───────────────────────────┘

关键设计原则:

  1. StackingSvc(叠加计算服务)不直接依赖交易系统,只依赖"订单快照 + 候选券列表 + 规则",可以独立测试、独立压测、甚至可以离线跑批对账。
  2. RuleEngine 是纯函数式的判定组件,输入上下文、输出通过/不通过,不产生副作用,便于单元测试和规则回归。
  3. 核销(资损敏感操作)与计算(读多写少)物理拆分,核销走强一致链路,计算可以适度做缓存和降级。

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——也就是说,不同用户只要持有的模板组合相同、订单金额落在同一区间,最优解就是一样的。

因此可以在模板维度做缓存:

  1. 对"高频券模板组合 × 订单金额区间"做离线/懒加载预计算,把结果(最优模板子集)缓存进 Redis;
  2. 线上请求先查缓存命中的"最优模板子集",再映射回用户手里对应的具体券实例;
  3. 只有缓存未命中(长尾组合、新上线的券模板)才走实时回溯搜索。

这个优化通常能把大促高峰期的平均计算耗时从个位数毫秒进一步降到亚毫秒级,同时显著降低 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. 总结与后续演进

本设计的核心思路是把优惠券系统拆成三条相对独立的能力线:

  1. 规则引擎回答"能不能"的问题(领取资格、使用门槛、能否叠加),并通过存储与编译分离、热加载、灰度发布、冲突检测,支撑安全的规则变更;
  2. 运营管理台把规则配置能力开放给不懂代码的运营,通过向导表单、条件构建器/决策表、试算器、审批发布闭环,让"配错规则"在上线前就被拦下;
  3. 叠加计算引擎回答"怎样最优"的问题,建模为带约束的组合优化,用回溯剪枝求精确解、贪心兜底求近似解,并通过模板级缓存把大部分请求前置到亚毫秒级。

后续可演进方向:

  • 当叠加规则进一步复杂化(如涉及跨订单、跨店铺的联合满减)时,可以考虑引入更通用的 ILP(整数线性规划)求解器替换手写回溯;
  • 结合用户历史行为,在多个"折扣相同"的最优解之间做个性化选择(如优先消耗即将过期的券);
  • 引入 A/B 测试能力,在规则引擎层面支持"规则版本"灰度发布,并与管理台的试算器联动,让运营可以直接对比新旧版本在真实流量下的效果差异。

此博客中的热门博文

PyTorch学习路线图

  第一阶段:基础知识(2-3周) 第1周:Python与机器学习基础 Python数据结构与NumPy基础 张量概念与线性代数基础 机器学习基本概念 第2-3周:PyTorch核心 张量操作与计算图 自动微分(autograd)机制 数据加载与预处理(DataLoader, Dataset) 构建神经网络模块(nn.Module) 第二阶段:深度学习基础(4-6周) 第4周:线性模型与优化器 线性回归与逻辑回归实现 优化器(SGD, Adam等) 损失函数与评估指标 第5-6周:基础神经网络 多层感知机(MLP) 卷积神经网络(CNN) 循环神经网络(RNN, LSTM, GRU) 第7-8周:训练技巧 模型保存与加载 学习率调度 正则化技术 迁移学习 第三阶段:进阶应用(6-8周) 第9-10周:计算机视觉 图像分类 目标检测 图像分割 视觉Transformer 第11-12周:自然语言处理 词嵌入 序列到序列模型 Transformer架构 预训练语言模型应用 第13-14周:生成模型 自编码器 变分自编码器(VAE) 生成对抗网络(GAN) 扩散模型 第四阶段:工程实践(4-6周) 第15-16周:模型部署 模型量化与优化 TorchScript与ONNX导出 服务化部署 移动端部署 第17-18周:高级训练技术 分布式训练 混合精度训练 梯度累积与梯度裁剪 模型剪枝与蒸馏 第19-20周:项目实战 完整项目流程 模型性能优化 工业级代码实践

Elasticsearch 读写原理指南

### 1. 什么是 segment,里面装了什么? 在 Lucene(也是 Elasticsearch)里,索引被切分成若干 **segment(段)**,每个 segment 是一个完整的、只读的倒排索引单元。一个 segment 包含: * **倒排词典** —— 用 **FST(Finite‑State Transducer)** 以高度压缩的形式保存每个字段出现的所有 term 以及 term→ord 的映射。对应的磁盘文件是 `*.tim`(新版)或 `*.tis/*.tii`(旧版)。 * **倒排列表(postings)** —— 保存每个 term 出现的文档 ID、频次、位置信息等,文件名通常是 `*.doc`、`*.pos`、`*.pay`。 * **存储字段**(_source、store:true 的字段)—— 以二进制块的形式写入 `*.fdt` / `*.fdx`。 * **doc‑values、norms、向量** 等辅助结构,分别保存在 `*.dv`、`*.norm`、`*.tv` 等文件里。 * **deleted‑docs bitmap**(`*.del`),标记哪些文档已被删除或被更新。 所有这些文件在 segment **写入磁盘后即成为只读**,后续的查询只能读取,永远不会在原文件上进行增删改。 --- ### 2. 原始文档和 FST 为什么都在 segment 里? * **原始文档**:Elasticsearch 默认把完整的 JSON(_source)以及任何 `store:true` 的字段写入 segment 的 `*.fdt/*.fdx` 文件。每个 segment 保存自己的那部分文档,旧的 segment 在合并前仍然保留,直到合并后被删除。 * **FST**:每个字段的词典在每个 segment 中单独维护,采用 FST 进行前缀共享和字节压缩。这样即使同一个 term 在多个 segment 中出现,也会在每个 segment 里拥有独立的映射,查询时只需要在对应 segment 的 FST 中定位即可。 --- ### 3. 查询时到底是怎么遍历 segment 的? 1. **请求入口**      客户端的搜索请求先到达 **协调节点**,协调节点把请求 ...

LLM缓存详解

 可以把“大模型缓存”理解成: 把已经算过的结果(或中间结果)存下来,下次尽量复用 。但这里面其实分几层,不只是简单的“问题→答案”缓存。 1️⃣ 常见的几种缓存类型 (1)KV Cache(推理内部缓存) Transformer 在生成时,会把前面 token 的 Key/Value 向量 缓存下来。 本质:避免重复计算 attention 作用: 同一请求内部加速 特点: 👉 只对“同一上下文继续生成”有效 👉 不跨用户、不跨请求 这类缓存是你体感“流式输出越来越快”的原因之一。 (2)Prompt Cache(提示词缓存) 缓存的是: 相同(或高度相似)的 prompt → 对应的中间表示 / 输出 典型场景: 系统提示词(system prompt)很长 多轮对话里前文基本不变 👉 这里能省掉 前缀计算成本(prefill) (3)Embedding / 语义缓存(Semantic Cache) 这个才是你问题的关键 👇 不是按“字符串完全一致”,而是: 把问题转成向量 → 找“语义相似”的历史问题 → 直接复用答案 2️⃣ 为什么命中缓存成本低很多? 因为大模型推理成本主要在两块: (1)Prefill(吃 prompt) 复杂度 ~ O(n²) 很贵(尤其长 prompt) (2)Decode(逐 token 生成) 每个 token 都要算一遍模型 而缓存命中后: KV cache:不用重复 attention Prompt cache:不用重新 encode 语义缓存: 直接跳过模型推理 👉 相当于从: 几十~几百毫秒 + GPU算力 变成: 一次向量检索(毫秒级)+ 直接返回 所以成本差一个数量级是正常的。 3️⃣ “每个人问法不同,怎么命中缓存?” 这是核心难点,也是工程重点👇 ❌ 不能靠字符串匹配 比如: “今天天气怎么样” “今天外面热不热” 字符串完全不同 → 必须 miss ✅ 用语义相似度(Embedding) 流程一般是: 把问题转 embedding(向量) 在向量数据库里找 TopK 相似问题 如果相似度 > 阈值(比如 0.9) 直接返回缓存答案 一个简单示意 Q1: 北京天气怎么样 → embedding A Q2: 北京今天热吗 → embedding B cosine(A, B) ≈ 0.95...