hyapp-server/docs/幸运礼物AB实验.md
zhx dc8435f869 增加幸运礼物 AB 实验:按用户哈希分流灰度发布配置版本
- lucky_gift_experiments/overrides 表(006 迁移,含分组统计索引与 binary user_key)
- 创建实验=同事务发布 treatment+登记;两组强制同策略;实验期常规发布拒绝
- Check/单抽/批量/外部抽按用户钉住组内版本;结束实验重发布获胜快照全量收敛
- AdminLuckyGiftService 新增 5 个实验 RPC(放量字段 proto3 optional)
- admin 实验管理 HTTP API + lucky-gift:experiment 权限(迁移 103)
- 设计文档 docs/幸运礼物AB实验.md;域/真库/admin 回归测试

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:22:12 +08:00

122 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 幸运礼物 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。放量/白名单/暂停操作会让分组在两次解析间漂移,因此——
- **两组必须同策略族**(创建实验强制校验):同策略下分组漂移只是参数差异,不会击穿 room saga 对
device_id/paid_at 的扣费前拦截。fixed_v2 → dynamic_v3 的**策略迁移走常规发布**,不走实验
(旧客户端本就无法进入 dynamic 组,混合策略实验的分组天然有偏差,统计上也不成立)。
- **单调放量**`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 | 两组同策略(强制),共享同一口支付能力,按组核算走抽奖事实 |
| 用户状态 `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}。白名单上限 500service 校验)。
## 三、运行时接入点lucky-gift-service
`resolveLuckyGiftRuleConfigForUser``lucky_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.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`