20 KiB
抢购引擎优化计划(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 | — | — | — | — |
关键事实:
-
开抢窗口只有 约 9 秒(17:00:00.1 放量 → 17:00:08.2~09.4 售罄)。
-
pc-1 的 82% 兑换请求(1161/1421)被服务端限流挡回(「活动太火爆了」+「领取频率限制」);同机同 IP 峰值约 122 req/s。
-
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 整窗口(但旧版售罄即停) -
pc-3 default 的账号
1199662595042连续 13 次返回webActUI.scoreExchangePrize 响应缺少 tRsp/tResp,而同实例另外 28 个账号全部成功;日志里没有任何服务端线索。 -
服务端放行速率是稳定的"每秒 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 |
与原计划的三处偏差(有实测依据)
- 自适应降速的触发信号改为「无进展」而不是「限流占比 > 60%」。 原计划按限流占比判定,但实测数据显示 pc-3(成功率 97.6%)也有 67% 请求被限流、pc-1 是 82% —— 用占比阈值会把正常运行的机器一起拖慢。改为「连续 ≥12 个请求且 ≥800ms 拿不到任何业务响应」,命中后按 1.5 倍逐级降速(上限 4 倍),有进展立刻回退。
- 退避阶梯不引入
RetryStages配置项,改为在既有ErrorBackoff/TooFrequentBackoff基础上乘「时间档位 + 连续失败档位」:既不新增配置面,又避免「连续售罄探测」这类正常等待被放大(售罄/未开放档位更保守)。 - 窗口模式下
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 |