跳至主要内容

优惠券系统架构设计

优惠券系统架构设计文档

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


目录

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

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 只承载"状态性"属性。叠加判定始终发生在模板层面,因为运营配置的互斥关系是针对"这一类券",而不是针对某一张具体的券实例——这个区分对后面第 7 节的性能优化很关键。


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 规则的配置化(可插拔)

为了让运营"配置规则而不是等发版",规则引擎支持两种规则来源:

  1. 硬编码规则:实现 Rule 接口的 Go 结构体,适合逻辑复杂、变动少的规则(如库存扣减校验);
  2. 表达式规则:规则的判定逻辑用表达式(如 order.amount >= 100 && coupon.category == "merchant")存储在数据库/配置中心,运行时用轻量表达式引擎(如 exprcel-go)解析执行,适合运营高频调整的门槛类规则。
type ExpressionRule struct {
    id         string
    ruleType   RuleType
    expression string // 存储在 DB/配置中心,后台可视化编辑
    program    *vm.Program // 预编译后的表达式,启动时/配置变更时编译
}

func (r *ExpressionRule) Evaluate(ctx *RuleContext) RuleResult {
    result, err := expr.Run(r.program, ctx)
    if err != nil || !result.(bool) {
        return RuleResult{Passed: false, Reason: "未满足表达式规则:" + r.expression}
    }
    return RuleResult{Passed: true}
}

两类规则统一实现 Rule 接口,对上层 RuleEngine 透明——这是插件化的关键:新增规则类型不需要改 RuleEngine,只需要实现接口并注册。


6. 叠加规则设计详解

这是整个系统里业务方最关心、也最容易表达不清的部分。实践中"能否叠加"通常可以用三层约束表达:

6.1 互斥组(Exclusion Group)

同一个互斥组内的券模板,最多只能选一张。例如"新人专享券"和"大促满减券"属于同一互斥组,二选一。互斥组是最常用、表达能力最强的机制,大多数叠加规则都可以归约成互斥组划分。

6.2 类型级别/全局限制

除了互斥组,通常还有"数量上限"约束,例如:

  • 平台券最多叠加 1 张;
  • 商家券最多叠加 3 张;
  • 一笔订单总共最多用 4 张券。

6.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}
}

MutualExclusionRuleStackLimitRuleDependencyRule 校验都注册进 RuleEngineRuleStacking 规则链,依次短路执行——业务上"是否可叠加"这一个问题,被拆成了三条独立、互不感知的小规则,新增一种叠加约束只需要新增一个 Rule 实现并注册,不改动已有代码。


7. 最优叠加方案计算(核心算法)

7.1 问题建模

给定用户在某订单下的候选券集合 C = {c1, c2, ..., cn},以及第 6 节的叠加约束,求一个子集 S ⊆ C,满足:

  • S 中任意两张券都不违反互斥组/依赖规则;
  • S 中每个分类的数量不超过上限,总数不超过全局上限;
  • 按结算优先级依次应用 S 中的券后,订单实付金额最小(折扣总额最大)

这本质上是一个带约束的组合优化问题:如果只有"互斥组 + 数量上限"两类约束,它接近分组背包问题(每组最多选一个 + 容量限制),多数场景下可以用动态规划精确求解;但一旦引入 DependencyRule 这种非对称的局部冲突,退化为通用图上的最大权独立集问题,理论上是 NP-Hard。

工程上的应对策略是:候选券数量在真实业务里通常很小(用户在一个订单下能同时满足门槛的券,过滤后一般不超过 15~20 张),因此用回溯 + 剪枝(branch and bound)做精确搜索完全可以在毫秒级完成;为极端情况兜底再加一个贪心降级方案 + 超时熔断

7.2 计算流程

候选券过滤(领取规则+门槛规则)
        │
        ▼
排序(按单张券折扣额降序,利于剪枝)
        │
        ▼
回溯搜索(叠加规则做可行性剪枝,已选+理论上限做最优性剪枝)
        │
        ├─ 在时间预算内求得精确解 ──▶ 返回最优组合
        │
        └─ 超时 ──▶ 贪心兜底(按折扣密度贪心选取) ──▶ 返回近似解(标记 Degraded)

7.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
}

7.4 工程优化:模板级缓存

回溯搜索虽然快,但既然是同步链路,能不算就不算更好。关键观察是:叠加判定和折扣计算只依赖 CouponTemplate,不依赖 CouponInstance——也就是说,不同用户只要持有的模板组合相同、订单金额落在同一区间,最优解就是一样的。

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

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

这个优化通常能把大促高峰期的平均计算耗时从个位数毫秒进一步降到亚毫秒级,同时显著降低 CPU 消耗。


8. 数据存储设计

8.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
);

-- 核销流水,用于对账和审计
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
);

8.2 缓存策略(Redis)

缓存内容 用途 策略
券模板库存 大促场景下的原子扣减 Redis + Lua 脚本保证扣减原子性,异步回写 MySQL
用户券包(可用券列表) 减少结算页查询延迟 领取/核销时主动失效,TTL 兜底
模板级最优组合结果(见 7.4) 减少重复计算 按"模板组合签名 + 金额区间"作 key,定期刷新
叠加策略配置 规则引擎加载 本地缓存 + 配置中心推送更新(Nacos/Apollo)

9. 服务拆分与 API 设计

服务 职责
TemplateService 券模板 CRUD、库存管理
WalletService 用户领券、查询用户券包
StackingService 可用券过滤 + 最优组合计算(第 7 节核心逻辑)
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

10. 高并发与一致性保障

10.1 库存扣减防超卖

采用 Redis + Lua 脚本做原子扣减(检查剩余库存 + 扣减在一条 Lua 脚本内完成,避免竞态),扣减成功后异步写入 MySQL 流水,定期对账修正。

10.2 核销幂等与状态机

券的生命周期建模为显式状态机:

UNUSED ──锁定(下单)──▶ LOCKED ──核销(支付成功)──▶ USED
   ▲                      │
   └──────回滚(取消/退款)───┘

每次状态流转都要求携带订单号做幂等校验(同一订单重复调用核销接口不能重复核销),并记录 redemption_log 用于审计。

10.3 跨服务一致性

下单锁券、支付核销、库存扣减往往涉及多个服务,推荐用 本地消息表 + 事务消息(如 RocketMQ 事务消息)Saga 模式 保证最终一致性:支付成功事件驱动"核销"和"库存正式扣减"两个动作,任一失败都有补偿流程(重试 + 人工介入告警)。


11. 可扩展性设计

  • 规则可插拔:新增规则类型只需实现 Rule 接口并注册进对应的 RuleType 链,不侵入 RuleEngineStackingResolver;
  • 规则可配置:高频调整的门槛/叠加参数用表达式规则或数据库配置驱动,运营后台可视化编辑,变更通过配置中心热推送,不需要发版;
  • 券类型可扩展:新增券类型(如"阶梯满减")只需在 PriceCalculator.SimulateDiscount 的 switch 分支中新增计算逻辑,并在 CouponType 枚举中注册;
  • 计算引擎可替换:StackingResolver 对外只暴露 Resolve(order, candidates) 接口,内部精确搜索/贪心/未来可能引入的整数规划求解器(如小规模场景接入开源 ILP 求解器)可以自由替换,不影响调用方。

12. 监控与可观测性

指标 说明 告警阈值建议
核销成功率 核销失败可能意味着资损或体验问题 失败率 > 0.1% 告警
库存扣减异常 Redis/MySQL 库存不一致 出现即告警
最优组合计算耗时 P99 影响结算页体验 P99 > 100ms 告警
降级(Degraded)比例 精确搜索超时走贪心的比例 比例持续 > 5% 需要排查候选券过多的原因
规则判定拒绝原因分布 定位哪条叠加规则最常触发拒绝,辅助运营优化规则 用于分析,非告警

建议在 RuleResult.ReasonResolveResult.Degraded 上打点上报,便于事后分析"为什么用户没凑出更优惠的组合"。


13. 总结与后续演进

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

  1. 规则引擎回答"能不能"的问题(领取资格、使用门槛、能否叠加);
  2. 叠加计算引擎回答"怎样最优"的问题,建模为带约束的组合优化,用回溯剪枝求精确解、贪心兜底求近似解,并通过模板级缓存把大部分请求前置到亚毫秒级。

后续可演进方向:

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

此博客中的热门博文

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...

事务的ACID是什么

 事务的 ACID 是数据库事务必须满足的四个基本性质,用来保证在并发和故障情况下数据的正确性与可靠性: A(Atomicity,原子性) 一个事务中的操作要么 全部成功 ,要么 全部失败回滚 ,不存在“只做了一半”的中间状态。 C(Consistency,一致性) 事务执行前后,数据库都必须处于 一致的合法状态 ,满足约束(如主键、外键、唯一性、业务规则等)。 I(Isolation,隔离性) 并发执行的多个事务之间 相互隔离 ,一个事务未提交的中间结果对其他事务不可见(具体强弱由隔离级别决定)。 D(Durability,持久性) 一旦事务提交成功,其结果会被 永久保存 ,即使系统崩溃也不会丢失(通常依赖 WAL/redo log 等机制)。 一句话记忆: 要么全做完、前后不破坏规则、互不干扰、做完不丢。