8.9 KiB
幸运礼物 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)替换了四处配置加载:
CheckLuckyGift(送礼预检查,非锁读)executeSingleLuckyGiftDraw(单抽,事务内 FOR UPDATE)executeOptimizedLuckyGiftDrawBatch(批量抽,事务内 FOR UPDATE)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 升级为例)
- 配置页 super_lucky 行 → "创建 AB 实验":填新配置内容、实验名,流量先设 0%,白名单加测试账号(arm=treatment)。
- 测试账号真机验证:送幸运礼物应命中新版本(抽奖记录 rule_version = treatment 版本)。
- 实验详情 → 放量 5% → 观察两组 RTP/消耗/独立用户(结算口径差异需累计足够流水才有意义)→ 20% → 50%。
- 异常随时"暂停"(全量回对照组,不销毁分组);修复后恢复。
- 数据达标 → "结束实验",选获胜方;系统重发布获胜配置为新版本并全量收敛。
六、发布与排障
- 迁移:生产按既有流程手动执行
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。