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

20 KiB
Raw Blame History

抢购引擎优化计划(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