# 幸运礼物 AB 实验(灰度放量) > 2026-07 引入。允许对同一 app + 奖池发布一个"实验组"配置版本,只对按用户哈希圈定的部分用户生效, > 其余用户继续使用原版本;支持白名单验证、放量调整、暂停与选择获胜方收口。 ## 一、动机与总体设计 幸运礼物配置是不可变版本流(`lucky_gift_rule_versions`,PK = 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 → treatment`(`internal/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`+poolID;dynamic_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.sql`(initdb 001 已同步): - `lucky_gift_experiments`:PK (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_overrides`:PK (app_code, experiment_id, user_key),arm ∈ {control, treatment}。白名单上限 500(service 校验)。 ## 三、运行时接入点(lucky-gift-service) `resolveLuckyGiftRuleConfigForUser`(`lucky_gift_experiment_repository.go`)替换了四处配置加载: 1. `CheckLuckyGift`(送礼预检查,非锁读) 2. `executeSingleLuckyGiftDraw`(单抽,事务内 FOR UPDATE) 3. `executeOptimizedLuckyGiftDrawBatch`(批量抽,事务内 FOR UPDATE) 4. `ExecuteExternalGiftDraw`(外部 App,ext: 前缀 userKey) 实验行用一致性快照读(不加锁),钉住的版本行按原语义加锁;两组锁不同版本行但仍在共享资金池行上串行, 锁序(规则行 → 实验行)在常规发布、创建实验、结束实验三个入口保持一致,无交叉死锁。 钉住版本行缺失 = 不可变事实被外部破坏,fail-close 报错,不静默回落最新版本。 已知取舍(均无资金风险): - 创建实验提交瞬间,个别在途抽奖事务可能按"最新版本=treatment"开奖一次(毫秒级窗口,两组配置都已校验可运行)。 - 不同奖池并发创建实验可能在唯一索引间隙锁上触发一次 InnoDB 死锁回滚——管理端低频操作,报错重试即可。 - 结束实验以获胜方快照重发布时,dynamic_v3 遗留的 initial_pool_coins 会被清零(与运行时读取口径一致)。 ## 四、管理 API gRPC `luckygift.v1.AdminLuckyGiftService` 新增:CreateLuckyGiftExperiment / ListLuckyGiftExperiments(with_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.sql`(`mysql_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`。