活动营销系统:玩法、权益发放、限量风控与高并发

为什么需要活动营销系统

互联网产品的增长靠三件事:拉新、促活、转化。获客成本越来越高,"花一笔营销预算,换用户完成一个关键行为" 是最直接、最可度量的增长手段:新人红包换注册,首单返现换第一笔交易,签到积分换每日打开,邀请奖励换社交裂变。 活动营销系统就是把这件事做成可规模化的能力。它要解决四个问题:

问题 没有统一系统时的表现 系统提供的能力
上线慢 每个活动从零开发,一个节日活动排期数周,错过营销窗口 活动类型模板化,常见玩法由运营配置上线,开发只做新玩法
资金风险 各业务自建发奖逻辑,限量、风控、对账能力参差不齐;配置多写一个 0、被羊毛党批量刷、并发超发,都会直接造成资金损失 统一的限量、预算、风控、熔断、对账,所有发奖经过同一套资金防线
扛不住峰值 节日红包、周年庆开始的几秒内流量是平时的几十到上百倍,自建系统容易雪崩 配置本地化、KV 主存储、异步化、过载保护、库存热点优化
效果说不清 奖发出去了,带来多少新用户、多少交易、花了多少钱,各算各的 统一的参与、发奖、到账数据和对账报表,按活动核算投入产出

这个系统的重要性来自一个事实:它直接花钱。活动玩法出错,损失的是用户体验;权益发放出错,损失的是真金白银, 而且往往在几分钟内就被刷空、难以追回。因此设计重心依次是:限量与预算的正确性、防重复发放、风控、熔断和对账, 其次是大促时的高并发,最后才是玩法的丰富程度。

系统范围

活动营销系统负责"用户参与一个活动,满足条件后拿到奖励"这件事:签到领积分、抽奖、邀请好友得红包、 组团瓜分奖金、完成首单返现、积分兑换商品。它由两部分组成:

  • 活动:玩法和规则。决定谁能参加、怎么参加、参加后该得什么
  • 权益:真正发出去的东西。红包、优惠券、积分、会员、实物、第三方卡券,以及它们的库存、预算、到账和对账

本文给出一套通用设计。表结构、接口字段、代码均为示意;文中出现的超时、间隔、比例等数值是示例取值,按业务调整。


1. 业务全景

1.1 活动玩法

玩法 用户动作 核心机制 关键难点
直接领取 点击领取 按用户分层映射奖品 限量、防重复领取
抽奖 消耗抽奖机会抽一次 奖池概率、保底、抽奖机会账户 概率正确、售罄降级、防刷
任务 完成指定行为(交易、浏览、分享) 任务进度、完成判定、任务 → 奖励映射 行为来源分散,完成判定的时效和准确
签到 每日签到 连续天数、断签重置、累计奖励 跨天边界、补签
邀请 / 分享 / 助力 分享链接,好友点击或注册 分享码、邀请关系绑定、助力次数 防自助、防刷号、关系归属
组团 / 拼团 创建或加入团,满员或达标成团 团状态机、团积分 防超员、一人一团、成团后瓜分
排行榜 / 竞赛 累计行为得分 实时排名、按名次发奖 同分排名、去重加分、热点
答题 / 游戏 答题、PK、小游戏 题库、匹配、得分 防作弊、匹配超时
阶梯 / 多选一 达到不同门槛选择奖品 阶梯配置、互斥选择 选择后不可反悔的一致性
交易激励 完成首单、定投、续存等交易 交易消息驱动资格判定和到账 撤单 / 退款时的冲正
积分兑换 用积分兑换商品 积分账户、兑换订单 扣积分与发货的分布式一致性

1.2 权益类型

权益 发放方式 到账特点
现金红包 转账到用户零钱或账户 真实出款,需要出款商户、资金单号、对账
优惠券 / 抵扣券 发券到券账户,交易时核销 有有效期,核销、退回、过期回收都要处理
积分 加到积分账户 有余额、流水、批次、过期
虚拟物品 会员、话费、游戏道具 调用第三方发货接口
第三方卡券 / CDKey 发码或调用合作方接口 合作方回调核销,需要对账
实物 登记收货地址,线下发货 物流状态回传
抽奖机会 / 任务机会 加到机会账户 作为另一个活动的"货币"
行业特有权益 金融场景有加息券、体验金、交易返现 往往依赖交易事件才能到账

1.3 两个关键区分

资格与到账。很多权益不是领取即到账:

  • 满减券领了之后要在下单时核销,才产生实际价值
  • "首单返 10 元"要等交易确认才到账,交易撤单还要收回
  • 实物领取后要走线下发货

所以发放分两个阶段:发资格(生成一条奖品记录,用户"拥有"了它)和发货 / 到账(真正把钱、券、物给到用户)。 两个阶段各自有状态、有失败和重试(第 6 节)。

活动与权益平台。活动玩法变化快、形态多,每个业务都在做;权益发放的限量、风控、对账要求高、逻辑稳定。 因此常见的分工是:各业务的活动系统负责玩法,统一的权益平台(也叫营销中台)负责发货、限量、预算、风控、熔断、对账。 活动系统调用权益平台发奖。本文两部分都覆盖。


2. 概念模型

erDiagram
    BUDGET ||--o{ ACTIVITY : 授权
    ACTIVITY ||--o{ PRIZE : 包含
    PRIZE }o--|| ITEM : 发放
    ITEM }o--|| DELIVERY_CHANNEL : 发货方式
    ACTIVITY ||--o{ LIMIT_RULE : 限量
    PRIZE ||--o{ LIMIT_RULE : 限量
    USER ||--o{ PARTICIPATION : 参与
    PARTICIPATION }o--|| ACTIVITY : 属于
    USER ||--o{ PRIZE_RECORD : 获得
    PRIZE_RECORD }o--|| PRIZE : 实例
    PRIZE_RECORD ||--o| DELIVERY_ORDER : 发货
概念 说明
预算 一笔立项资金,有金额、有效期、审批人。活动和奖品的花费不能超过预算
活动 玩法 + 时间 + 资格 + 奖品集合。活动类型决定玩法
奖品 活动里的一个奖项。一个奖品对应一种物品,带发放规则:数量、金额计算方式、有效期、到账方式
物品 真正发出去的东西(某面额红包、某批次券、某会员卡),带单价和发货方式
限量规则 某个维度(活动、奖品、用户、自然人)在某个周期(日、周、月、总)内的上限
参与记录 用户参加活动的记录,用于次数限制和查询
奖品记录 用户获得的一个奖品实例,由全局唯一的奖品码标识,有独立的状态机
发货单 一次真实出款或发货,有发货单号,用于下游幂等和对账

"活动 → 奖品 → 物品"三层的好处:同一个物品(如 5 元红包)可以被多个活动的奖品复用;物品层对接发货,奖品层管发放规则, 活动层管玩法。新增一种物品只需要实现发货,不影响活动逻辑。


3. 整体架构

flowchart TB
    subgraph 前端
        H5[活动页 H5 / App]
        ADMIN[运营管理端]
    end

    subgraph 接入层
        GW[接入网关<br/>登录态 / 签名 / CSRF<br/>命令字路由 / 过载保护]
        MQIN[消息接入<br/>交易、支付、任务事件]
    end

    subgraph 活动层
        ENGINE[活动引擎<br/>通用校验 + 玩法插件]
        LOTTERY[抽奖机会账户]
        TASK[任务服务]
        RANK[排行榜服务]
        GROUP[组团服务]
        SHOW[活动展示 / 查询]
    end

    subgraph 权益层["权益层(营销中台)"]
        PGW[权益网关<br/>业务接入鉴权]
        PRIZE[发奖服务<br/>发资格 / 发货]
        LIMIT[限量与预算服务]
        RISK[风控代理]
        FUSE[熔断服务 旁路]
        DELIVER[发货适配器<br/>红包 / 券 / 积分 / 虚拟物品 / 第三方]
        RESEND[补发服务]
    end

    subgraph 支撑
        RULE[规则引擎 / 标签服务<br/>资格与人群]
        MSG[消息通知<br/>站内信 / Push / 短信]
        CFG[配置中心 / 本地配置缓存]
    end

    subgraph 存储
        KV[(KV 存储<br/>奖品记录 / 计数 / 机会<br/>支持 CAS 与原子 incr)]
        ZSET[(Redis ZSet<br/>排行榜)]
        DB[(分布式 MySQL<br/>参与、奖品、账单、补发、配置)]
        ASYNC[异步写库服务]
    end

    H5 --> GW --> ENGINE
    MQIN --> ENGINE
    ENGINE --> LOTTERY & TASK & RANK & GROUP
    ENGINE --> RULE
    ENGINE --> PGW --> PRIZE
    PRIZE --> LIMIT & RISK
    PRIZE --> DELIVER
    PRIZE --> KV
    PRIZE --> ASYNC --> DB
    FUSE -. 读发奖记录 .-> DB
    PRIZE -. 读熔断状态 .-> FUSE
    RESEND --> DELIVER
    PRIZE --> MSG
    RANK --> ZSET
    LOTTERY & TASK & LIMIT --> KV
    ADMIN --> CFG --> ENGINE & PRIZE
    ADMIN --> RESEND
层 职责
接入层 统一入口:登录态校验、CSRF、签名、命令字路由、过载保护;另有消息接入,把交易、支付等事件转成活动请求
活动层 活动引擎做通用校验,按活动类型分派给玩法插件;抽奖机会、任务、排行榜、组团作为可复用的周边服务
权益层 发资格、发货、限量、预算、风控、熔断、补发;按物品类型接入各发货渠道
支撑 规则引擎和标签服务判断资格,消息服务发通知,配置服务管理活动配置
存储 KV 作为奖品记录和计数的主存储(高并发、支持 CAS),MySQL 做持久化、查询和对账,写库异步化

KV 为主、DB 为辅是高并发活动系统的常见选择:参与路径上的读写(限量计数、奖品记录、机会账户)都在 KV 上完成, 用 CAS 或原子自增保证并发正确;DB 通过异步写库服务持久化,用于查询、对账和修复。代价是 KV 和 DB 之间存在短暂不一致, 需要对账和"以 DB 修复 KV"的工具(第 10 节)。


4. 活动配置与生命周期

4.1 配置模型:通用字段 + 按类型扩展

活动类型有几十种,每种的配置都不同。把所有字段平铺到一张表里不可维护,常见做法是:

部分 内容 存储
通用字段 活动 ID、类型、名称、起止时间、状态、渠道、访问 token、预算、限量、资格与风控开关、功能开关位 活动表的普通列
类型配置 该类型特有的逻辑:抽奖的奖池和概率、任务的任务列表、阶梯的门槛 活动表的一个 JSON 列,按类型反序列化成对应结构

通用字段示例:

分组 字段
基础 act_id、act_type、name、channel(App / 小程序 / 全部)、token
时间 start_time、end_time
状态 state(见 4.2)
次数限制 单用户总次数、日次数、月次数;是否按自然人(身份证 / 手机号 / 银行卡)限制
预算 budget_fee(分)
资格 用户分层方式、人群包、客群规则、是否组团活动
风控 是否校验黑名单、羊毛党、风控评分;命中后的处理方式(拒绝 / 发指定奖品)
功能开关 位掩码:分享码当日有效、生日限制、展示中奖跑马灯、不记录参与列表……

奖品配置同样是"通用字段 + 按物品类型扩展":

通用字段 说明
prize_id、act_id、item_id 奖品、所属活动、发放的物品
总量、单用户上限、日上限 限量
金额计算方式 固定、区间随机、按交易金额比例
到账方式 直接发放、交易确认后到账、设置计划后到账
有效期 奖品(券)的使用期限
预算 奖品级预算

活动与奖品的关系写在类型配置里,例如直接领取活动配置"用户分层 → 奖品",抽奖活动配置"用户分层 → 奖池 [奖品, 概率]"。

token 是活动级的访问令牌,请求必须带上。活动 ID 通常是自增的,没有 token 时攻击者可以遍历活动 ID 直接调用领取接口。

4.2 状态与发布

stateDiagram-v2
    [*] --> 待审核: 运营创建
    待审核 --> 产品审核通过: 产品审核
    待审核 --> 产品驳回
    产品审核通过 --> 技术审核通过: 技术审核(正式生效)
    产品审核通过 --> 技术驳回
    产品审核通过 --> 灰度: 白名单灰度
    灰度 --> 技术审核通过: 全量
    技术审核通过 --> 下架
    灰度 --> 下架
    下架 --> [*]
  • 双重审核:产品审核看玩法和文案,技术审核看配置(金额、概率、限量、预算)是否合理。技术审核通过才正式生效
  • 灰度态:只对白名单用户生效,上线前在真实环境走通全流程
  • 测试库 → 生产库:运营在测试环境配置和验证,审核通过后由管理端把配置从测试库同步到生产库;只同步"已生效"或"灰度"状态的配置
  • 直接发钱类活动双人确认:直接给用户发现金的活动,除活动配置外,还要求在另一个系统中单独登记,两处都配置了才允许发放,防止单人误配置直接放钱

4.3 配置加载

活动引擎和发奖服务每次请求都要读配置,配置必须在本地内存里:

机制 做法
全量加载 进程启动时把所有生效活动、奖品、限量、消息模板配置加载到内存
双缓冲 两份配置对象,后台线程加载到备用那份,完成后原子切换下标;读路径无锁
定时重载 定时(如每几分钟)全量重载;紧急修改时由管理端触发立即重载
错峰 多台机器加载时刻加随机偏移,避免同时打数据库
预计算 加载时算好读路径需要的派生结构:抽奖的前缀和概率表、按活动组装好的限量规则
共享内存快照 配置同时写一份到共享内存,进程重启时先从快照恢复,缩短启动时间

4.4 活动玩法插件

活动类型多,参与流程的骨架却相同:通用校验 → 资格 → 风控 → 选奖 → 限量 → 发奖。用"基类 + 工厂 + 模板方法"组织:

// 示意代码,未实测
class BaseActivity {
 public:
  virtual ~BaseActivity() = default;

  // 模板方法:固定骨架,变化点交给子类
  Result Join(const JoinContext& ctx) {
    RETURN_IF_ERROR(GlobalCheck(ctx));        // 状态、灰度、时间、token、渠道
    RETURN_IF_ERROR(QualificationCheck(ctx)); // 人群包、客群规则、前置活动、组团成员
    RiskDecision risk = RiskCheck(ctx);       // 黑名单、羊毛党……
    if (risk == RiskDecision::kReject) return Error(kRiskReject);
    RETURN_IF_ERROR(CostJoinChance(ctx));     // 活动维度次数限制
    PrizeId prize = (risk == RiskDecision::kFixedPrize)
                        ? config_.risk_prize_id
                        : SelectPrize(ctx);   // 子类实现:映射 / 抽奖 / 阶梯
    return DoJoin(ctx, prize);                // 子类实现:发奖及玩法特有逻辑
  }

 protected:
  virtual PrizeId SelectPrize(const JoinContext& ctx) = 0;
  virtual Result DoJoin(const JoinContext& ctx, PrizeId prize) = 0;

  Result GlobalCheck(const JoinContext& ctx);
  Result QualificationCheck(const JoinContext& ctx);
  RiskDecision RiskCheck(const JoinContext& ctx);
  Result CostJoinChance(const JoinContext& ctx);

  ActivityConfig config_;
};

std::unique_ptr<BaseActivity> ActivityFactory::Create(const ActivityConfig& cfg) {
  switch (cfg.act_type) {
    case ActType::kDirect:  return std::make_unique<DirectActivity>(cfg);
    case ActType::kLottery: return std::make_unique<LotteryActivity>(cfg);
    case ActType::kTask:    return std::make_unique<TaskActivity>(cfg);
    case ActType::kInvite:  return std::make_unique<InviteActivity>(cfg);
    // ...
  }
  return nullptr;
}

新增一种玩法:新增一个子类和一个类型配置结构,在工厂注册。通用校验、风控、限量不用重写。 奖品侧用同样的结构:发奖基类定义"发资格 / 校验 / 发货 / 补发"等方法,每种物品一个子类(第 6.4 节)。


5. 参与流程

5.1 主流程

sequenceDiagram
    participant U as 用户
    participant GW as 接入网关
    participant ACT as 活动引擎
    participant RULE as 规则 / 标签
    participant RISK as 风控
    participant LIM as 限量
    participant PRZ as 发奖服务
    participant DLV as 发货渠道
    participant KV as KV
    participant DB as DB(异步)
    U->>GW: 参与活动(act_id、token)
    GW->>GW: 登录态、CSRF、过载保护
    GW->>ACT: 转发
    ACT->>ACT: 通用校验:状态 / 灰度 / 时间 / token / 渠道
    ACT->>RULE: 资格:人群包、客群规则、前置条件
    ACT->>RISK: 黑名单、羊毛党、风险评分
    ACT->>LIM: 扣活动次数(日 / 总)
    ACT->>ACT: 选奖(分层映射 / 抽奖 / 阶梯)
    ACT->>PRZ: 发奖(user、act、prize、业务单号)
    PRZ->>LIM: 奖品总量、单用户、自然人、预算
    PRZ->>KV: 写奖品记录(初始态)+ CAS 追加用户奖品列表
    PRZ->>DB: 异步落库
    PRZ->>DLV: 发货(直接到账类)
    PRZ->>KV: 更新奖品状态
    PRZ-->>ACT: 奖品记录
    ACT-->>U: 结果
    PRZ--)U: 异步通知(站内信 / Push)

5.2 各步骤要点

步骤 要点
通用校验 纯本地判断:配置在内存,灰度白名单也在内存
资格 人群包、BI 客群、规则引擎可能都要调外部服务。同一请求内多次判断的结果做请求级缓存
风控 优先级从高到低:黑名单 > 行业从业者等特殊人群 > 羊毛党 > 非白名单。结果不只是"拒绝",还可以是"放行但发指定奖品"(如给疑似羊毛党发最低档奖品),减少误伤投诉
用户分层 按标签把用户分为新客、老客、高价值、沉睡等,不同层映射不同奖品或奖池
次数限制 活动维度的日 / 总次数,按用户、按自然人
选奖 由玩法插件实现
发奖 调权益平台,带上业务单号作为幂等键

5.3 交易驱动的活动

"首单返现""定投满 3 期送券""续存加息"这类活动不由用户点击触发,而是由交易事件触发:

flowchart LR
    TRADE[交易系统] -- 申购成功 / 确认 / 撤单 / 到期 --> MQ[[消息队列]]
    MQ --> IN[消息接入]
    IN --> MATCH[匹配用户已获得的待到账奖品<br/>或判断是否满足活动条件]
    MATCH --> DEDUP{按交易单号<br/>是否处理过}
    DEDUP -- 否 --> PROVIDE[到账:计算金额、发货]
    DEDUP -- 是 --> SKIP[跳过]
    IN -- 撤单 / 退款 --> REVERSE[冲正:收回奖励、回退限量]
要点 说明
两阶段 用户先在活动页领到资格(奖品记录状态为"待到账"),交易成功的消息到来后再到账
消息去重 同一交易消息可能重复投递。以"交易单号 + 奖品码"判断是否已处理,已处理的交易单号记录在奖品记录里
冲正 交易撤单或退款时,收回已发奖励(扣回金额、作废券),回退限量计数
首单判定 "首单"需要查询用户历史交易,结果写入标记防止重复判定

6. 权益发放

6.1 奖品记录状态机

stateDiagram-v2
    [*] --> 初始: 发资格
    初始 --> 待到账: 需交易等外部条件
    初始 --> 发奖中: 发货(CAS 抢占)
    待到账 --> 发奖中: 条件满足
    发奖中 --> 成功: 发货成功
    发奖中 --> 失败: 不可重试的错误
    发奖中 --> 延迟补发: 可重试的错误 / 频控
    发奖中 --> 待外部发货: 由合作方异步发货
    待外部发货 --> 成功: 合作方回调
    延迟补发 --> 发奖中: 补发任务
    初始 --> 无效: 发货前校验不通过
    待到账 --> 过期: 超过有效期
    成功 --> 已冲正: 撤单 / 退款
    成功 --> [*]
    失败 --> [*]
    无效 --> [*]
    过期 --> [*]

6.2 发资格

sequenceDiagram
    participant C as 调用方
    participant P as 发奖服务
    participant L as 限量
    participant KV as KV
    participant A as 异步写库
    C->>P: 发资格(user、act、prize、业务单号)
    P->>P: 校验:活动时间、奖品时间、前置条件
    P->>KV: 读用户奖品列表(带 CAS 版本)
    P->>L: 扣限量:奖品总量、单用户、自然人、预算
    P->>P: 生成奖品码;子类计算金额等
    P->>KV: 写奖品记录(初始态)
    P->>KV: CAS 追加奖品码到用户奖品列表
    alt CAS 冲突
        P->>KV: 奖品记录置为清理态
        P-->>C: 失败,稍后重试
    end
    P->>A: 异步写奖品表
    P-->>C: 奖品码
要点 说明
奖品码 全局唯一:活动 ID + 机器标识 + 时间 + 线程号 + 序号 拼接,本地生成,不依赖中心发号器
先记录后发货 奖品记录写成功后才发货。发货失败时记录还在,可以补发;反过来先发货再记录,记录失败就无法追溯
用户奖品列表 KV 中按"用户 + 活动"存一个奖品码列表,用于"我的奖品"查询,也用于次数限制。用 CAS 追加,同一用户的并发请求只有一个能写成功
业务单号幂等 调用方传入业务单号。重复请求时返回已有奖品码,不重复扣限量

6.3 发货

sequenceDiagram
    participant P as 发奖服务
    participant KV as KV
    participant S as 发货渠道
    participant DB as 账单表
    P->>KV: 读奖品记录(带 CAS 版本)
    P->>P: 状态必须是 初始 / 待到账
    P->>KV: CAS:状态改为 发奖中(抢占,防并发重复发货)
    P->>P: 生成发货单号,先写回奖品记录
    P->>S: 发货(带发货单号)
    alt 成功
        P->>KV: 状态 → 成功
    else 可重试错误 / 频控
        P->>KV: 状态 → 延迟补发
        P->>DB: 写补发表
    else 不可重试错误
        P->>KV: 状态 → 失败
    end
    P->>DB: 写账单

防重复发货的三道保险:

  1. 状态 CAS 抢占:只有把状态从"初始"成功改成"发奖中"的那个请求才能发货,并发请求在 CAS 上失败
  2. 发货单号先落盘:发货单号在调用下游之前写入奖品记录,补发时复用同一个单号
  3. 下游按单号幂等:出款、发券接口以发货单号去重,同一单号重复请求只执行一次

只有下游明确返回"该单号已作废,需换新单号"这类错误时,才生成新单号。

超时怎么办:调用下游超时,结果未知。此时不能认为失败而用新单号重发(可能重复打款), 应进入补发,用原单号重试或查询,由下游幂等保证最多执行一次。

6.4 物品与发货适配

发奖服务用"基类 + 每种物品一个子类"组织发货逻辑:

方法 作用
OnTicket 发资格阶段的物品特有逻辑,如计算随机金额
Check 发货前校验,如交易金额是否满足门槛
Provide 真正发货
RetryProvide 补发
OnReverse 冲正(可选)

接入新物品有两种方式:

方式 说明 适用
标准化发货 平台定义标准发货协议,物品提供方实现。请求包含 seq、billno(需支持重复请求)、user_id、item_id、amount,响应包含 ret 和 errmsg;签名和加密使用业务接入时分配的密钥 新接入的物品。平台新增物品类型不需要开发
适配发货 平台为已有的发货接口写适配代码 存量接口、定制化多的接口

物品表记录物品类型(实物 / 虚拟 / 内部券)、单价、所属业务、发货方式和发货接口信息(地址、是否签名加密),并有审核状态。

6.5 优惠券

券是最复杂的一类权益:有批次、有核销、有资金流、有过期回收。

券批次

字段 说明
批次 ID、名称、使用说明
面额、最低使用金额 单张券的金额(分)和使用门槛
总数量、总预算 发券上限
已发 / 已绑定 / 已核销 / 已过期数量与金额 统计与对账
有效期 开始、结束时间
券分类 领取即核销、领取后核销等(见下表)
使用类型 下单抵扣、提现抵扣、赠送现金、加息等
预算立项信息 立项单号、立项金额
状态 创建 → 提交审核 → 审核中 → 已审核 → 过期处理中 → 已过期;作废

券分类

分类 说明 过期回收
领取即核销 领取时直接到账,如现金红包 不需要
领取后核销 领取后在交易中使用:兑换券(满足资格后兑换)、交易后一步核销(交易完成后自动核销)、交易中两步核销(下单时锁券、支付成功后确认) 需要,由券所属系统负责
任务积分券 状态由活动侧维护 由活动侧发起
平台托管券 状态由公司统一的券托管平台维护 在托管平台发起
合作方券 用户跳转外部系统兑现,或由合作方线下发货 不需要

券的状态机

stateDiagram-v2
    [*] --> 待领取
    待领取 --> 已领取: 领取成功
    待领取 --> 领取失败
    已领取 --> 已绑定: 下单锁券
    已绑定 --> 已领取: 解绑(订单取消)
    已绑定 --> 使用请求: 支付
    使用请求 --> 已扣款: 资金划转
    已扣款 --> 使用成功: 交易确认
    使用请求 --> 使用失败
    已扣款 --> 资金退回: 交易失败 / 退款
    资金退回 --> 退回成功
    已领取 --> 过期请求: 到期
    过期请求 --> 已过期
    领取失败 --> [*]
    使用成功 --> [*]
    使用失败 --> [*]
    退回成功 --> [*]
    已过期 --> [*]

资金流

flowchart LR
    BUDGET[活动资金账户<br/>预算充值] -- 批次审核通过 --> COUPON[券资金账户]
    COUPON -- 核销:抵扣部分 --> MCH[收款方 / 商户]
    MCH -- 退款 / 交易失败 --> COUPON
    COUPON -- 批次过期:未使用金额 --> BUDGET
  • 批次审核通过时,从活动资金账户把"面额 × 数量"转入券资金账户;每笔转账有单号,记录在批次上
  • 核销时从券资金账户付给收款方;交易撤销时退回券资金账户
  • 批次过期后,未使用部分退回活动资金账户
  • 每张券的交易记录保存资金单号、退回单号、关联交易单号,用于对账

券的日常处理

处理 说明
核销触发 订阅交易结果消息,找出用户待核销的券,调用核销
过期回收 定时扫描到期未使用的券,置为过期,回收资金
中间态补单 已领取未过期、却长时间停在中间态(如"使用请求")的券,定时查询下游结果并推进到终态
日终对账 与权益平台对账:已领取、已核销、过期回收三类数量与金额
历史迁移 券交易表按用户分库分表,定期把终态的历史数据迁出

券交易表的唯一键是"发券方单号"(发券方按规则生成,保证不重复),用于发券幂等。

6.6 补发

发货失败后有三条补发链路:

链路 触发 实现
自动补发 发货返回可重试错误、超时、或被本机频控拦截 写补发表(按"用户 + 奖品码"去重,设过期时间如 5 天);补发线程定时扫描;指数退避重试(如从 60 秒开始翻倍,上限 1 小时);过期未成功转人工
延迟发送 需要在指定时间发放的奖品 延迟发送表 + 扫描线程,状态 BEGIN → DOING → SUCC
人工批量补发 运营发现问题后批量补发 管理端生成补发配置和补发单,审核通过后执行

补发任务的抢占:多台机器同时扫描补发表,用条件更新抢占:

UPDATE resend_bill SET status = 1, owner = ?
WHERE id = ? AND status = 0;   -- 影响行数为 1 才处理

批量补发任务用分布式锁(SET key value NX PX ttl,释放时用 Lua 脚本比较值后再删除),保证同一个补发批次只有一台机器执行。

补发前再次读取奖品记录,只有处于"发奖中 / 失败 / 延迟补发"的记录才补发,避免已经成功的奖品被重复发放。


7. 玩法实现

7.1 抽奖

选奖

配置:奖池 = [(奖品 A, 概率 100), (奖品 B, 概率 2000), (谢谢参与, 概率 99997900)],基数 1e8
加载时:前缀和表 = [100, 2100, 100000000]
抽奖时:r = rand() % 1e8 + 1;找到第一个前缀和 ≥ r 的奖品
要点 说明
概率基数 用整数基数(如 1e8)表示概率,避免浮点误差,支持万分之一以下的小概率
预计算 前缀和表在配置加载时计算,抽奖时二分或线性查找
随机数 每线程独立种子的随机数生成器,避免全局锁
保底 首抽、第二抽固定奖品;第 N 抽固定奖品;按顺序先判断保底再走概率
分层奖池 按用户分层选择不同奖池;风控命中用户使用专用奖池
售罄降级 抽中的奖品因总量或单用户上限发不出时,降级发"末等奖",用户体验上仍然"中奖"
互斥 某些奖品不能同时持有(如同类翻倍卡),抽中时检查,冲突则降级

概率不等于实际发放比例:概率抽奖在库存有限时,头部奖品可能在活动开始几分钟就被抽完。需要均匀放量时, 按时间段给奖品设置库存(如每小时放 100 个),或按剩余库存和剩余时间动态调整概率。

抽奖机会账户

key = lottery_chance:{act_id}:{user_id}
value = { 总获得, 总消耗, 当日消耗, 当日日期, 本周消耗, 本周编号 }
  • 获得机会:参与任务、分享、交易等,由其他活动或奖品"加机会"
  • 消耗机会:检查总、日、周三个维度的剩余次数,CAS 写回
  • 跨天、跨周:读取时比较日期,自然重置当日计数,不需要定时任务清零

7.2 任务

任务系统有两种模型:

模型 做法 优点 缺点
拉模式 用户点击领奖时,实时查询业务系统判断任务是否完成 简单,不需要接入事件 每次领奖都要查业务系统;只能判断当前状态,不适合累计型任务
推模式(事件驱动) 业务系统发布行为事件,任务服务订阅,按事件更新任务进度,达成后自动发奖或点亮领取按钮 支持累计、连续、组合任务;实时 需要事件接入和进度存储;事件乱序、重复需要处理

事件驱动的任务服务:

flowchart LR
    EV[行为事件<br/>交易 / 浏览 / 分享 / 签到] --> MQ[[消息队列]]
    MQ --> TS[任务服务]
    TS --> MATCH[按事件类型找到<br/>用户参与中的任务]
    MATCH --> DEDUP{事件 ID<br/>已处理?}
    DEDUP -- 否 --> PROG[更新进度 KV<br/>CAS]
    PROG --> DONE{达成?}
    DONE -- 是 --> REWARD[发奖或标记可领取]
要点 说明
任务定义 事件类型、条件(金额 ≥ X、指定商品)、目标值、周期(一次性 / 每日 / 每周)、奖励
进度存储 task_progress:{task_id}:{user_id} → 当前值、周期编号、已处理事件 ID 集合
幂等 同一事件只计一次进度(事件 ID 去重)
任务组 规则引擎按用户画像下发一组任务,组内按取模或随机分配;达到上限的任务从组中剔除
做任务机会 另一种简化形态:任务完成后获得"机会",用机会换奖励;机会账户与抽奖机会同构

7.3 邀请、分享、助力

要点 做法
分享码 服务端生成加密分享码,明文包含 `邀请人
两级限量 单个好友对同一分享码的助力次数;同一分享码的总助力次数
防自助 解析分享码后判断是否是自己的码
关系绑定 邀请关系首次绑定后不可更改(先到先得),存"被邀请人 → 邀请人"
奖励时机 被邀请人完成关键行为(注册、首单)后才给邀请人奖励,防止刷号
反作弊 设备指纹、同 IP / 同设备大量新号、被邀请人质量分,命中则不给奖励

7.4 组团

stateDiagram-v2
    [*] --> 组团中: 团长创建
    组团中 --> 已成团未满员: 达到最少人数
    组团中 --> 已满员: 达到最大人数
    已成团未满员 --> 已满员: 继续加入
    组团中 --> 系统成团: 到期由系统补足
    组团中 --> 失败: 到期未达最少人数
要点 做法
防超员 条件更新:UPDATE team SET member_num = member_num + 1 WHERE team_id = ? AND member_num < max,看影响行数
一人一团 成员表以"活动 + 用户"为唯一键
原子性 "人数 + 1"和"插入成员记录"必须在同一个数据库事务里;分两步执行时,插入失败会导致人数多算,出现"显示满员实际没满"
团积分 团内成员行为(分享、交易)折算成团积分,写入团排行榜
邀请有效期 邀请链接带过期时间
团名 唯一、过敏感词检测

7.5 排行榜

要点 做法
存储 Redis ZSet:rank:{act_id},member 为用户,score 为分数
加分 ZINCRBY
排名 普通排名 ZREVRANK + 1;同分同名次时名次 = ZCOUNT(key, (score, +inf) + 1,即分数严格大于自己的人数加 1((score 表示开区间)
去重 按交易单号加分,已处理的单号要记录
原子性 "记录单号已处理"与"加分"必须原子。先记单号再加分时,加分失败后重试会被判为重复,分数永久丢失。用 Lua 脚本把"检查单号 + 加分 + 记录单号"合成一个原子操作
同分先后 需要"先达到者排前"时,score 编码为 分数 × 10^k + (MAX_TS − 时间戳)
读一致性 读写分离时刚写完读从库可能拿到旧排名;用户查自己名次时读主库
TopN 缓存 榜单 TopN 在进程内存缓存几分钟,个人名次实时查
大榜 参与人数极多时只维护前 N 名的精确排名,其余给出区间(如"前 10%")

7.6 积分与积分商城

积分账户

表 内容
账户汇总 余额、累计获得、累计消费、累计过期、最后检查时间
积分批次 每次获得一条:数量、剩余、过期时间、状态(可用 / 用完 / 过期)
流水 每次变动一条:变动类型、数量、变动前后余额、业务单号、业务类型
操作 做法
加积分 按业务单号查重 → 开事务 → SELECT ... FOR UPDATE 锁住用户账户 → 日限量和预算检查 → 插入批次和流水 → 更新余额 → 提交
扣积分 同样加锁;按过期时间先到先扣(FIFO)扣减各批次,扣减明细记在流水里,退款时按明细退回原批次
过期 查询余额时惰性检查:把已过期批次置为过期,写过期流水。不需要在过期时刻跑大批量任务
退还 兑换失败时按原扣减明细退回
一致性校验 每日校验:流水的前后余额是否首尾相接;余额是否等于可用批次剩余之和。不一致时以批次为准校准并告警
预算 按业务累计发放的积分,超过预算拒绝发放

积分兑换

兑换涉及库存、积分、发货三个系统,采用"预留 + 确认 + 补偿":

sequenceDiagram
    participant U as 用户
    participant O as 订单服务
    participant S as 库存
    participant P as 积分账户
    participant D as 发货
    U->>O: 兑换商品
    O->>O: 建单(初始)
    O->>S: 预扣库存(条件更新 + 预扣记录,同一事务)
    O->>P: 扣积分(业务单号 = 订单号)
    O->>D: 发货(发货单号 = 订单号)
    O->>O: 订单 → 成功
    Note over O: 异常订单扫描:<br/>无预扣记录 → 作废<br/>扣积分失败 → 回滚预扣、作废<br/>扣积分成功未发货 → 补发货<br/>重试超限 → 告警转人工

库存预扣:

UPDATE goods_stock SET used = used + 1
WHERE goods_id = ? AND used < total;          -- 影响行数为 1 才成功
INSERT INTO stock_prelock (order_id, goods_id, ...) VALUES (...);   -- 同一事务

单用户兑换频次限制:对"用户锁"行 SELECT ... FOR UPDATE 使同一用户的兑换串行化,再统计已有订单数。


8. 限量与预算

8.1 限量维度

维度 例子
活动 × 用户 每人每天参与 3 次,总计 10 次
奖品总量 一等奖共 100 个
奖品 × 用户 每人最多获得 1 张券;日、周、月上限
自然人 同一身份证 / 手机号 / 银行卡只能领 1 次(防多账号)
分享码 单个好友助力次数、分享码总助力次数
群组互斥 同一组内的多个活动,用户只能参加其中一个
活动预算 活动累计发放金额不超过预算
业务预算 某业务线累计发放的积分 / 金额不超过预算

8.2 实现方式

方式 做法 一致性 性能 适用
KV CAS 计数节点 读节点(含日 / 月重置时间戳)→ 判断 → 带版本写回 并发冲突时失败,不会超 好 用户维度的日 / 月 / 总限量
KV 原子自增 INCR 后判断是否超限;超限则不发(可选择回退计数) 不会超;不回退时计数偏大,效果"宁少发不超发" 优 奖品总量、全局计数
记录计数 统计用户在该活动下已有奖品码数量,不单独维护计数器 依赖奖品列表的 CAS 写回兜底 好 避免计数器与记录不一致
DB 条件更新 UPDATE ... SET cnt = cnt + 1 WHERE id = ? AND cnt < limit,看影响行数 强一致,支持跨城容灾 差(单行热点写串行) 高价值奖品
分批预算授权 预算按批次授权(如每批 50 万),消耗到 80% 告警提醒追加;未追加则耗尽后停止发放;调低预算不能低于已消耗额度 控制单次失控的上限 — 活动总预算

按奖品价值选择:低价值、大量的奖品用 KV(性能优先);高价值奖品用 DB 条件更新(一致性优先,KV 异步复制在故障时可能丢更新)。

多层兜底:活动系统的限量 → 权益平台的奖品限量 → 预算限量 → 业务自身的限量。任何一层都不应被当成唯一保证: 权益平台做不到跨城容灾时,业务侧必须保留自己的限量。

8.3 回退与冲正

场景 处理
扣完限量后发奖失败 需要回退限量计数,否则库存泄漏(计数多扣,实际少发)
交易撤单、退款 回退限量、收回奖励
券核销后退款 券回到可用状态或作废,按规则回退

限量扣减和发奖分属不同存储,无法放进一个事务。做法是:扣减成功后再发奖;发奖确定失败时调用回退接口; 回退失败记录下来,由对账修正。库存泄漏的方向是"少发",比"超发"安全。

8.4 热点库存

一个爆款奖品的总量计数器是单个热点 key,大促时所有请求都打到它:

手段 做法
库存分桶 总库存拆成 N 个子 key,请求随机或按用户哈希选择一个桶扣减;某个桶扣完再尝试其他桶
本地预取 每台机器从中心库存批量领取一段(如 100 个)到本地,本地扣减;用完再领。机器宕机会损失本地剩余,需要回收机制
前置过滤 库存耗尽后在本地内存标记"已售罄",后续请求直接返回,不再访问存储
展示与扣减分离 活动页展示的剩余库存从本地缓存读(定时如每 10 秒刷新),只有真正领取才访问库存存储

9. 风控

9.1 分层

阶段 手段
事前 资格(人群包、客群规则、新老用户)、黑名单、羊毛党识别、设备风险、行业从业者等特殊人群限制、活动 token
事中 防并发(按用户、身份证、手机号加短时锁)、频控、自然人限量、分享码校验
事后 熔断(发放异常时自动停止)、对账、离线识别作弊并追回

9.2 风控代理

风控规则通常由专门的风控团队或 BI 维护(羊毛党、防水墙、社交平台黑名单等),活动系统只做一个代理服务调用统一接口:

  • 输入:用户、业务方、要查的风控项列表
  • 输出:每个风控项的结果
  • 风控项可能增加,接口耗时要有上限(如 200ms),超时按配置降级

命中风控后的处理方式可配置:

处理 适用
拒绝参与 高风险用户
发指定奖品 疑似风险用户:给最低档奖品,既控制损失又减少误伤投诉
放行 风控服务故障时,结合白名单兜底放行(业务可用性优先),或全部拒绝(资金安全优先),按活动配置

9.3 防并发

同一用户的并发请求(连点、客户端重试)要在限量和发奖之前拦住:

SET lock:{user_id}:{act_id}:{prize_id} 1 EX 3 NX
    成功 → 继续流程,结束后删除
    失败 → 已有相同请求在处理,直接返回

开启自然人防刷时,对身份证号和手机号各加一次锁。锁的过期时间是异常时的兜底,正常流程结束时主动删除。

9.4 自然人防刷

一个人可以有多个账号。按身份证号或手机号识别自然人:

  1. 预先把存量用户的"账号 → 身份证 / 手机号"映射导入 KV(量级估算:10 亿用户 × 100 字节 ≈ 100GB),设置有效期;未命中时实时查询补全
  2. 发奖前按"身份证或手机号 + 活动 + 奖品"查计数,≥ 1 则拦截
  3. 发奖成功后计数加一

这是"尽量保证"的方案:检查和记录之间有并发窗口,记录失败也会漏拦;严格的自然人限量需要以身份证为 key 做限量扣减。

9.5 熔断

熔断是营销事故的最后一道防线:配置错误(面额多写一个 0)、被刷、逻辑漏洞,都会表现为某个奖品的发放量异常。

sequenceDiagram
    participant ADM as 管理端
    participant PRZ as 发奖服务
    participant DB as 数据库
    participant F as 熔断服务
    ADM->>DB: 配置规则(奖品、周期、统计方式、阈值)
    loop 每次发奖
        PRZ->>DB: 写发奖记录
    end
    loop 每个周期(如 5 分钟)
        F->>DB: 统计周期内各奖品发放量
        F->>F: 与阈值、上周期(环比)、昨天同时段(同比)比较
        F->>DB: 超限:写熔断记录(批次号)并告警
    end
    PRZ->>DB: 定时加载熔断记录
    PRZ->>PRZ: 命中熔断的奖品停止发放<br/>请求上下文写入待补发表
    ADM->>DB: 人工确认:误判则触发补发
设计 原因
旁路系统,只通过 DB 交互 不改动发奖主流程;熔断服务故障不影响发奖
分钟级统计 运营熔断能接受一定时间误差,晚一分钟熔断可以接受,换来实现简单
同比 / 环比 / 绝对值 绝对值防总量失控;环比防突增;同比排除每天固定的高峰
批次号 + 补发 熔断可能误判,被拦下的请求保存上下文,人工确认后补发,用户不受损失

10. 一致性、幂等与对账

10.1 幂等键

环节 幂等键 作用
发资格 业务单号 调用方重试不重复发资格
奖品记录 奖品码 KV 主键、DB 唯一键
发货 发货单号(先落盘,补发复用) 下游出款 / 发券去重
账单 账单号 账单表唯一键;重复写入时容忍唯一键冲突
交易消息 交易单号 + 奖品码 消息重复投递不重复到账
补发 用户 + 奖品码 补发表去重
积分变动 业务单号 积分流水去重
券发放 发券方单号 券交易表唯一键
批量任务 批次状态条件更新(旧状态 → 新状态) 批次不被重复执行

10.2 KV 与 DB 的一致性

问题 做法
KV 写成功、异步写 DB 失败 异步写库服务重试;对账发现 DB 缺失时从 KV 补
DB 有、KV 丢失(KV 故障) 管理端提供"以 DB 修复 KV"的工具
高价值奖品 发资格时同步写 DB(先写 DB 再发货),牺牲一些性能换可靠性
同一用户并发 用户奖品列表 CAS 写回;CAS 失败时把刚写入的奖品记录置为清理态

10.3 对账

对账 内容
发放流水 ↔ 出款流水 奖品核销流水与商户出款流水按流水号双向关联,一一对应
预算 按预算立项单号汇总已核销金额,不超过立项金额
活动 按活动汇总花费,不超过活动预算
商户账户 入账总额 = 余额 + 各活动花费总额
券 日终与券系统核对已领取、已核销、过期回收的数量和金额
积分 流水连续性、余额 = 批次之和
预算缓存 预算计数缓存在 KV,定期与 DB 实际发放合计比对:间隔读两次缓存,两次一致时取 DB 在该时刻的合计,不一致则以 DB 为准修正缓存
补发 补发单是否生成完整;单笔金额是否超过上限
按机构记账 每笔成功发放按出资机构记账,便于结算

11. 高并发与高可用

大促活动(节日红包、周年庆)的流量特征是瞬时峰值:活动开始的几秒内请求量是平时的几十到上百倍。

11.1 手段

手段 做法
配置本地化 活动、奖品、限量配置全在进程内存,读路径不访问配置库
KV 为主存储 参与路径的读写都在 KV 上,DB 异步写
按用途拆分 KV 集群 可过期数据(限量计数、防并发锁)和不过期数据(奖品记录)分开存放,分散压力,也便于设置不同的淘汰策略
异步化 接入层异步 IO;写库异步;消息通知异步、失败不影响主流程;到账由消息驱动
削峰 发货侧按出款渠道做本机频控(如每秒 120 笔),超出部分写入补发表,由补发线程平滑处理
库存优化 分桶、本地预取、售罄标记(第 8.4 节)
降级 发不出的奖品降级为末等奖;非核心展示(跑马灯、排行榜 TopN)读缓存;风控超时按配置降级
预热 活动开始前加载配置、预建连接、预热缓存
路由 按用户 ID 一致性哈希路由,同一用户的请求落到同一实例,提高本地缓存命中,降低同用户并发冲突

11.2 接入层过载保护

接入层按排队时间判断过载,按优先级丢请求:

过载判定:统计周期内平均排队时间超过阈值(排队时间 = 开始处理时间 − 收到请求时间)
          或出现队列超时、队列溢出;下游回包带回自己的排队时间,用于判断下游过载

请求优先级 = 业务优先级(命令字配置,如 3 档)× 1024 + 用户优先级
用户优先级 = hash(user_id + 当前小时) & 1023      每小时轮换,避免总是同一批用户被拒
未登录用户使用最低优先级

过载时:按优先级直方图从低到高丢弃,直到放行量不超过期望容量
恢复时:逐步放开;容量估算以过载周期内的正常回包数为候选值,恢复时从估算容量的 80% 开始加量
设计 原因
看排队时间而不是耗时 请求耗时长不一定是过载(有的请求本来就慢),排队说明处理不过来
按优先级丢而不是随机丢 核心命令字(领取、发奖)优先保证;被拒的是固定的一部分用户,同一用户的请求要么都成功要么都失败,而不是所有用户"时好时坏"
用户优先级按小时轮换 保证公平,不让同一批用户一直被拒

12. 管理端与运营工具

功能 说明
活动配置 创建活动和奖品、配置玩法、预览、灰度白名单
审核 产品审核、技术审核;直接发钱类活动双人确认
配置同步 测试库配置审核后同步到生产库,服务定时或立即重载
预算管理 预算立项、分批授权、消耗告警
查询 按用户查参与记录、奖品记录、限量计数节点、黑名单状态
补发 单笔补发、批量补发(导入名单 → 审核 → 执行,批次状态条件更新)
名单直发 按导入的名单批量直接发奖,每个用户的发放状态独立记录
修复 以 DB 修复 KV、修改奖品记录状态、重新发货
熔断 配置规则、查看熔断记录、确认补发
对账报表 每日对账结果、差异明细、按机构统计
监控告警 发放量、失败率、补发积压、预算消耗、商户账户余额

13. 常见缺陷与设计要点

缺陷 后果 正确做法
先扣限量再发奖,发奖失败不回退 库存泄漏,实际发放少于配置 发奖确定失败时回退;回退失败由对账修正
先读后判再写(检查与写入分离) 并发时超发 CAS、原子自增或条件更新,判断和扣减在一个原子操作里
CAS 冲突直接返回失败 高并发下正常用户大量失败 对幂等的操作做有限次重试;或把冲突转化为"系统繁忙,请重试"
去重记录与业务操作非原子(先记单号再加分) 加分失败后重试被判重复,永久丢失 用 Lua 脚本或事务把"检查 + 操作 + 记录"合成原子操作
多步数据库操作不在事务里(先加人数再插成员) 计数与记录不一致 同一个库内的多步修改放进一个事务
发货超时后用新单号重发 重复打款 复用原发货单号,由下游幂等
读写分离下写后立即读从库 读到旧数据(如刚加的分没有体现在排名上) 需要强一致的读走主库
发资格只写 KV、DB 异步 KV 故障时丢记录 高价值奖品同步写 DB;异步写库有重试和对账
参与请求没有幂等键 重复点击只能靠限量兜底 由前端生成请求 ID,或由服务端按"用户 + 活动 + 时间窗"去重
配置、脚本里写明文数据库账号密码 泄露风险 使用密钥管理服务,配置文件只存引用
活动 ID 可遍历、接口无 token 被直接调用领取 活动 token、奖品 token、请求签名