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"
}
}