hyapp-server/docs/幸运礼物AB实验.md

8.9 KiB
Raw Blame History

幸运礼物 AB 实验(灰度放量)

2026-07 引入。允许对同一 app + 奖池发布一个"实验组"配置版本,只对按用户哈希圈定的部分用户生效, 其余用户继续使用原版本;支持白名单验证、放量调整、暂停与选择获胜方收口。

一、动机与总体设计

幸运礼物配置是不可变版本流(lucky_gift_rule_versionsPK = app_code + pool_id + rule_version 发布 = 追加 max(rule_version)+1 的新行,运行时永远取最新版本。因此发布即全量,没有灰度空间。

AB 实验在其上加一层"版本钉住"语义,不改变版本流本身:

  • 实验(lucky_gift_experiments)为一个奖池钉住两个已存在的不可变版本: control_rule_version(对照组 = 创建实验时的最新启用版本)与 treatment_rule_version(实验组 = 随实验同事务原子发布的新版本)。
  • 有未完成实验时,抽奖/预检查按用户解析组并加载对应版本;无实验时保持"最新版本生效"原语义。
  • 同一奖池同时最多一个未完成实验(active 生成列 + 唯一键兜底)。

为什么"创建实验 = 原子发布 + 登记"

若先正常发布 v10 再建实验,发布提交到实验生效之间存在窗口:此时 v10 已是最新版本,全量用户瞬间切到 v10。 因此 CreateLuckyGiftExperiment 在一个 MySQL 事务里完成 treatment 发布与实验登记(lucky_gift_experiment_repository.go)。 同理实验进行中常规发布被拒绝409实验期两组各自钉住版本新发布的"最新版本"不会生效,只会造成困惑。

分流算法

FNV-1a 64(salt ":" userKey) % 1,000,000 < treatment_traffic_ppm → treatmentinternal/domain/luckygift/experiment.go)。

  • userKey内部用户为十进制 user_id外部 App 用户为 ext: + external_user_id。两条抽奖链路共用同一分流。 白名单 user_key 列使用 utf8mb4_bin字节精确与 FNV 分桶口径一致。
  • 确定性:实验状态不变时,同一用户在 Check 与 Draw 解析到同一组room-service 送礼 saga 在 CheckLuckyGift 固化 strategy_version、恢复阶段不重新 Check。放量/白名单/暂停操作会让分组在两次解析间漂移。
  • 允许跨策略对照control 与 treatment 可以分别使用 fixed_v2、dynamic_v3用于真实比较两代策略。旧客户端 缺少可信 device_id 时dynamic_v3 只跳过设备日返奖上限,用户/房间/主播等其他风控照常执行paid_at_ms 始终来自扣费后的 wallet 回执,所以分组在 Check 与 Draw 之间漂移也不会造成已扣费命令无法开奖。
  • 单调放量bucket < traffic 的比较方式保证调大流量时已在实验组的用户全部保留,只有新用户迁入; 调小流量会把部分用户迁回对照组(用户状态跨版本共享,见下)。
  • 白名单(lucky_gift_experiment_overrides)优先于哈希,可强制任一组,用于放量前的真机验证(流量 0% + 白名单); 单实验累计上限 500。
  • paused 是紧急停闸:全部流量(含白名单)回落对照组;恢复 running 后原分组不变。

与既有机制的关系(为什么这套结构对实验天然安全)

机制 维度 实验语义
RTP 窗口 lucky_rtp_windows pool:v{rule_version} 天然按组隔离,两组 RTP 观察互不污染
资金池 lucky_pools fixed_v2: pool+poolIDdynamic_v3: pool_dynamic_v3+poolID 同策略组共享对应资金账本;跨策略实验分别使用 V2 与 V3 独立账本,按组核算走抽奖事实
用户状态 lucky_user_states、日/72h RTP、六维风控 按用户/设备/房间,不含版本 跨组共享;用户粘在一组内,状态连续,不因实验重置保底/风控额度
抽奖事实 lucky_draw_records 每抽落 rule_version 分组统计免费:按 rule_version 聚合即得两组口径

结束实验(收口)

EndLuckyGiftExperiment(winner_arm) 在一个事务里:把获胜组钉住的配置快照重发布为新的最新版本 final_rule_version),并把实验标记 completed。结束瞬间"最新版本生效"的默认语义恰好收敛到获胜配置,无中间态:

  • treatment 胜 → 全量放开新配置(等效全量上线);
  • control 胜 → 回滚等效撤销本次变更treatment 版本留在版本流里成为历史)。

注意:若 treatment 是 dynamic_v3 而 control 胜出,实验期物化的 dynamic 账本及其人工注资仍在,需要用现有"扣减水位"人工调账收回。

二、数据模型

services/lucky-gift-service/deploy/mysql/migrations/006_ab_experiments.sqlinitdb 001 已同步):

  • lucky_gift_experimentsPK (app_code, experiment_id)active TINYINT AS (IF(status IN ('running','paused'),1,NULL)) STORED UNIQUE (app_code, pool_id, active) ⇒ 每池至多一个未完成实验completed 历史不限量。
  • lucky_gift_experiment_overridesPK (app_code, experiment_id, user_key)arm ∈ {control, treatment}。白名单上限 500service 校验)。

三、运行时接入点lucky-gift-service

resolveLuckyGiftRuleConfigForUserlucky_gift_experiment_repository.go)替换了四处配置加载:

  1. CheckLuckyGift(送礼预检查,非锁读)
  2. executeSingleLuckyGiftDraw(单抽,事务内 FOR UPDATE
  3. executeOptimizedLuckyGiftDrawBatch(批量抽,事务内 FOR UPDATE
  4. ExecuteExternalGiftDraw(外部 Appext: 前缀 userKey

实验行用一致性快照读(不加锁),钉住的版本行按原语义加锁;两组锁不同版本行但仍在共享资金池行上串行, 锁序(规则行 → 实验行)在常规发布、创建实验、结束实验三个入口保持一致,无交叉死锁。 钉住版本行缺失 = 不可变事实被外部破坏fail-close 报错,不静默回落最新版本。

已知取舍(均无资金风险):

  • 创建实验提交瞬间,个别在途抽奖事务可能按"最新版本=treatment"开奖一次(毫秒级窗口,两组配置都已校验可运行)。
  • 不同奖池并发创建实验可能在唯一索引间隙锁上触发一次 InnoDB 死锁回滚——管理端低频操作,报错重试即可。
  • 结束实验以获胜方快照重发布时dynamic_v3 遗留的 initial_pool_coins 会被清零(与运行时读取口径一致)。

四、管理 API

gRPC luckygift.v1.AdminLuckyGiftService 新增CreateLuckyGiftExperiment / ListLuckyGiftExperimentswith_stats 返回分组聚合)/ UpdateLuckyGiftExperiment放量、暂停恢复/ SetLuckyGiftExperimentOverrides / EndLuckyGiftExperiment。

admin HTTP前缀 /api/v1/admin/ops-center权限查看 lucky-gift:view、管理 lucky-gift:experiment

Method Path 说明
GET /lucky-gifts/experiments?app_code&pool_id&status&with_stats 实验列表(含分组统计)
POST /lucky-gifts/experiments?app_code 创建body: name + treatment_traffic_percent + overrides + config
PATCH /lucky-gifts/experiments/{id}?app_code 放量 / 暂停 / 恢复
PUT /lucky-gifts/experiments/{id}/overrides?app_code 白名单增删
POST /lucky-gifts/experiments/{id}/end?app_code 结束并选择获胜方

ops-center 前端在幸运礼物配置页集成:实验中奖池带徽章与"实验详情",行操作可"创建 AB 实验"(复用配置表单 + 实验字段), 管理弹窗支持放量滑调、白名单、分组指标对比与结束实验二次确认。

五、标准操作流程(以 lalu super_lucky 升级为例)

  1. 配置页 super_lucky 行 → "创建 AB 实验":填新配置内容、实验名,流量先设 0%白名单加测试账号arm=treatment
  2. 测试账号真机验证:送幸运礼物应命中新版本(抽奖记录 rule_version = treatment 版本)。
  3. 实验详情 → 放量 5% → 观察两组 RTP/消耗/独立用户(结算口径差异需累计足够流水才有意义)→ 20% → 50%。
  4. 异常随时"暂停"(全量回对照组,不销毁分组);修复后恢复。
  5. 数据达标 → "结束实验",选获胜方;系统重发布获胜配置为新版本并全量收敛。

六、发布与排障

  • 迁移:生产按既有流程手动执行 006_ab_experiments.sqlmysql_auto_migrate 不建表);本地 docker 新库由 initdb 覆盖, 旧库需手动执行 006。测试环境同理。
  • 分组核对 SQL SELECT rule_version, COUNT(*), SUM(coin_spent), SUM(effective_reward_coins) FROM lucky_draw_records WHERE app_code=? AND pool_id=? AND created_at_ms>=? GROUP BY rule_version;
  • 某用户实际在哪组:查 lucky_gift_experiment_overrides;无 override 时按 salt 哈希(域函数 ExperimentBucketPPM 可用 strategy-sim 或临时 Go 片段复算)。
  • 真库回归:LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN=... go test ./services/lucky-gift-service/internal/storage/mysql/ -run Experiment