Files
dygou_go/internal/seckill/run_control.go
T
yml2213 43270e04a6 收紧重试退避并把首发请求改成立即发出,发布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 秒"、首发错开说明
2026-10-03 19:21:26 +08:00

159 lines
4.2 KiB
Go

package seckill
import (
"sync"
"time"
)
// runControl 承载一次抢购任务的共享状态:窗口截止时间、并发闸门、请求计数。
//
// 手册链路(精英手册锁单支付 / 电竞手册直接兑换)下,每个账号在各自的 goroutine
// 中独立重试,互不阻塞。并发闸门作用在"单次 HTTP 请求"上而不是整个账号循环上,
// 这样账号在退避等待期间不会占着并发额度。
type runControl struct {
s *Scheduler
start time.Time
deadline time.Time
stop <-chan struct{}
limit chan struct{}
firstWaveStep time.Duration
mu sync.Mutex
lockReqs int
payReqs int
exchangeReqs int
firstWaveUsed int
}
// 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),
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 {
case c.limit <- struct{}{}:
return true
default:
}
select {
case c.limit <- struct{}{}:
return true
case <-c.stop:
return false
}
}
// leave 释放并发额度。
func (c *runControl) leave() {
<-c.limit
}
// stopped 返回是否已收到停止指令。
func (c *runControl) stopped() bool {
return c.s.runStopped()
}
// expired 返回抢购窗口是否已经结束(结束后不再发起新的锁单)。
func (c *runControl) expired() bool {
return !time.Now().Before(c.deadline)
}
// elapsed 返回任务开始至今的时间,用于按配置的重试阶段取节奏。
func (c *runControl) elapsed() time.Duration {
return time.Since(c.start)
}
// sleep 在抢购窗口内等待,等待时间会被剩余窗口裁剪。
// 返回 false 表示该账号应当结束循环(窗口结束或收到停止指令)。
func (c *runControl) sleep(d time.Duration) bool {
return c.sleepUntil(c.deadline, d) && !c.stopped()
}
// sleepUntil 在指定截止时间前等待。
// 返回 false 表示到达截止时间或收到停止指令,调用方应当结束当前流程。
//
// 支付阶段用锁单过期时间作为截止,而不是抢购窗口:锁单已经占用库存,
// 窗口结束后仍要把已锁订单支付完,中途放弃等于白锁。
func (c *runControl) sleepUntil(limit time.Time, d time.Duration) bool {
if d > 0 {
remaining := time.Until(limit)
if remaining <= 0 {
return false
}
if d > remaining {
d = remaining
}
if c.s.interruptibleSleep(d) {
return false
}
}
return time.Now().Before(limit) && !c.stopped()
}
func (c *runControl) countLockReq() {
c.mu.Lock()
c.lockReqs++
c.mu.Unlock()
}
func (c *runControl) countPayReq() {
c.mu.Lock()
c.payReqs++
c.mu.Unlock()
}
func (c *runControl) countExchangeReq() {
c.mu.Lock()
c.exchangeReqs++
c.mu.Unlock()
}
// counts 返回锁单 / 支付 / 直接兑换的真实请求次数,用于汇总日志。
func (c *runControl) counts() (lock, pay, exchange int) {
c.mu.Lock()
defer c.mu.Unlock()
return c.lockReqs, c.payReqs, c.exchangeReqs
}