* perf: optimize request limiting Replace per-request full-map scan under a global mutex with a per-IP sliding window and throttled reclamation; publish config as an immutable atomic snapshot to remove reload/read races. * fix: sample limiter timestamp under lock and use per-request config snapshot - rateLimiter.allow now samples time.Now() while holding the mutex, so entries[ip] stays time-ascending under concurrency; allowAt/allowLocked share the same locked logic for deterministic tests. - handler passes its single config snapshot into proxy and render404 so one request never mixes config versions. - rewrite sweep test to hit sweepLocked directly; restore config/log/gin global state in tests; add real-clock concurrency ordering test. * test: make limiter benchmarks measure their stated operations - BenchmarkLimiterSweep 把「新建限流器 + 填充 5000 个 IP」的初始化成本计入了 被测区间,测得的 ~10.7ms/op 不能解释为单次 sweep 成本。改用 StopTimer/StartTimer 把初始化排除在计时之外(现 ~1.6ms/op),并新增 BenchmarkLimiterSweepWithFill 明确保留「填充+回收」的端到端口径(~16ms/op,含初始化)。 - 并行基准中每个 worker 都从 i := 0 开始,多 IP 场景实际退化为争抢同一个 IP。 改为按 worker 分配互不相同的起始偏移(乘一个与 IP 数互质的步长),使访问分布 真正覆盖多个 IP;偏移只在 worker 启动时计算一次,不增加每次请求的测试开销。 - 统一新旧对比的状态:两套基准都预填 limit+1 条记录,使双方都处于「已满→拒绝」 的稳态拒绝路径,且每-IP 记录条数一致(101)。原基准旧实现预填 limit 条,第 1 次 调用会先走放行路径(把记录推到 limit+1,增长有界);原基准新实现预填 limit 条 即已拒绝(记录数 limit)。两边的路径与记录数并不严格同态,故此前的新旧对比 不能作为「同状态对比」沿用;统一后实际数字变化不大,报告里会写明。 * test: fix limiter benchmark priming state and comparison basis (#3) * test: fix limiter benchmark priming state and comparison basis 上一提交(8d6781e)的基准注释与实际行为不符:新实现经公开入口 (allow/allowAt)预填 limit+1 次时,第 limit+1 次调用**已被拒绝、不会追加**, 因此实际只保留 limit 条记录(实测 100),而旧实现侧直接写入 limit+1 条 (实测 101)。原注释却写成两边同为 limit+1、声称「记录数一致(101)」, 属错误陈述。 修正方式(不做伪统一,改为如实描述并各自准备稳态): - 不再声称两边记录数相同。两个实现的拒绝条件不同(旧 len > limit, 新 len >= limit),可长期停留的饱和记录数天然相差 1(101 vs 100), 这正是本 PR 修正的边界偏差,不可也不应被「统一」掉: 新实现无法经公开入口达到 limit+1 条(运行期不可达状态),旧实现若只预填 limit 条则第 1 次调用会先放行追加,使计时区间混入路径不同的样本。 - 改为让每侧各自准备在自己的饱和拒绝态,使计时区间内每次调用都是同一条 「已饱和 → 拒绝」路径;各自的记录条数由 prime 函数返回并被断言。 - 新增 TestBenchmarkPrimeStates:断言新实现饱和态 = limit 条、旧实现 = limit+1 条, 且饱和后调用被拒绝、记录数不再增长,防止注释再次与代码脱节。 - 修正 NewRealClock 系列:原实现未预置,计时区间混入前 limit 次放行调用; 现同样预置到饱和态(真实时钟),并说明其时钟口径不可与注入固定时间的 New/Old 两组混比。 - 记录一个踩坑:b.ResetTimer() 只归零累计耗时、**不会启动**计时器, b.StopTimer() 之后必须显式 b.StartTimer(),否则计时区间恒为 0、 框架会把 b.N 放大到上限(实测卡在 1e9)。已在代码内注明。 其余(sweep 初始化移出计时区间 + SweepWithFill 端到端口径、并行基准 worker 起始偏移分散)沿用上一提交,未改动实现代码 main.go 与其它测试。 * test: fill 10 records per IP in sweep fixture and state timing scope fixSweepLimiter() 的注释与 PR 描述声称「每个 IP 10 条记录」,但代码里每个 IP 只调用一次 l.allowAt(ip, 10, base),实际只写入 1 条记录,导致回收基准测的是 「5000 个 IP × 1 条」而非声称的「5000 个 IP × 10 条」,与 ns/op 的可解释性 一并失真。 修正: - fixSweepLimiter() 改为经限流器正常入口(allowAt)对每个 IP 调用 sweepLimit (10) 次,使每个 IP 恰好写入 10 条记录;并在填充后自检每个 IP 的记录数, 防止注释再次与代码脱节。 - 新增常量 sweepLimit / sweepRecordsPerIP / sweepIPCount,消除散落的魔数。 - 明确两者的计时范围:BenchmarkLimiterSweep **排除**填充成本(StopTimer 期间 填充,仅计 sweepLocked 与其触发它的那一次 allow),BenchmarkLimiterSweepWithFill **有意计入**填充成本,故其结果不得当作纯回收耗时。 - 新增 TestSweepFixtureRecordsAllExpired:断言夹具每个 IP 恰好 10 条记录、且这 10 条在触发回收时全部早于 cutoff(全部过期),并且一次 sweep 后仅剩新 IP。 工作量随之从 5,000 条变为 50,000 条记录,Sweep 的 ns/op 上升属预期(测的东西 变多了),不是性能回归。 仅改 bench_test.go;未改 main.go、其它测试、go.mod/go.sum、CI、Dockerfile。 * test: fix sweep benchmark timing scope note and per-iteration trigger time GPT 复核剩余两点,均已修正(仅 bench_test.go): 1) fixSweepLimiter 的注释说「填充工作不计入任何计时区间:调用方需在 StopTimer 状态下调用它」,与 BenchmarkLimiterSweepWithFill 有意把填充计入计时相矛盾。 改为如实描述:填充成本是否计入**由调用方决定**,并分别点名两个基准的做法。 2) BenchmarkLimiterSweep / SweepWithFill 把 base := time.Now() 放在循环外,且固定 使用 base.Add(2*time.Minute)。这是真实隐患:newRateLimiter 会把 lastSweep 设为 当时的真实 time.Now(),而 sweep 的触发条件是 now.Sub(l.lastSweep) >= l.sweepEvery (sweepEvery 已置 0,即要求触发时刻**不早于** lastSweep)。一旦基准循环的墙钟时间 超过 2 分钟,循环外的 next 就会早于新一轮限流器的 lastSweep,差值变负 → 回收 **静默不触发**,基准实际退化为在测一条空的拒绝路径(且不报错)。 改为每轮在循环内取 base 并相对本轮填充时刻计算 next,使触发条件恒成立。 已核对:此前的数据并非无效(单次基准循环墙钟约 17~25s,未越过 2 分钟), 但代码是潜在错误的,现已消除。 3) 新增 TestSweepTriggersWithStaleBase 把该陷阱固定下来:用远早于现在的 stale base 构造夹具、以 staleNext 触发时,断言回收**没有**发生(保留 64+1 个 IP);改用相对 当前时刻的触发时刻后,断言回收完成(仅剩 1 个 IP)。 未改 main.go、其它测试、go.mod/go.sum、CI、Dockerfile。 --------- Co-authored-by: Geekertao-bot <254965144+Geekertao-bot@users.noreply.github.com> --------- Co-authored-by: Geekertao-bot <254965144+Geekertao-bot@users.noreply.github.com> Co-authored-by: Geekertao-bot(openclaw) <Geekertao@qq.com>
109 lines
2.5 KiB
Go
109 lines
2.5 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// 配置重载与请求读取并发时不应发生数据竞争,且读取方始终看到完整一致的快照。
|
|
func TestConfigReloadConcurrentWithRead(t *testing.T) {
|
|
// 保存并恢复所有被修改的全局状态,避免污染其他测试。
|
|
origConfig := config.Load()
|
|
oldOut := log.Out
|
|
oldLevel := log.GetLevel()
|
|
log.SetOutput(io.Discard)
|
|
t.Cleanup(func() {
|
|
config.Store(origConfig)
|
|
log.SetOutput(oldOut)
|
|
log.SetLevel(oldLevel)
|
|
})
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.json")
|
|
|
|
write := func(n int) error {
|
|
data := fmt.Sprintf(
|
|
`{"debug":false,"whiteList":["v%d"],"requestLimit":{"limitRate":%d}}`,
|
|
n, n,
|
|
)
|
|
return os.WriteFile(path, []byte(data), 0o644)
|
|
}
|
|
|
|
if err := write(0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := loadConfigFrom(path); err != nil {
|
|
t.Fatalf("初始加载失败: %v", err)
|
|
}
|
|
|
|
const rounds = 200
|
|
var wg sync.WaitGroup
|
|
stop := make(chan struct{})
|
|
|
|
// 写方:持续重载配置。
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
defer close(stop)
|
|
for n := 1; n <= rounds; n++ {
|
|
if err := write(n); err != nil {
|
|
t.Errorf("写入配置失败: %v", err)
|
|
return
|
|
}
|
|
if err := loadConfigFrom(path); err != nil {
|
|
t.Errorf("重载配置失败: %v", err)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// 读方:持续读取,验证同一快照内字段一致(whiteList 与 limitRate 对应)。
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
cfg := config.Load()
|
|
if cfg == nil {
|
|
t.Error("config 不应为 nil")
|
|
return
|
|
}
|
|
if len(cfg.WhiteList) == 1 {
|
|
want := cfg.WhiteList[0]
|
|
got := fmt.Sprintf("v%d", cfg.RequestLimit.LimitRate)
|
|
if want != got {
|
|
t.Errorf("快照不一致: whiteList=%q, limitRate=%d", want, cfg.RequestLimit.LimitRate)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
wg.Wait()
|
|
|
|
// 最终配置应等于最后一次写入。
|
|
got := config.Load()
|
|
if got.RequestLimit.LimitRate != rounds {
|
|
t.Fatalf("最终 limitRate 应为 %d,实际 %d", rounds, got.RequestLimit.LimitRate)
|
|
}
|
|
}
|
|
|
|
// 加载不存在的文件应返回错误且不破坏已有配置。
|
|
func TestLoadConfigFromMissingFileKeepsOldConfig(t *testing.T) {
|
|
before := config.Load()
|
|
if err := loadConfigFrom(filepath.Join(t.TempDir(), "nope.json")); err == nil {
|
|
t.Fatal("加载不存在的文件应返回错误")
|
|
}
|
|
if config.Load() != before {
|
|
t.Fatal("加载失败时不应替换已有配置")
|
|
}
|
|
}
|