Files
dygou_go/internal/seckill/replace_file_windows.go
yml2213 288109de2f 按 10-23 三机抢购日志复盘修正重试节奏与状态口径,发布0.3.8
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
  只压缩阶梯的时间尺度,不改变阶梯结构。
2026-10-24 12:27:21 +08:00

36 lines
1.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build windows
package seckill
import "os"
// replaceFile 替换目标文件,兼容 Windows 的 rename 限制。
//
// Windows 的 MoveFileEx(os.Rename 底层调用)默认不能覆盖已存在的文件,
// 会直接报 "Access is denied"。2026-10-23 的三机日志里因此出现 281 次
// "积分基线写入失败"——多实例同时回写同一个 scores.json 时尤其频繁,
// 结果是基线没落盘,抢购后核对拿不到基线,只能记成"还没有基线",
// 下一次抢购也就无法比对积分扣减。
//
// 策略:先试 rename(快路径);失败则删掉旧文件再 rename;
// 再失败则退化为直接写目标文件(牺牲原子性,但总比写不进去好)。
func replaceFile(src, dst string) error {
err := os.Rename(src, dst)
if err == nil {
return nil
}
// 目标存在时 Windows 会拒绝 rename:先删旧文件。
if removeErr := os.Remove(dst); removeErr != nil && !os.IsNotExist(removeErr) {
return err
}
if renameErr := os.Rename(src, dst); renameErr == nil {
return nil
}
// 最后兜底:直接覆盖写。
data, readErr := os.ReadFile(src)
if readErr != nil {
return err
}
return os.WriteFile(dst, data, 0o600)
}