收紧重试退避并把首发请求改成立即发出,发布0.3.1

- 退避上限从 5 秒压到 1.5 秒(含抖动最长约 1.9 秒),连续失败放大倍数 8→4,抖动 50%→25%:
  2026-10-03 实测库存只活了约 1.1 秒(PC2 四个实例 18:00:00.171~18:00:01.101 一直能锁到
  同一个礼包),而 PC1 默认实例的 31 个号在 18:00:00.225 失败后一直睡到 18:01:09.379,
  唯一有货的那段窗口被退避吃掉;超过 2 秒的等待都可能正好错过库存回流
- 首发请求不再随机等 150-700 毫秒,改为"第 0 个账号立即发出 + 其余按 8ms 步长错开",
  整批铺开不超过 300 毫秒(账号再多也不会拖长)。定时器本来就提前约 100ms 触发以补偿 RTT,
  再抖几百毫秒等于把这个提前量整个抵消,请求实际落到 t+150ms~t+650ms
- 锁单成功到首次支付的等待从 2-4 秒压到 300-700 毫秒:这段纯属把关键路径上的时间白送出去,
  "订单操作进行中"现在按"已受理、结果未定"快重试,不需要靠等待规避
- 支付退避上限同步收紧:频控 3s→2s,其它 2s→1.2s(在途仍为 300ms→1s)
- 新增 TestRetryDelayNeverBacksOffLong:遍历 75 秒 × 40 次连续失败,断言任何一次退避都
  小于 2 秒,并覆盖"重试间隔策略被配到 5000ms"的极端配置
- 新增 TestFirstWaveStartsImmediately:单账号步长必须为 0、位次从 0 递增、
  2/34/100/500/2000 个账号下步长 ≤8ms 且整批铺开 ≤300ms
- 设置页文案同步:退避"任何一次等待都不超过约 2 秒"、首发错开说明
This commit is contained in:
yml2213
2026-10-03 19:21:26 +08:00
parent 46c06180a5
commit 43270e04a6
7 changed files with 159 additions and 43 deletions
@@ -233,7 +233,7 @@ defineExpose({ open, close })
</div>
<div class="form-hint">
按经过秒数从小到大排列,控制精英/电竞手册的<strong>锁单</strong>重试节奏;
同一账号连续失败会在此基础上成倍放大(最长 5 秒)
同一账号连续失败会在此基础上成倍放大,但<strong>任何一次等待都不超过约 2 秒</strong>
</div>
</div>
@@ -243,6 +243,7 @@ defineExpose({ open, close })
<li>抢购窗口:<strong>75 秒</strong>;窗口内每个账号各自独立重试,互不阻塞</li>
<li>默认:阶段 2 为 200ms,阶段 3 为 500ms,其余阶段 1000ms</li>
<li>已锁单的订单即使窗口结束也会把<strong>支付</strong>重试完(最长 120 秒)</li>
<li>首发请求:第一个账号立即发出,其余按几毫秒步长错开,整批不超过 0.3 秒</li>
<li>"活动火爆/网络繁忙"是请求级限流(同一秒别的账号仍能锁单),会继续重试</li>
<li>"库存已耗完"也不提前停止:别人锁单未支付时库存会回流,会降频探测到窗口结束</li>
</ul>
+1 -1
View File
@@ -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"
+15 -8
View File
@@ -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 = "失败|已收到停止指令"
+44 -11
View File
@@ -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 {
+21 -5
View File
@@ -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 生成结果键。
+74 -15
View File
@@ -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) {
+2 -2
View File
@@ -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"
}
}