10-23 三台机器 13 个实例抢同一次 18:00 的精英手册,把当天日志翻完发现 四个问题都指向"请求发得太猛、发得太早、结论分得太粗": - 被限流的账号仍按 100ms 节奏猛打。单实例锁单 950~1650 次(约 20 QPS)时 18~24 个账号被"您点击太快",而锁单仅 148 次的实例 30 个账号一次全中—— 可见"点击太快/活动火爆"是账号级封禁,继续打只会加深封禁。 - 定时器提前约 100ms 触发,想用这点提前量抵消 RTT,结果 148 次请求 被明确拒绝:"不在时间范围内 不能兑换"。它们全部落在斗鱼估算时间 -0.026s ~ +0.172s,也就是活动还没开闸就冲进去了。 - 积分基线写盘用 os.Rename 覆盖旧文件,Windows 上会报 Access is denied, 三机日志里出现 281 次"积分基线写入失败",基线没落盘导致 pc-1 四个实例 0 条核对记录,且基线为 0 时会误报"积分未见减少"。 - "已达本次活动兑换上限"被记成账号级终止失败。这其实是"该账号在本次活动 里已经换到过了",10-23 日志里这些账号紧接着就报了"积分已扣减,兑换确认"。 - 限流改为单账号阶梯降频:新增 throttleBackoff,第 1 次等 1~2s、第 2 次 2~3s、第 3 次 3~4s……上限 5s,区间内随机取值避免同步醒来。throttleStreak 独立计数,非限流的失败会把它归零——只有"连续被限流"才越等越久。 每个账号跑在自己的 goroutine 里,降频只压它自己的下一次尝试, 不占用并发额度、不影响其它账号的节奏。 - 触发时刻改为精确命中:dynamicAdvance 返回 0,不再提前。另加兜底守卫—— 服务端回"不在时间范围内"时该次尝试不计失败、不消耗 attempts/failStreak、 不触发降频,用 50~150ms 快速重试直到活动真正开始。结果集里的 "失败|不在时间范围内…"同步改写成"等待活动开始",避免界面闪红。 - 积分基线改异步写盘:flushCh 队列 + 后台协程串行写,抢购收尾不再等写盘; 新增 Flush 供需要确定落盘的场合。另按 build tag 分平台解决 Windows os.Rename 不能覆盖已有文件的问题(replace_file_unix/windows.go)。 - "已达兑换上限"单列:新增 outcomeLimitReached,结果集写"已抢到|"前缀, 汇总行加"已达兑换上限=N",既不计入失败也不混进"账号终止"。 - 账号不做全局去重:同一账号可以买多个皮肤,不同实例也可以抢不同皮肤。 - 新增测试 throttle_backoff_test.go(12 个用例):降频阶梯递增、单账号级 隔离、非限流失败重置阶梯、兑换上限不计失败且立即收手、精确命中不提前、 活动未开始不触发降频且不记失败、Flush 同步落盘。 - 三个原有测试因降频语义变化需要调整时间尺度:新增 speedUpThrottleBackoff 只压缩阶梯的时间尺度,不改变阶梯结构。
217 lines
6.8 KiB
Go
217 lines
6.8 KiB
Go
package seckill
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"douyu_go/internal/constants"
|
|
"douyu_go/internal/logger"
|
|
"douyu_go/internal/types"
|
|
"douyu_go/internal/utils"
|
|
)
|
|
|
|
// ScheduleRun 注册一次定时抢购。
|
|
func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (types.ScheduleResult, error) {
|
|
if len(req.AccountProducts) == 0 {
|
|
return types.ScheduleResult{}, errors.New("请选择账号和商品")
|
|
}
|
|
if req.TargetTime == "" {
|
|
return types.ScheduleResult{}, errors.New("请设置目标时间")
|
|
}
|
|
|
|
targetLocal, err := time.ParseInLocation("2006-01-02 15:04:05", req.TargetTime, constants.CST)
|
|
if err != nil {
|
|
return types.ScheduleResult{}, fmt.Errorf("时间格式错误: %w", err)
|
|
}
|
|
targetTime := targetLocal.UTC()
|
|
|
|
calibration := s.calibrateDouyuTime("定时")
|
|
serverTime := calibration.EstimatedServerNow()
|
|
|
|
advance := dynamicAdvance(calibration)
|
|
triggerTime := targetTime.Add(-advance)
|
|
remain := triggerTime.Sub(serverTime)
|
|
if remain <= 0 {
|
|
return types.ScheduleResult{}, errors.New("目标时间已过")
|
|
}
|
|
|
|
ap := utils.CloneStringMap(req.AccountProducts)
|
|
s.scheduleMu.Lock()
|
|
s.stopSchedulesLocked()
|
|
s.clearWarmups()
|
|
s.scheduleMu.Unlock()
|
|
|
|
s.scheduleTimers(ap, targetTime, triggerTime, calibration, req.Attempts, req.DelayMs, false)
|
|
|
|
resyncRemain := triggerTime.Sub(serverTime) - resyncBeforeTrigger
|
|
if resyncRemain < 0 {
|
|
resyncRemain = 0
|
|
}
|
|
|
|
s.scheduleMu.Lock()
|
|
s.replaceTimerLocked(&s.resyncSchedule, resyncRemain, func() {
|
|
if s.IsRunning() {
|
|
logger.LogEvent("定时", "info", "临近复校时抢购已开始,不再改写触发时刻")
|
|
return
|
|
}
|
|
newCalibration := s.calibrateDouyuTime("临近")
|
|
if s.IsRunning() {
|
|
logger.LogEvent("定时", "info", "临近复校完成时抢购已开始,保持当前任务")
|
|
return
|
|
}
|
|
newTriggerTime := targetTime.Add(-dynamicAdvance(newCalibration))
|
|
s.scheduleTimers(ap, targetTime, newTriggerTime, newCalibration, req.Attempts, req.DelayMs, true)
|
|
})
|
|
s.scheduleMu.Unlock()
|
|
|
|
warmupRemain := remain - warmupBeforeTrigger
|
|
if warmupRemain < 0 {
|
|
warmupRemain = 0
|
|
}
|
|
|
|
logger.LogEvent("定时", "success", "定时已设置: 目标=%s 触发=%s 精确命中(不提前) 斗鱼估算=%s 本地偏移=%dms 误差<=%dms RTT=%dms 预热=%s 复校=%s",
|
|
targetTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
|
triggerTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
|
serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
|
calibration.Offset.Milliseconds(),
|
|
calibration.ErrorBound.Milliseconds(),
|
|
calibration.RTT.Milliseconds(),
|
|
time.Now().Add(warmupRemain).In(constants.CST).Format("15:04:05.000"),
|
|
time.Now().Add(resyncRemain).In(constants.CST).Format("15:04:05.000"),
|
|
)
|
|
|
|
remainSecond := int(math.Ceil(triggerTime.Sub(serverTime).Seconds()))
|
|
if remainSecond < 0 {
|
|
remainSecond = 0
|
|
}
|
|
|
|
return types.ScheduleResult{
|
|
Success: true,
|
|
TargetTime: targetTime.In(constants.CST).Format("2006-01-02 15:04:05"),
|
|
TriggerTime: triggerTime.In(constants.CST).Format("2006-01-02 15:04:05"),
|
|
RemainSecond: remainSecond,
|
|
OffsetMs: calibration.Offset.Milliseconds(),
|
|
ErrorBoundMs: calibration.ErrorBound.Milliseconds(),
|
|
RttMs: calibration.RTT.Milliseconds(),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Scheduler) scheduleTimers(accountProducts map[string]string, targetTime, triggerTime time.Time, calibration types.TimeCalibration, attempts, delayMs int, resync bool) {
|
|
serverTime := calibration.EstimatedServerNow()
|
|
remain := triggerTime.Sub(serverTime)
|
|
if remain < 0 {
|
|
remain = 0
|
|
}
|
|
s.scheduleMu.Lock()
|
|
defer s.scheduleMu.Unlock()
|
|
if !resync {
|
|
warmupRemain := remain - warmupBeforeTrigger
|
|
if warmupRemain < 0 {
|
|
warmupRemain = 0
|
|
}
|
|
gen := atomic.AddInt64(&s.warmupGen, 1)
|
|
s.replaceTimerLocked(&s.warmupSchedule, warmupRemain, func() {
|
|
if atomic.LoadInt64(&s.warmupGen) != gen {
|
|
return
|
|
}
|
|
if s.clientSnapshot().Activity.IsXiaodian {
|
|
return
|
|
}
|
|
logger.LogEvent("预热", "success", "预热触发: 本地=%s", time.Now().In(constants.CST).Format("15:04:05.000"))
|
|
warmupTasks := s.buildTasks(accountProducts)
|
|
s.warmupAttempts(warmupTasks)
|
|
})
|
|
}
|
|
s.replaceTimerLocked(&s.schedule, remain, func() {
|
|
now := time.Now()
|
|
estimatedServer := now.Add(calibration.Offset)
|
|
logger.LogEvent("定时", "success", "定时触发: 本地=%s 斗鱼估算=%s 偏移=%dms 误差<=%dms",
|
|
now.In(constants.CST).Format("15:04:05.000"),
|
|
estimatedServer.In(constants.CST).Format("15:04:05.000"),
|
|
calibration.Offset.Milliseconds(),
|
|
calibration.ErrorBound.Milliseconds())
|
|
if !s.TryStartRun() {
|
|
logger.LogEvent("抢购", "warn", "已有抢购任务正在运行,本次定时触发已跳过")
|
|
return
|
|
}
|
|
if s.clientSnapshot().Activity.IsXiaodian {
|
|
s.RunXiaodian(accountProducts, attempts, delayMs, "")
|
|
return
|
|
}
|
|
s.RunNow(accountProducts)
|
|
})
|
|
if resync {
|
|
logger.LogEvent("定时", "success", "临近复校已应用: 目标=%s 触发=%s 斗鱼估算=%s 偏移=%dms 误差<=%dms RTT=%dms",
|
|
targetTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
|
triggerTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
|
serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
|
calibration.Offset.Milliseconds(),
|
|
calibration.ErrorBound.Milliseconds(),
|
|
calibration.RTT.Milliseconds())
|
|
}
|
|
}
|
|
|
|
// CancelSchedule 停止尚未触发的定时任务。
|
|
func (s *Scheduler) CancelSchedule() bool {
|
|
s.scheduleMu.Lock()
|
|
defer s.scheduleMu.Unlock()
|
|
stopped := false
|
|
if s.schedule != nil {
|
|
stopped = s.schedule.Stop()
|
|
}
|
|
s.stopSchedulesLocked()
|
|
s.clearWarmups()
|
|
return stopped
|
|
}
|
|
|
|
func (s *Scheduler) calibrateDouyuTime(label string) types.TimeCalibration {
|
|
cli := s.clientSnapshot()
|
|
calibration, err := cli.BestServerTimeCalibration(timeCalibrateRounds, timeCalibrateWait)
|
|
if err != nil {
|
|
logger.LogEvent("时间", "warn", "%s校准降级: %v", label, err)
|
|
}
|
|
if !calibration.Fallback {
|
|
s.recordCalibrationOffset(calibration.Offset)
|
|
}
|
|
smoothed := s.smoothedOffset()
|
|
if smoothed == 0 && calibration.Fallback {
|
|
smoothed = calibration.Offset
|
|
}
|
|
calibration.Offset = smoothed
|
|
logger.SetDouyuOffset(smoothed)
|
|
logger.LogEvent("时间", "success", "%s校准完成: 斗鱼估算=%s 平滑偏移=%dms 误差<=%dms RTT=%dms 采样=%d 来源=%s",
|
|
label,
|
|
calibration.EstimatedServerNow().In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
|
smoothed.Milliseconds(),
|
|
calibration.ErrorBound.Milliseconds(),
|
|
calibration.RTT.Milliseconds(),
|
|
calibration.Samples,
|
|
calibration.Source)
|
|
return calibration
|
|
}
|
|
|
|
func (s *Scheduler) replaceTimerLocked(timer **time.Timer, d time.Duration, fn func()) {
|
|
if *timer != nil {
|
|
(*timer).Stop()
|
|
}
|
|
*timer = time.AfterFunc(d, fn)
|
|
}
|
|
|
|
func (s *Scheduler) stopSchedulesLocked() {
|
|
if s.schedule != nil {
|
|
s.schedule.Stop()
|
|
s.schedule = nil
|
|
}
|
|
if s.warmupSchedule != nil {
|
|
s.warmupSchedule.Stop()
|
|
s.warmupSchedule = nil
|
|
}
|
|
if s.resyncSchedule != nil {
|
|
s.resyncSchedule.Stop()
|
|
s.resyncSchedule = nil
|
|
}
|
|
}
|