diff --git a/frontend/src/components/modal/SettingsModal.vue b/frontend/src/components/modal/SettingsModal.vue index 84429c8..dc9efa3 100644 --- a/frontend/src/components/modal/SettingsModal.vue +++ b/frontend/src/components/modal/SettingsModal.vue @@ -233,7 +233,7 @@ defineExpose({ open, close })
按经过秒数从小到大排列,控制精英/电竞手册的锁单重试节奏; - 同一账号连续失败会在此基础上成倍放大(最长 5 秒) + 同一账号连续失败会在此基础上成倍放大,但任何一次等待都不超过约 2 秒
@@ -243,6 +243,7 @@ defineExpose({ open, close })
  • 抢购窗口:75 秒;窗口内每个账号各自独立重试,互不阻塞
  • 默认:阶段 2 为 200ms,阶段 3 为 500ms,其余阶段 1000ms
  • 已锁单的订单即使窗口结束也会把支付重试完(最长 120 秒)
  • +
  • 首发请求:第一个账号立即发出,其余按几毫秒步长错开,整批不超过 0.3 秒
  • "活动火爆/网络繁忙"是请求级限流(同一秒别的账号仍能锁单),会继续重试
  • "库存已耗完"也不提前停止:别人锁单未支付时库存会回流,会降频探测到窗口结束
  • diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 7b6d399..d3e71f7 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -3,7 +3,7 @@ package constants import "time" // Version 应用版本号,与 wails.json version 同步。 -const Version = "0.3.0" +const Version = "0.3.1" // BuildTime 构建时间,由编译时 ldflags 注入。 var BuildTime = "unknown" diff --git a/internal/seckill/attempt.go b/internal/seckill/attempt.go index a604a37..5842bff 100644 --- a/internal/seckill/attempt.go +++ b/internal/seckill/attempt.go @@ -44,15 +44,16 @@ type retryLadder struct { // 声明为变量是为了让测试能缩短。 var ( payRetryInFlight = retryLadder{base: 300 * time.Millisecond, limit: time.Second} - payRetryRate = retryLadder{base: time.Second, limit: 3 * time.Second} - payRetryOther = retryLadder{base: 500 * time.Millisecond, limit: 2 * time.Second} + payRetryRate = retryLadder{base: 800 * time.Millisecond, limit: 2 * time.Second} + payRetryOther = retryLadder{base: 400 * time.Millisecond, limit: 1200 * time.Millisecond} ) var ( - peacePreLockMin = 300 * time.Millisecond - peacePreLockMax = 1200 * time.Millisecond - peaceLockPayMin = 2 * time.Second - peaceLockPayMax = 4 * time.Second + // 锁单成功后到第一次支付的等待。原来是 2-4 秒,纯属把关键路径上的时间 + // 白送出去——"订单操作进行中"现在按"已受理、结果未定"快重试处理, + // 不需要靠等待来规避。 + peaceLockPayMin = 300 * time.Millisecond + peaceLockPayMax = 700 * time.Millisecond ) // 斗鱼订单接口错误码。 @@ -135,9 +136,15 @@ func (s *Scheduler) runPeaceLockPay(attempt types.ExchangeAttempt, ctl *runContr "精英手册锁单支付: 账号=%s 商品=%s(%s) 第%d次", attempt.Account.Name, attempt.Product.CommodityName, attempt.Product.CommodityID, attempt.Index) - // 首发请求按 300-1200ms 抖动打散;后续重试的间隔由外层节奏计算。 + // 首发请求立刻发出:第 0 个账号不等待,其余按几毫秒步长错开(整批不超过 0.3 秒)。 + // 定时器已提前约 100ms 触发以补偿 RTT,这里再抖几百毫秒会把这套提前量抵消掉。 if attempt.Index == 1 && ctl != nil { - if !ctl.sleep(randomSpan(peacePreLockMin, peacePreLockMax)) { + slot, step := ctl.firstWaveSlot() + delay := time.Duration(slot) * step + if step > 0 { + delay += time.Duration(rand.Int63n(int64(step))) + } + if !ctl.sleep(delay) { out.stopped = ctl.stopped() if out.stopped { out.result.Message = "失败|已收到停止指令" diff --git a/internal/seckill/run_control.go b/internal/seckill/run_control.go index 32c0ec2..9725a1d 100644 --- a/internal/seckill/run_control.go +++ b/internal/seckill/run_control.go @@ -17,26 +17,59 @@ type runControl struct { stop <-chan struct{} limit chan struct{} - mu sync.Mutex - lockReqs int - payReqs int - exchangeReqs int + firstWaveStep time.Duration + + mu sync.Mutex + lockReqs int + payReqs int + exchangeReqs int + firstWaveUsed int } -// newRunControl 创建运行控制块。concurrency 限制同时在途的请求数。 -func newRunControl(s *Scheduler, start time.Time, deadline time.Time, concurrency int) *runControl { +// newRunControl 创建运行控制块。concurrency 限制同时在途的请求数, +// accountCount 用来决定首发请求的错开步长。 +func newRunControl(s *Scheduler, start time.Time, deadline time.Time, concurrency, accountCount int) *runControl { if concurrency <= 0 { concurrency = 1 } return &runControl{ - s: s, - start: start, - deadline: deadline, - stop: s.stopRunChan(), - limit: make(chan struct{}, concurrency), + s: s, + start: start, + deadline: deadline, + stop: s.stopRunChan(), + limit: make(chan struct{}, concurrency), + firstWaveStep: firstWaveStepFor(accountCount), } } +// firstWaveStepFor 计算首发请求之间的错开步长。 +// +// 首发是整场抢购最关键的一波,不能像早期版本那样随机等 150-700ms——定时器本来就 +// 提前约 100ms 触发以补偿 RTT,再抖几百毫秒等于把提前量整个抵消。 +// 这里让第 0 个账号立即发出,其余按步长依次错开,整批最多铺开 firstWaveSpread。 +func firstWaveStepFor(accountCount int) time.Duration { + if accountCount <= 1 { + return 0 + } + step := firstWaveSpread / time.Duration(accountCount-1) + if step > firstWaveMaxStep { + step = firstWaveMaxStep + } + if step < 0 { + step = 0 + } + return step +} + +// firstWaveSlot 领取本账号的首发错开位次与步长。 +func (c *runControl) firstWaveSlot() (int, time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + slot := c.firstWaveUsed + c.firstWaveUsed++ + return slot, c.firstWaveStep +} + // enter 占用一个并发额度;返回 false 表示任务已被停止。 func (c *runControl) enter() bool { select { diff --git a/internal/seckill/scheduler.go b/internal/seckill/scheduler.go index dc14977..3404f57 100644 --- a/internal/seckill/scheduler.go +++ b/internal/seckill/scheduler.go @@ -424,7 +424,7 @@ func (s *Scheduler) RunNow(accountProducts map[string]string) { start := time.Now() deadline := start.Add(seckillMaxDuration) - ctl := newRunControl(s, start, deadline, settings.concurrency) + ctl := newRunControl(s, start, deadline, settings.concurrency, len(tasks)) s.clearAttemptCache() if settings.seckillMode == types.SeckillModeAggressive { @@ -463,10 +463,22 @@ const ( ) // 每账号重试节奏的上下限与递增倍数。 +// +// 抢购是争分夺秒的,退避不能长:2026-10-03 的日志里,PC2 四个实例在 +// 18:00:00.171~18:00:01.101 一直能锁到同一个礼包(库存至少活了 1.1 秒), +// 而 PC1 默认实例的 31 个号在 18:00:00.225 失败后一直睡到 18:01:09.379—— +// 唯一有货的那段窗口被退避白白吃掉了。所以这里把任何一次等待都压在 2 秒以内。 const ( minRetryDelay = 100 * time.Millisecond - maxRetryDelay = 5 * time.Second - maxRetryEscalation = 8 + maxRetryDelay = 1500 * time.Millisecond + maxRetryEscalation = 4 +) + +// 首发请求的错开参数:第 0 个账号立即发出,其余按小步长依次错开, +// 整批最多铺开 firstWaveSpread(账号再多也不会拖长),单个步长不超过 firstWaveMaxStep。 +const ( + firstWaveSpread = 300 * time.Millisecond + firstWaveMaxStep = 8 * time.Millisecond ) // stockOutWarnAfter 是同一账号连续收到"商品已售罄"多少次后,打一条"转为低频探测" @@ -600,7 +612,11 @@ func expiryOutcome(lastStockOut, lastThrottled bool, lastFailure string) (string // // 基准节奏来自配置的"重试间隔策略"(按经过秒数分档)——早期版本把它只用在 // 轮与轮之间,单次锁单失败却硬编码睡 30 秒,配置形同虚设。同一账号连续失败时 -// 再成倍放大,避免单个账号猛打;并叠加抖动,防止所有账号同步重试形成尖峰。 +// 再成倍放大(最多 ×4),避免单个账号猛打;并叠加 0-25% 抖动, +// 防止所有账号同步重试形成尖峰。 +// +// 上限刻意压到 1.5 秒:抢购窗口只有 75 秒,库存随时可能因为别人锁单未支付而回流, +// 任何超过 2 秒的等待都可能正好错过它。 func retryDelay(stages []types.RetryStage, elapsed time.Duration, failStreak int) time.Duration { base := retryIntervalFor(stages, elapsed) if base <= 0 { @@ -617,7 +633,7 @@ func retryDelay(stages []types.RetryStage, elapsed time.Duration, failStreak int if delay < minRetryDelay { delay = minRetryDelay } - return jitterFraction(delay, 2) + return jitterFraction(delay, 4) } // resultKey 生成结果键。 diff --git a/internal/seckill/scheduler_test.go b/internal/seckill/scheduler_test.go index f5369d9..a2114ac 100644 --- a/internal/seckill/scheduler_test.go +++ b/internal/seckill/scheduler_test.go @@ -34,14 +34,11 @@ func makeAttempts(names ...string) []types.ExchangeAttempt { // 让测试只关注重试逻辑本身。 func speedUpPeace(t *testing.T) { t.Helper() - oldPreMin, oldPreMax := peacePreLockMin, peacePreLockMax oldPayMin, oldPayMax := peaceLockPayMin, peaceLockPayMax oldDuration := seckillMaxDuration - peacePreLockMin, peacePreLockMax = 0, 0 peaceLockPayMin, peaceLockPayMax = 0, 0 seckillMaxDuration = 2 * time.Second t.Cleanup(func() { - peacePreLockMin, peacePreLockMax = oldPreMin, oldPreMax peaceLockPayMin, peaceLockPayMax = oldPayMin, oldPayMax seckillMaxDuration = oldDuration }) @@ -145,7 +142,7 @@ func TestPayRetryDelayStaysShort(t *testing.T) { t.Fatalf("在途重试第%d次退避 %v,太长了", try, inFlight) } rateLimited := payRetryDelay(orderErrRateLimited, try) - if rateLimited > 4*time.Second { + if rateLimited > 2600*time.Millisecond { t.Fatalf("频控重试第%d次退避 %v,太长了", try, rateLimited) } } @@ -195,7 +192,7 @@ func TestPayKeepsRetryingBeyondOldAttemptCap(t *testing.T) { } }) - ctl := newRunControl(scheduler, time.Now(), time.Now().Add(30*time.Second), 4) + ctl := newRunControl(scheduler, time.Now(), time.Now().Add(30*time.Second), 4, 1) out := scheduler.runPeaceLockPay(peaceAttempt("a"), ctl) if !out.result.OK { t.Fatalf("服务端在 %d 次「处理中」之后结算,应当继续重试到成功, result=%#v", inFlightTimes, out.result) @@ -224,7 +221,7 @@ func TestPayGivesUpHonestlyAtBudget(t *testing.T) { } }) - ctl := newRunControl(scheduler, time.Now(), time.Now().Add(30*time.Second), 4) + ctl := newRunControl(scheduler, time.Now(), time.Now().Add(30*time.Second), 4, 1) out := scheduler.runPeaceLockPay(peaceAttempt("a"), ctl) if out.result.OK { t.Fatal("预算内不可能成功,不应报成功") @@ -256,8 +253,8 @@ func TestRetryDelayFollowsConfiguredStages(t *testing.T) { {5 * time.Second, 0, 200 * time.Millisecond, 300 * time.Millisecond}, {31 * time.Second, 0, time.Second, 1500 * time.Millisecond}, // 连续失败成倍放大,但不超过 maxRetryDelay - {1 * time.Second, 3, 480 * time.Millisecond, 720 * time.Millisecond}, - {31 * time.Second, 10, 5 * time.Second, 5*time.Second + 2500*time.Millisecond}, + {1 * time.Second, 3, 480 * time.Millisecond, 620 * time.Millisecond}, + {31 * time.Second, 10, 1500 * time.Millisecond, 1900 * time.Millisecond}, } for _, c := range cases { got := retryDelay(stages, c.elapsed, c.streak) @@ -271,6 +268,34 @@ func TestRetryDelayFollowsConfiguredStages(t *testing.T) { } } +// TestRetryDelayNeverBacksOffLong 抢购是争分夺秒的:任何一次退避都必须压在 +// 2 秒以内。2026-10-03 的日志里,库存只活了约 1.1 秒(PC2 四个实例 +// 18:00:00.171~18:00:01.101 一直在成功锁单),超过 2 秒的等待就可能正好错过库存回流。 +func TestRetryDelayNeverBacksOffLong(t *testing.T) { + stages := []types.RetryStage{ + {MaxElapsed: 4, Interval: 120}, + {MaxElapsed: 12, Interval: 200}, + {MaxElapsed: 30, Interval: 500}, + {MaxElapsed: 75, Interval: 1000}, + } + worst := time.Duration(0) + for elapsed := time.Duration(0); elapsed <= 75*time.Second; elapsed += 250 * time.Millisecond { + for streak := 0; streak <= 40; streak++ { + if got := retryDelay(stages, elapsed, streak); got > worst { + worst = got + } + } + } + if worst >= 2*time.Second { + t.Fatalf("最大退避 = %v,必须小于 2 秒", worst) + } + // 也要覆盖用户把阶段间隔调到很大值的极端配置。 + extreme := []types.RetryStage{{MaxElapsed: 75, Interval: 5000}} + if got := retryDelay(extreme, 40*time.Second, 10); got >= 2*time.Second { + t.Fatalf("极端配置下退避 = %v,仍必须小于 2 秒", got) + } +} + func TestRetryIntervalFor(t *testing.T) { stages := []types.RetryStage{ {MaxElapsed: 4, Interval: 120}, @@ -343,7 +368,7 @@ func TestPeacePurchaseLocksThenPaysWithoutChecks(t *testing.T) { } }) - ctl := newRunControl(scheduler, time.Now(), time.Now().Add(time.Minute), 4) + ctl := newRunControl(scheduler, time.Now(), time.Now().Add(time.Minute), 4, 1) out := scheduler.runPeaceLockPay(peaceAttempt("a"), ctl) if !out.result.OK || out.result.RoleName != "测试角色" { t.Fatalf("result = %#v", out) @@ -388,7 +413,7 @@ func TestPayInFlightRetriesQuickly(t *testing.T) { } }) - ctl := newRunControl(scheduler, time.Now(), time.Now().Add(30*time.Second), 4) + ctl := newRunControl(scheduler, time.Now(), time.Now().Add(30*time.Second), 4, 1) start := time.Now() out := scheduler.runPeaceLockPay(peaceAttempt("a"), ctl) elapsed := time.Since(start) @@ -427,7 +452,7 @@ func TestPayFinishesAfterWindowClosed(t *testing.T) { } }) - ctl := newRunControl(scheduler, time.Now(), time.Now().Add(50*time.Millisecond), 4) + ctl := newRunControl(scheduler, time.Now(), time.Now().Add(50*time.Millisecond), 4, 1) out := scheduler.runPeaceLockPay(peaceAttempt("a"), ctl) if !out.result.OK { t.Fatalf("窗口到期后仍应把已锁订单支付完, result=%#v", out.result) @@ -468,7 +493,7 @@ func TestSlowPayDoesNotBlockOtherAccounts(t *testing.T) { }) start := time.Now() - ctl := newRunControl(scheduler, start, start.Add(2500*time.Millisecond), 8) + ctl := newRunControl(scheduler, start, start.Add(2500*time.Millisecond), 8, 1) settings := runSettings{ concurrency: 8, seckillMode: types.SeckillModeAggressive, @@ -508,7 +533,7 @@ func TestStockOutKeepsProbingToWindowEnd(t *testing.T) { }) start := time.Now() - ctl := newRunControl(scheduler, start, start.Add(1200*time.Millisecond), 4) + ctl := newRunControl(scheduler, start, start.Add(1200*time.Millisecond), 4, 1) settings := runSettings{ concurrency: 4, seckillMode: types.SeckillModeAggressive, @@ -550,7 +575,7 @@ func TestStockOutAfterThrottleStillReported(t *testing.T) { }) start := time.Now() - ctl := newRunControl(scheduler, start, start.Add(1200*time.Millisecond), 4) + ctl := newRunControl(scheduler, start, start.Add(1200*time.Millisecond), 4, 1) settings := runSettings{ concurrency: 4, seckillMode: types.SeckillModeAggressive, @@ -585,7 +610,7 @@ func TestThrottledKeepsRetryingToWindowEnd(t *testing.T) { }) start := time.Now() - ctl := newRunControl(scheduler, start, start.Add(1200*time.Millisecond), 4) + ctl := newRunControl(scheduler, start, start.Add(1200*time.Millisecond), 4, 1) settings := runSettings{ concurrency: 4, seckillMode: types.SeckillModeAggressive, @@ -607,6 +632,40 @@ func TestThrottledKeepsRetryingToWindowEnd(t *testing.T) { } } +// TestFirstWaveStartsImmediately 首发是整场最关键的一波:第 0 个账号必须立即发出 +// (定时器已提前约 100ms 补偿 RTT,不能再自己抖几百毫秒),其余按小步长错开, +// 整批铺开时间有上限。 +func TestFirstWaveStartsImmediately(t *testing.T) { + if got := firstWaveStepFor(1); got != 0 { + t.Fatalf("单账号步长 = %v, want 0(必须立即发出)", got) + } + for _, count := range []int{2, 34, 100, 500, 2000} { + step := firstWaveStepFor(count) + if step > firstWaveMaxStep { + t.Fatalf("账号数 %d 的步长 = %v, 超过上限 %v", count, step, firstWaveMaxStep) + } + spread := time.Duration(count-1) * step + if spread > firstWaveSpread+step { + t.Fatalf("账号数 %d 整批铺开 %v, 超过上限 %v", count, spread, firstWaveSpread) + } + } + // 位次必须从 0 开始且逐个递增,保证总有账号在 0 延时首发。 + s := &Scheduler{} + ctl := newRunControl(s, time.Now(), time.Now().Add(time.Minute), 4, 4) + for want := 0; want < 4; want++ { + got, step := ctl.firstWaveSlot() + if got != want { + t.Fatalf("第%d个位次 = %d", want, got) + } + if step <= 0 { + t.Fatalf("4 个账号时步长应为正数, got %v", step) + } + } + if got, _ := ctl.firstWaveSlot(); got != 4 { + t.Fatalf("位次应继续递增, got %d", got) + } +} + // ========== RunNow 端到端 ========== func TestRunNowEndToEnd(t *testing.T) { diff --git a/wails.json b/wails.json index 54f3725..8639848 100644 --- a/wails.json +++ b/wails.json @@ -2,7 +2,7 @@ "$schema": "https://wails.io/schemas/config.v2.json", "name": "douyu_go", "outputfilename": "douyu_go", - "version": "0.3.0", + "version": "0.3.1", "frontend:dir": "frontend", "frontend:install": "npm install", "frontend:build": "npm run build", @@ -15,6 +15,6 @@ "email": "yml@example.com" }, "info": { - "productVersion": "0.3.0" + "productVersion": "0.3.1" } }