122 lines
8.9 KiB
Markdown
122 lines
8.9 KiB
Markdown
# 幸运礼物 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`。
|