Files
huya-go/docs/seckill-optimization-plan.md
T

263 lines
20 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.
# 抢购引擎优化计划(v0.4.0)
> 依据:2026-10-16 三台机器(pc-1 / pc-2 / pc-3)同一场活动(sid=2277,17:00 开抢)的真实日志复盘。
> 参考:`/Users/yml/codes/dygou_go`(斗鱼)的调度设计,**只借鉴结论与参数形态,不照搬链路**(虎牙无锁单/支付,协议也不同)。
---
## 1. 复盘数据(问题来源)
| 机器 | 账号 | 成功 | 成功率 | 兑换请求 | 火爆 | 频率限制 | 售罄 | 版本 |
|---|---|---|---|---|---|---|---|---|
| pc-1 | 152 | 85 | 56% | 1421 | 917 | 244 | 19 | 旧版(无汇总 / 无时钟校准 / 售罄即停) |
| pc-2 | 10 | 7 | 70% | 81 | 51 | 1 | 0 | 新版(轮次窗口不足) |
| pc-3 | 85 | 83 | 97.6% | 498 | — | 0 | 0 | 新版(目标未售罄) |
| 合计 | 247 | 175 | 71% | 2000 | — | — | — | — |
关键事实:
1. 开抢窗口只有 **约 9 秒**(17:00:00.1 放量 → 17:00:08.2~09.4 售罄)。
2. pc-1 的 **82%** 兑换请求(1161/1421)被服务端限流挡回(「活动太火爆了」+「领取频率限制」);同机同 IP 峰值约 122 req/s。
3. `Delay` 默认 150ms 时,**账号越少,每秒轮次越多,越容易在开抢前把轮次烧光**:
| 实例 | 账号数 | 每轮耗时 | 20 轮覆盖 | 实际打进的开抢窗口 |
|---|---|---|---|---|
| pc-3 instance-4 | 1 | 155ms | 3.1s | **0 秒**(提前量 2.5s 吃掉全部预算 → 0 成功) |
| pc-2 default | 3 | 178ms | 3.6s | ~1.0s |
| pc-2 instance-3 | 3 | 170ms | 3.4s | ~1.2s |
| pc-3 instance-3 | 22 | 586ms | 11.7s | 5.7s |
| pc-1 各实例 | 30 | 830ms | 16.6s | 整窗口(但旧版售罄即停) |
4. pc-3 default 的账号 `1199662595042` **连续 13 次**返回 `webActUI.scoreExchangePrize 响应缺少 tRsp/tResp`,而同实例另外 28 个账号全部成功;日志里没有任何服务端线索。
5. 服务端放行速率是稳定的"每秒 4~6 个"(pc-3 六秒曲线),不是瞬间批量 → **覆盖足够的窗口时长**比**更高瞬时并发**更有效。
## 2. 四个优化项(已确认全部实施)
| 编号 | 问题 | 现状证据 |
|---|---|---|
| P1 | 轮次预算被提前量吃掉,账号少的实例根本没打到开抢瞬间 | pc-3 i4 = 0 成功;pc-2 三实例开抢后 1~1.4s 收工 |
| P2 | WUP/协议错误不可观测,且被混入普通失败 | 13 次 `缺少 tRsp/tResp` 只给了一句话,`iRet/status/payload` 全丢 |
| P3 | 限流文案没分类,退避语义缺失 | `火爆`/`领取频率限制` 落到 `failed`,共用同一退避 |
| P4 | 日志缺关键上下文 | `启动监控` 无 pid/名称;`账号未绑定` 与真失败同桶;无接口耗时统计 |
## 3. 参考方案(dygou_go)与适用边界
| dygou_go 做法 | 虎牙适用性 | 说明 |
|---|---|---|
| 窗口 `deadline` + `expired()`(75s)替代轮次 | ✅ 直接借鉴 | 虎牙实测窗口 ~9s,改为可配 `WindowSeconds`(建议默认 30s) |
| 每账号独立循环,不做轮间同步 | ⚠️ 改造后借鉴 | 虎牙已有"请求级闸门 + 账号独立 loop",但外层仍按 loop 同步一轮 |
| `dynamicAdvance = ErrorBound + RTT/2`,下限 50ms | ✅ 直接借鉴 | 替代固定 `LeadMS=2500` |
| 触发前 3s 复校时钟(`resyncBeforeTrigger`) | ✅ 借鉴 | 虎牙已有 `OffsetHistory` 中位数平滑,缺"临近复校" |
| 预热早于触发 2s(`warmupBeforeTrigger`) | ✅ 借鉴 | 改为"预热后静默等触发",不再让提前量循环白打 |
| 首发错峰 `firstWaveSpread=300ms` / `step≤8ms` | ✅ 直接借鉴 | 缓解同机 152 账号同毫秒齐射 |
| 退避阶梯 `retryIntervalFor(按已过时间分档)` + ×2 递增(≤×4) + 抖动,区间 [100ms,1.5s] | ✅ 直接借鉴 | 替代固定 300ms 退避 |
| 售罄不终止、退避自动降速到秒级 | ✅ 已有 | huya-go 已在 0294606 实现,保留并加提示日志 |
| 结局分类 success/rejected/throttled/terminal/expired | ✅ 借鉴 | 用于"窗口结束时"的每账号结局 |
| 限流关键词表 `exchangeRateLimited`(火爆/频繁/繁忙/排队…) | ✅ 直接借鉴 | 补进 `classifyExchangeText` |
| 请求统计(count/failed/avg/slowest/lastErr)+ 网络汇总行 | ✅ 借鉴 | 虎牙已有分类计数,补耗时与最后错误 |
| 目标商品 id 打进日志(`productTargets`) | ✅ 直接借鉴 | 补 `启动监控` 的 pid + 名称 |
| 抢购后积分核对(基线比对) | ✅ 借鉴 | 现有 `refresh_score` 可升级为"扣分=成功"二次确认 |
| 锁单→支付、锁单 TTL/CSRF、支付阶梯、和平小店 | ❌ 不适用 | 虎牙是直接兑换、无锁单无支付 |
| 斗鱼订单错误码表(1644400007 等) | ❌ 不适用 | 虎牙是 TAF/WUP,需按 `iRet / status / 文案` 三类信号自建 |
## 4. 现状代码坐标
| 位置 | 说明 |
|---|---|
| `internal/sniper/sniper.go:198` | `RunTargets` |
| `internal/sniper/sniper.go:227` | `for loop := 1; loop <= cfg.Loops; loop++`(待替换的轮次模型) |
| `internal/sniper/sniper.go:308` | `runAccountLoop` |
| `internal/sniper/sniper.go:444 / 480` | `exchangeTarget` / `applyExchangeDecision`(退避决策) |
| `internal/sniper/sniper.go:734` | `emitSummary` |
| `internal/sniper/sniper.go:838` | `normalizeConfig`(默认 Delay 150ms / Backoff 300ms) |
| `internal/sniper/sniper.go:996` | `classifyExchangeText`(文案分类) |
| `internal/huya/wss_client.go:704` | `callRPC`(:715 丢诊断) |
| `internal/huya/http_client.go:140 / 218` | `CallRPC`(:218 同样的丢诊断) |
| `internal/wup/wup.go:97 / 229` | `Response`(有 `Message/Status/Context/NewData` 未用)、`ReadStruct` |
| `internal/huya/timecal.go:98 / 145` | `AlignedTriggerTime` / `RunTimeCalibration`(已有中位数平滑) |
| `internal/gui/app.go:502 / 522-540` | `启动监控` 日志 / 定时触发路径 |
| ~~`cmd/huya-go/main.go:198 / 220`~~ | CLI `waitForMonitor` —— **CLI 已在 0.4.0 移除**(见文末「实施记录(续)」) |
## 5. 设计(四个工作包)
### WP1 窗口预算与首发时序(P1)
1.1 **终止条件:轮次 → 截止时间**
- `Config` 新增:`WindowSeconds int`(默认 30)、`DeadlineAt time.Time`(内部)、`FirstWaveSpread / FirstWaveMaxStep`。
- `RunTargets` 计算 `deadline := openAt + WindowSeconds`;循环条件改为 `!deadlineReached()`;`Loops > 0` 时仍作为上限(**向后兼容**:两者先到先止)。
1.2 **起跑阶段不消耗预算(消灭 pc-3 i4 的 0 成功)**
- 两段式:阶段 A(预打探活)T-advance 起,每账号只发 1 次用于判断是否已开放;阶段 B(正式窗口)等 `openAt` 后才开始计预算,`deadline = openAt + WindowSeconds`。
- 引擎自持 `openAt`(由 GUI 传入),不再把"等待"放在 `RunTargets` 之外。
1.3 **首发错峰**
- `runState.firstWaveSlot()`:第 0 个账号立即发,其余 `slot × step`;`step = min(spread/(n-1), maxStep)`,`spread=300ms`、`maxStep=8ms`(152 账号 → 2ms/步,总铺开 300ms)。
1.4 **触发时序统一(引擎与 GUI 共用一份实现)**
- 新增 `internal/sniper/timing.go`:`PlanTrigger(target, calibration, fallbackLead) Plan{WarmupAt, ResyncAt, TriggerAt, Advance, WindowEnd}`。
- GUI(`internal/gui/app.go` 的 `StartMonitor`)改为调用同一函数:预热 T-2s、复校 T-3s、触发 T-advance、窗口结束 openAt+WindowSeconds。
- `advance = max(ErrorBound + RTT/2, 50ms)`;`LeadMS > 0` 时覆盖动态值(兼容旧配置)。
1.5 **退避阶梯**
- 新增 `retryDelay(elapsed, failStreak)`:基准按"已过时间"分档(默认 0-2s→150ms、2-5s→400ms、>5s→900ms,可配 `RetryStages`),连续失败 ×2(上限 ×4),抖动 0-25%,区间 [100ms, 1500ms]。
- 退避等待被 deadline 裁剪(`sleepUntil(deadline, d)`);窗口结束立即收尾,不再产生窗口外的无效失败。
### WP2 WUP/协议错误可观测性(P2)
2.1 `wup.Response` 新增 `Diagnose()`:`iRet=%d servant=%s func=%s status=%v context=%v keys=%v payload=%dB`(value 截断、key 排序,保证日志稳定)。
2.2 `wss_client.callRPC`(:715)与 `http_client.CallRPC`(:218):tRsp 缺失时错误串带上 `resp.Diagnose()`。
2.3 新增 debug 级原始帧日志:`cmd=`、帧长度、前 32 字节 HEX(仅在日志等级为 debug 时输出)。
2.4 新增分类 `CategoryProtocol = "protocol"`(解码失败、缺 tRsp、帧异常)→ 日志等级 error、独立计数。
2.5 新增分类 `CategorySessionLost = "session_lost"`("账号未绑定"/"登录态"/"token"):瞬时错误照常重试,连续 ≥3 次才升级为账号级终止并打 warn。
2.6 单测:构造 `iRet≠0 且无 tRsp` 的响应 → 断言错误串含 iRet/status/keys;"账号未绑定" → 断言新分类。
### WP3 限流分类与退避(P3)
3.1 `classifyExchangeText` 关键词扩充(借 dygou 的关键词表)
- 归入 `too_frequent`:`火爆`、`频繁`、`稍后重试`、`太频繁`、`限流`、`繁忙`、`人多`、`排队`、`手慢`、`点击太快`、`请求过多`、`行为异常`。
- 判定顺序固定:`未开放 → 已抢完/库存 → 货币不足/已兑换 → 限流 → 兜底`,避免"稍后重试"吃掉"售罄"。
3.2 汇总口径扩充:`RunSummary` 增 `Throttled / SoldOut / SessionLost / Protocol`;汇总行形如
`任务结束: 窗口=30.0s 账号=29 成功=28 限流=123(67%) 售罄=19 协议错误=13 耗时=9.1s`
3.3 退避:`too_frequent` 走 WP1.5 的阶梯,并支持"连续 N 次限流后该账号基础间隔 ×2"(N 默认 3)。
3.4 (可选,默认关)**全局自适应降速**:最近 1s 限流占比 > 60% 时,把基础间隔与并发按 1.5 倍衰减,10s 窗口后恢复。开关 `AdaptiveThrottle`,用于缓解 pc-1 那种 122 req/s → 82% 限流的同 IP 风暴。
3.5 终态修正:限流/火爆/未绑定一律**不终止**(现状已如此,补测试固化)。
### WP4 日志与复盘(P4)
4.1 `启动监控` 带目标明细:
`启动监控: sid=2277 类目=不限 targets=1 目标=13437(冠军之刃) accounts=29 窗口=30s 提前=动态≈120ms 首发错峰=300ms`
4.2 每账号一行结局(任务结束时):`账号=1234 结局=限流中结束 请求=12 最后=活动太火爆了,请稍后重试`
4.3 结局分布带原因:`结局 sold_out ×19(窗口结束仍售罄)`
4.4 接口统计行:`网络: scoreExchangePrize ×183 失败136 平均=412ms 最慢=2.1s 最后错误=…`
4.5 触发日志补全:`触发=… 复校=… 窗口结束=…`
## 6. 兼容性与风险
- **向后兼容**:`Loops > 0` 仍生效(作为上限);`LeadMS > 0` 覆盖动态提前量;新字段全部有默认值,GUI 都可关闭新行为。
- **风险 1**:窗口拉长 → 请求量上升。缓解:退避阶梯 + deadline 裁剪 + 可选自适应降速;`WindowSeconds` 可调。
- **风险 2**:首发错峰牺牲首波瞬时性。缓解:总铺开仅 300ms,而实测窗口有 9s 余量;第 0 个账号仍立即发。
- **风险 3**:动态提前量下限 50ms 可能压不住慢网络。缓解:`ErrorBound + RTT/2` 已含往返补偿,且保留"预打探活"。
- **风险 4**:新增分类影响 GUI 配色/筛选。缓解:`LogLevelForCategory` 补映射(protocol→error,session_lost→warn),前端按 level 上色即可。
- **风险 5**:`classifyExchangeText` 顺序调整可能改变既有用例结论。缓解:先补"售罄/货币不足优先于限流"的回归测试再改。
## 7. 验收标准(DoD)
- `go test ./...` 全绿;新增 ≥12 个用例(窗口预算、首发错峰、退避阶梯、分类、诊断串、临近复校)。
- 关键回归不破:售罄继续探测、同奖品重复兑换去重、成功后异步刷新、`StopAfterSuccess`。
- `go build ./...` 与 `wails build`(或 `-debug -nopackage`)通过;版本号升到 `0.4.0`。
- 日志验收(可用假 client 跑模拟):一次运行必须输出「窗口参数行 / 目标明细行 / 每账号结局行 / 汇总行 / 接口统计行」。
- 反向验收:账号数 = 1、`Loops=1`、窗口 30s 时,日志必须显示"有效请求覆盖到窗口结束",不再出现"开抢前收工"。
## 8. 里程碑与改动清单
| 里程碑 | 内容 | 主要文件 | 产出 |
|---|---|---|---|
| M1 | WP2 可观测性(低风险、独立、立即可用于复盘) | `internal/wup/wup.go`、`internal/huya/wss_client.go`、`internal/huya/http_client.go`、`internal/sniper/sniper.go`(+分类)、测试 | 新分类 + 诊断串 |
| M2 | WP3 限流分类与退避 | `internal/sniper/sniper.go`、`sniper_test.go`、`internal/gui/app.go` | 退避阶梯 + 汇总口径 |
| M3 | WP1 窗口预算与首发时序 | `internal/sniper/sniper.go`、新增 `internal/sniper/timing.go`、`internal/gui/app.go`、`internal/huya/timecal.go` | 窗口调度 |
| M4 | WP4 日志补齐 + 版本/文档 | `internal/gui/app.go`、`internal/logger`、`README.md`、`internal/version` | 可复盘日志 |
顺序理由:先把"看不清"变成"看得清"(M1/M2),再动调度核心(M3),最后收尾(M4)。
## 9. 待确认参数
| 参数 | 建议值 | 说明 |
|---|---|---|
| `WindowSeconds` | 30s | 实测售罄 9s;30s 覆盖「库存回流/补货」 |
| 首发错峰总量 / 单步上限 | 300ms / 8ms | 152 账号 → step 2ms |
| 退避区间 | [100ms, 1500ms] | 超过 1.5s 的等待可能正好错过回流库存 |
| 「账号未绑定」升级阈值 | 连续 3 次 | 实测同一账号下一轮即可成功(瞬时) |
| 自适应降速(WP3.4) | 默认开 | 由用户确认:默认打开,GUI 可关 |
---
## 10. 实施记录(v0.4.0,2026-10-16)
四个工作包已全部落地,`go test ./...`、`go vet`、`wails build`、前端 `npm run typecheck && npm run build` 均通过。
### 已实现
| 工作包 | 落地位置 |
|---|---|
| WP1 窗口预算 | `internal/sniper/sniper.go`(`Config.OpenAt/WindowSeconds/StartAhead`、`runState.configureWindow/windowExpired/windowStart/needsPrefire`、`RunTargets` 重写)、`internal/sniper/timing.go`(`PlanTrigger`)、`internal/gui/app.go` |
| WP1 首发错峰 | `runState.takeFirstWaveDelay` + `runAccountLoop` 首次尝试等待 |
| WP1 退避阶梯 | `runState.retryDelay / scheduleRetry / stageMultiplier / streakMultiplier / bumpFailStreak` |
| WP2 可观测性 | `internal/wup/wup.go`(`Diagnose`、`HexHead`)、`internal/huya/diagnose.go`(`ErrProtocolResponse`、`protocolResponseError`)、`internal/huya/wss_client.go`、`internal/huya/http_client.go` |
| WP2 新分类 | `CategoryProtocol`、`CategorySessionLost` + `LogLevelForCategory` / `CategoryLabel` |
| WP3 限流分类 | `classifyExchangeText` 关键词表 + 固定判定顺序 |
| WP3 汇总口径 | `RunSummary.Throttled`(经 `Outcomes[too_frequent]` 统计)、`RequestStats`、`AccountOutcomes` |
| WP3 自适应降速 | `throttleGovernor` + `runState.observeThrottle/throttleFactor` |
| WP4 日志 | `logRunSummary`(GUI)、`sniper.TargetSummary`、`App.logWindowPlan`、`waitFor` |
| 测试 | `internal/sniper/optimization_test.go`(15 个新用例 + 下文 11.4 再补 4 个)、`internal/wup/wup_test.go`(诊断串 + 原始帧前缀)、`internal/huya/diagnose_test.go`(协议错误判定)、前端 `tsc --noEmit` |
### 与原计划的三处偏差(有实测依据)
1. **自适应降速的触发信号改为「无进展」而不是「限流占比 > 60%」**。
原计划按限流占比判定,但实测数据显示 pc-3(成功率 97.6%)也有 67% 请求被限流、pc-1 是 82% —— 用占比阈值会把正常运行的机器一起拖慢。改为「连续 ≥12 个请求且 ≥800ms 拿不到任何业务响应」,命中后按 1.5 倍逐级降速(上限 4 倍),有进展立刻回退。
2. **退避阶梯不引入 `RetryStages` 配置项**,改为在既有 `ErrorBackoff/TooFrequentBackoff` 基础上乘「时间档位 + 连续失败档位」:既不新增配置面,又避免「连续售罄探测」这类正常等待被放大(售罄/未开放档位更保守)。
3. **窗口模式下 `Loops` 语义调整**:`OpenAt` 非零时 `Loops<=0` 表示跑到窗口结束(GUI 默认留空),`Loops>0` 仍作为单个账号的轮次上限;非窗口模式(测试与直接调用)保持原来的「默认 1 轮」语义,避免老行为回归。
### 顺带修掉的两个小问题
- 同一「账号 + 奖品」的「已兑换过,跳过重复兑换」提示改为只报一次:窗口模式下每轮都会命中,不限制会刷满整个窗口(请求数不变)。
- 会话失效计数在任何非会话失效结局后清零,避免历史计数把偶发抖动累积成「连续失效」。
---
## 11. 实施记录(续):每账号独立循环 + 共享列表 TTL + 移除 CLI(v0.4.0,2026-10-16)
三机复盘后用户追加两条要求:**每个账号独立抢购**(不再一批一批同步)、**移除 CLI 只保留 GUI**。
### 11.1 调度:批次同步 → 每账号独立循环
**旧实现**:`RunTargets` 外层是 `for loop := 1..Loops`,每轮把所有账号并发跑一遍再 `wg.Wait()` 等齐,然后按 `nextWaveDelay` 等一个共享间隔进入下一轮。慢账号(一次 2s 的请求)会把所有账号钉在这一轮的终点上,账号少的实例尤其吃亏(pc-3 instance-4 = 0 成功)。
**新实现**(`internal/sniper/sniper.go`):
| 旧 | 新 |
|---|---|
| `for loop` + `runWave(loop)` + 每轮 `wg.Wait()` 同步 | 每个账号一个 goroutine 跑 `runAccountLoop`,各自 `for round := 1; ;` 循环 |
| `nextWaveDelay` 批次间隔 | 账号自己的 `cfg.Delay`(`clipToDeadline` 裁剪到窗口结束) |
| `runAccountLoop(..., loop, sharedList)` 是一轮的尝试体 | 拆成 `runAccountLoop`(循环)+ `runAccountRound`(一轮尝试体) |
| 局部 `loopsRun` 记轮次 | `runState.noteRound/maxRound`(取所有账号的最大轮次) |
| 每轮开头只判断 `allDone()` | `allDone()` + `windowExpired()` + `accountFinished(uid, len(targets))` |
退出条件(每个账号独立判断):任务取消 → 显式轮次上限(`Loops > 0`)→ 全局目标终止(售罄/下架,`targetDone`)→ 窗口结束 → 本账号所有目标已完成。
**保留的批次语义**:开抢前的「预打一轮」(`runWave(1)`)仍是全账号一波并发,用于探活与验证通道;只有正式窗口改成独立循环。
**验证**:新增 `TestRunTargetsPerAccountLoopsAreIndependent` —— 账号 1 的第一个兑换请求被阻塞时,账号 2 必须已经独立跑完 ≥3 轮(旧实现下账号 2 会卡在第二轮起点)。
### 11.2 共享列表:每轮一个新快照 → TTL + 单飞缓存
`SharePrizeList` 原来的语义是「每一轮所有账号共用同一份快照」,在批次调度下等价于每轮查 1 次列表。改成独立循环后「一轮」不再有全局含义,于是把共享口径改为**按时间的快照缓存**(`runState.sharedListOnce`):
- 锁内优先复用 1 秒 TTL 内的成功快照;没有可用快照时**单飞**——只有一个账号真正发请求,其余账号等它完成后复用结果。
- **只缓存成功结果**:失败不写缓存,否则一次抖动会让所有账号一起拿着错误快照失败。
- `sharedListTTL = 1000ms`:单次查询约 100ms,单账号节奏是 `cfg.Delay`(默认 150~300ms)。1 秒既能让同一波账号复用一份快照(pc-1 的 152 个账号从每轮 152 次查询降到 1 次),又能及时看到库存变化(实测库存会回流,快照不能太旧)。
- 关闭 `SharePrizeList` 时保持原样:各查各的,不做任何缓存。
**验证**:新增 `TestRunTargetsSharedListTTLSingleFlight` —— 2 账号 × 3 轮,列表请求数必须为 1(只做单飞、无 TTL 时是 3~6 次)。
### 11.3 移除 CLI(只保留 GUI)
CLI 与 GUI 是两套并行入口(`prepareMonitor` / `logRunSummary` / 参数解析各一份),改动要同步两处,老计划里甚至专门列了「GUI 与 CLI 需保持一致」这一条。既然日常只用 GUI,直接删掉:
- 删除 `cmd/huya-go/`;`internal/sniper` 的 `Runner`/`Config` 完全由 GUI 复用,删除 CLI 不影响引擎。
- `internal/gui/app.go` 的 `StartMonitor` 成为唯一触发路径(时间校准 → 窗口计划 → `RunTargets`)。
- `README.md`:删掉 CLI 构建/示例/参数表,改为「设置项说明」,并把「GUI 与 CLI 一致」等表述收敛为 GUI。
### 11.4 本轮新增测试(4 个,全部通过)
| 用例 | 覆盖 |
|---|---|
| `TestRunTargetsPerAccountLoopsAreIndependent` | 慢账号不阻塞其他账号 |
| `TestRunTargetsSharedListTTLSingleFlight` | 共享列表 TTL + 单飞只查 1 次 |
| `TestRunStateClipToDeadline` | 等待被裁剪到窗口结束(含已过期与非窗口模式) |
| `TestRunStateAccountFinishedAndRoundTracking` | `accountFinished`(含全局目标终止)与 `maxRound` |