* 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>
417 lines
17 KiB
Go
417 lines
17 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// ===========================================================================
|
||
// 基准口径说明(改代码时请同步本段,并由 TestBenchmarkPrimeStates 守护)
|
||
//
|
||
// 【为什么不能宣称「两边记录数相同」】
|
||
// 两个实现的「饱和态」(此后每次调用都被拒绝的状态)记录条数天然不同:
|
||
// 旧:拒绝条件 len(entries[ip]) > limit → 饱和记录数 = limit+1
|
||
// 新:拒绝条件 len(times) >= limit → 饱和记录数 = limit
|
||
// 在 limit=100 时即 101 条 vs 100 条。这 1 条之差**正是本 PR 要修正的边界偏差**
|
||
// (旧实现窗口内会多放行一次),不是可以「统一」掉的实现细节:
|
||
// - 新实现**无法**经公开入口(allow / allowAt)达到 limit+1 条记录:第 limit+1 次
|
||
// 调用已被拒绝,不会追加。若要用直接改 map 的方式硬凑 limit+1,那是运行期
|
||
// 不可达的状态,不能代表新实现。
|
||
// - 旧实现若只预填 limit 条,则第 1 次调用会先放行并追加,计时区间内混入一次
|
||
// 与其余调用路径不同的样本。
|
||
// (此前版本的本文件曾把新实现写成「预填 limit+1 次 ⇒ 保留 limit+1 条」,并把
|
||
// 两边记录数写成「一致(101)」——经实测为 100 条,注释与代码不符,已更正。)
|
||
//
|
||
// 【本文件采用的做法】
|
||
// 不做「记录数相同」的伪统一,而是让**每一侧各自准备在自己的饱和拒绝态**:
|
||
// 计时区间内每次调用都是同一条「已饱和 → 拒绝」路径,不含放行与追加;
|
||
// 两侧各自的记录条数由 primeNewRejecting / primeOldRejecting 返回,
|
||
// 并由 TestBenchmarkPrimeStates 断言,避免注释再次与代码脱节。
|
||
//
|
||
// 【计时口径】
|
||
// - BenchmarkLimiterNew_* / BenchmarkLimiterOld_*:注入固定时间 → 纯算法成本,两者可互比
|
||
// - BenchmarkLimiterNewRealClock_*:走生产入口 allow()(锁内采样 time.Now)→ 含真实
|
||
// 时钟成本,只能与 NewRealClock 系列比,不可与前两者混比
|
||
//
|
||
// 【这不是放行路径的对比】
|
||
// 同一记录序列下新旧放行次数不同(limit+1 vs limit),不存在可直接对比的同一
|
||
// 放行状态;如需放行路径数据,必须单独说明状态与记录数,不能与本文件的数字混用。
|
||
// ===========================================================================
|
||
|
||
const (
|
||
// benchLimit 是基准统一使用的限流额度(生产 config.json 为 500;此处取 100 以
|
||
// 控制单次运行时长,两侧一致即可比)。
|
||
benchLimit int64 = 100
|
||
// benchIPsMany 是「多 IP」场景的 IP 数量。
|
||
benchIPsMany = 1000
|
||
)
|
||
|
||
// workerStride 是并行基准中相邻 worker 的起始 IP 偏移步长。
|
||
// 每个 worker 都从 i := 0 开始会让它们初始全部争抢同一个 IP,使多 IP 场景实际
|
||
// 退化成单 IP;给每个 worker 一个互不相同的起始偏移后,访问分布才真正覆盖多个 IP。
|
||
// 取一个与常见 IP 数量互质的较大质数,使起始位置在 IP 空间上分散。
|
||
const workerStride = 1000003
|
||
|
||
// parallelStart 返回当前 worker 的起始索引,仅在每个 worker 启动时调用一次,
|
||
// 不进入 pb.Next() 循环,因此不引入每次请求的额外开销。
|
||
func parallelStart(seq *atomic.Uint64, ipCount int) int {
|
||
return int(seq.Add(1)-1) * workerStride % ipCount
|
||
}
|
||
|
||
// allowOld 复刻优化前的限流实现,仅用于基准对比:
|
||
// 每个请求都加全局锁并遍历所有 IP,且从不删除过期 IP 键。
|
||
func allowOld(entries map[string][]time.Time, mu *sync.Mutex, ip string, limit int64, now time.Time) bool {
|
||
mu.Lock()
|
||
for k, times := range entries {
|
||
var recent []time.Time
|
||
for _, ts := range times {
|
||
if now.Sub(ts) <= time.Minute {
|
||
recent = append(recent, ts)
|
||
}
|
||
}
|
||
entries[k] = recent
|
||
}
|
||
if limit > 0 && len(entries[ip]) > int(limit) {
|
||
mu.Unlock()
|
||
return false
|
||
}
|
||
entries[ip] = append(entries[ip], now)
|
||
mu.Unlock()
|
||
return true
|
||
}
|
||
|
||
func makeIPs(n int) []string {
|
||
ips := make([]string, n)
|
||
for i := range ips {
|
||
ips[i] = fmt.Sprintf("10.%d.%d.%d", (i>>16)&0xff, (i>>8)&0xff, i&0xff)
|
||
}
|
||
return ips
|
||
}
|
||
|
||
// expectedNewRejectingRecords 返回新实现饱和拒绝态的每 IP 记录条数。
|
||
func expectedNewRejectingRecords(limit int64) int { return int(limit) }
|
||
|
||
// expectedOldRejectingRecords 返回旧实现饱和拒绝态的每 IP 记录条数。
|
||
func expectedOldRejectingRecords(limit int64) int { return int(limit + 1) }
|
||
|
||
// primeNewRejecting 把新实现预置到饱和拒绝态:每个 IP 经公开入口放行 limit 次
|
||
// (第 limit 次之后 len == limit,后续调用走拒绝路径)。返回实测记录条数。
|
||
// 预置期间 l.sweepEvery 应已设为不会触发回收的值。
|
||
func primeNewRejecting(l *rateLimiter, ips []string, limit int64, now time.Time) int {
|
||
for _, ip := range ips {
|
||
for j := int64(0); j < limit; j++ {
|
||
if !l.allowAt(ip, limit, now) {
|
||
panic("基准预置失败:limit 次以内不应被拒绝")
|
||
}
|
||
}
|
||
}
|
||
return len(l.entries[ips[0]])
|
||
}
|
||
|
||
// primeNewRejectingRealClock 同 primeNewRejecting,但走生产入口 allow()(真实时钟)。
|
||
func primeNewRejectingRealClock(l *rateLimiter, ips []string, limit int64) int {
|
||
for _, ip := range ips {
|
||
for j := int64(0); j < limit; j++ {
|
||
if !l.allow(ip, limit) {
|
||
panic("基准预置失败:limit 次以内不应被拒绝")
|
||
}
|
||
}
|
||
}
|
||
return len(l.entries[ips[0]])
|
||
}
|
||
|
||
// primeOldRejecting 把旧实现预置到饱和拒绝态:每个 IP 直接写入 limit+1 条记录
|
||
// (旧实现拒绝条件为 len > limit,故饱和态为 limit+1 条)。返回实测记录条数。
|
||
func primeOldRejecting(entries map[string][]time.Time, ips []string, limit int64, now time.Time) int {
|
||
for _, ip := range ips {
|
||
for j := int64(0); j < limit+1; j++ {
|
||
entries[ip] = append(entries[ip], now)
|
||
}
|
||
}
|
||
return len(entries[ips[0]])
|
||
}
|
||
|
||
func benchNew(b *testing.B, ipCount int, limit int64) {
|
||
l := newRateLimiter()
|
||
l.sweepEvery = time.Hour // 排除回收开销,只衡量单请求处理成本
|
||
ips := makeIPs(ipCount)
|
||
now := time.Now()
|
||
|
||
b.ReportAllocs()
|
||
b.StopTimer()
|
||
if got, want := primeNewRejecting(l, ips, limit, now), expectedNewRejectingRecords(limit); got != want {
|
||
b.Fatalf("新实现饱和态记录数 = %d,期望 %d", got, want)
|
||
}
|
||
// 注意:ResetTimer 只归零累计耗时,**不会启动**计时器;StopTimer 之后必须再
|
||
// 显式 StartTimer,否则计时区间恒为 0,框架会把 b.N 一路放大到上限后才停。
|
||
b.ResetTimer()
|
||
b.StartTimer()
|
||
|
||
var seq atomic.Uint64
|
||
b.RunParallel(func(pb *testing.PB) {
|
||
i := parallelStart(&seq, ipCount)
|
||
for pb.Next() {
|
||
l.allowAt(ips[i%ipCount], limit, now)
|
||
i++
|
||
}
|
||
})
|
||
}
|
||
|
||
// benchNewRealClock 衡量生产入口(锁内采样 time.Now)的单请求成本。
|
||
// 时钟口径与 New/Old 两组不同,结果只能在这两者之间比较。
|
||
// 预置与循环都用真实时钟:只要单次 benchmark 的循环耗时远小于 window(1 分钟),
|
||
// 记录不会被裁剪,循环内始终保持「已饱和 → 拒绝」。
|
||
func benchNewRealClock(b *testing.B, ipCount int, limit int64) {
|
||
l := newRateLimiter()
|
||
l.sweepEvery = time.Hour
|
||
ips := makeIPs(ipCount)
|
||
|
||
b.ReportAllocs()
|
||
b.StopTimer()
|
||
if got, want := primeNewRejectingRealClock(l, ips, limit), expectedNewRejectingRecords(limit); got != want {
|
||
b.Fatalf("新实现(真实时钟)饱和态记录数 = %d,期望 %d", got, want)
|
||
}
|
||
b.ResetTimer()
|
||
b.StartTimer()
|
||
|
||
var seq atomic.Uint64
|
||
b.RunParallel(func(pb *testing.PB) {
|
||
i := parallelStart(&seq, ipCount)
|
||
for pb.Next() {
|
||
l.allow(ips[i%ipCount], limit)
|
||
i++
|
||
}
|
||
})
|
||
}
|
||
|
||
func benchOld(b *testing.B, ipCount int, limit int64) {
|
||
entries := make(map[string][]time.Time, ipCount)
|
||
var mu sync.Mutex
|
||
ips := makeIPs(ipCount)
|
||
now := time.Now()
|
||
|
||
b.ReportAllocs()
|
||
b.StopTimer()
|
||
if got, want := primeOldRejecting(entries, ips, limit, now), expectedOldRejectingRecords(limit); got != want {
|
||
b.Fatalf("旧实现饱和态记录数 = %d,期望 %d", got, want)
|
||
}
|
||
b.ResetTimer()
|
||
b.StartTimer()
|
||
|
||
var seq atomic.Uint64
|
||
b.RunParallel(func(pb *testing.PB) {
|
||
i := parallelStart(&seq, ipCount)
|
||
for pb.Next() {
|
||
allowOld(entries, &mu, ips[i%ipCount], limit, now)
|
||
i++
|
||
}
|
||
})
|
||
}
|
||
|
||
func BenchmarkLimiterNew_SingleIP(b *testing.B) { benchNew(b, 1, benchLimit) }
|
||
func BenchmarkLimiterNew_ManyIPs(b *testing.B) { benchNew(b, benchIPsMany, benchLimit) }
|
||
func BenchmarkLimiterOld_SingleIP(b *testing.B) { benchOld(b, 1, benchLimit) }
|
||
func BenchmarkLimiterOld_ManyIPs(b *testing.B) { benchOld(b, benchIPsMany, benchLimit) }
|
||
|
||
func BenchmarkLimiterNewRealClock_SingleIP(b *testing.B) { benchNewRealClock(b, 1, benchLimit) }
|
||
func BenchmarkLimiterNewRealClock_ManyIPs(b *testing.B) {
|
||
benchNewRealClock(b, benchIPsMany, benchLimit)
|
||
}
|
||
|
||
// sweepRecordsPerIP 是回收基准中每个 IP 的记录条数:经公开入口写入 10 条
|
||
// (sweepLimit 恰好为 10,故 10 次调用全部放行、全部被记录)。
|
||
const (
|
||
sweepLimit int64 = 10
|
||
sweepRecordsPerIP = 10
|
||
sweepIPCount = 5000
|
||
)
|
||
|
||
// fixSweepLimiter 构造一个「已填充 n 个 IP、每个 IP sweepRecordsPerIP 条记录、
|
||
// 且下一次 allow 必然触发回收的限流器。
|
||
// 填充走限流器正常入口(allowAt),每个 IP 调用 sweepLimit 次,因此每个 IP 恰好
|
||
// 记录 10 条(t == base),并在 [base+2min] 触发回收时全部过期(window=1min,
|
||
// cutoff = base+1min,10 条均早于 cutoff)。
|
||
//
|
||
// 返回值中 l.sweepEvery 已置 0,只要传入的触发时刻**不早于** l.lastSweep
|
||
// (= 本函数内部 newRateLimiter 时的 time.Now()),下一次 allow 就会触发 sweepLocked。
|
||
// 因此调用方必须把触发时刻算成本轮填充的时刻(base)再加偏移,不能拿到循环外
|
||
// 复用同一个绝对时刻:那会随着真实时间前进使触发时刻反而早于 l.lastSweep,
|
||
// 此时 now.Sub(lastSweep) 为负、sweepEvery=0 也满足不了条件,回收会**静默不触发**,
|
||
// 基准就变成了在测一条空的拒绝路径。
|
||
//
|
||
// 填充成本是否计入计时区间**由调用方决定**:
|
||
// - BenchmarkLimiterSweep 在 StopTimer 状态下调用以排除填充成本;
|
||
// - BenchmarkLimiterSweepWithFill 则在计时区间内调用,有意把填充计入。
|
||
func fixSweepLimiter(n int, base time.Time) *rateLimiter {
|
||
ips := makeIPs(n)
|
||
l := newRateLimiter()
|
||
l.sweepEvery = time.Hour // 填充期间不触发回收
|
||
for _, ip := range ips {
|
||
for j := 0; j < sweepRecordsPerIP; j++ {
|
||
if !l.allowAt(ip, sweepLimit, base) {
|
||
panic("回收基准填充失败:sweepLimit 次以内不应被拒绝")
|
||
}
|
||
}
|
||
}
|
||
// 防注释与代码脱节:每个 IP 必须恰好 sweepRecordsPerIP 条记录。
|
||
for _, ip := range ips {
|
||
if got := len(l.entries[ip]); got != sweepRecordsPerIP {
|
||
panic(fmt.Sprintf("回收基准填充记录数 = %d,期望 %d", got, sweepRecordsPerIP))
|
||
}
|
||
}
|
||
l.sweepEvery = 0 // 下一次 allow 立即触发 sweepLocked
|
||
return l
|
||
}
|
||
|
||
// BenchmarkLimiterSweep 只衡量**一次回收操作**本身。
|
||
//
|
||
// 工作负载:sweepIPCount(5000) 个 IP × sweepRecordsPerIP(10) 条记录 = 50,000 条,
|
||
// 全部已在窗口外过期 → 一次 sweepLocked 应把 5000 个 IP 键全部删除。
|
||
//
|
||
// **计时范围**:构造限流器与填充 5000 个 IP 属于初始化成本,通过
|
||
// StopTimer/StartTimer 排除在计时区间之外;报告中的 ns/op、B/op、allocs/op
|
||
// 只反映 sweepLocked 及其触发它的那一次 allow 调用本身,可解释为「单次 sweep
|
||
// 的成本」,**不含**填充成本。
|
||
//
|
||
// **触发时刻每轮重算**:base 与 next 都在循环内、相对于本轮填充时刻计算。
|
||
// 若把 base 提到循环外,长时间运行后 next 会早于新一轮限流器的 lastSweep
|
||
// (= newRateLimiter 时的真实 now),now.Sub(lastSweep) 为负 → 回收不触发。
|
||
func BenchmarkLimiterSweep(b *testing.B) {
|
||
b.ReportAllocs()
|
||
b.ResetTimer()
|
||
for i := 0; i < b.N; i++ {
|
||
b.StopTimer()
|
||
base := time.Now()
|
||
l := fixSweepLimiter(sweepIPCount, base)
|
||
next := base.Add(2 * time.Minute) // 相对本轮填充时刻,保证晚于 l.lastSweep
|
||
b.StartTimer()
|
||
|
||
l.allowAt("192.168.0.1", sweepLimit, next) // 触发 sweepLocked
|
||
}
|
||
}
|
||
|
||
// BenchmarkLimiterSweepWithFill 衡量**完整周期**:新建限流器 + 填充 5000 个 IP
|
||
// (每个 10 条记录)+ 触发一次回收。
|
||
//
|
||
// **计时范围**:与 BenchmarkLimiterSweep 相反,它**有意把填充成本计入**被测区间,
|
||
// 因此其结果**不能**当作纯回收耗时解释;保留它是为了观察「填充 + 回收」整体开销
|
||
// 随规模的变化。
|
||
//
|
||
// 触发时刻同样每轮重算(见 BenchmarkLimiterSweep 的说明),避免长跑后回收不触发。
|
||
func BenchmarkLimiterSweepWithFill(b *testing.B) {
|
||
b.ReportAllocs()
|
||
b.ResetTimer()
|
||
for i := 0; i < b.N; i++ {
|
||
base := time.Now()
|
||
l := fixSweepLimiter(sweepIPCount, base)
|
||
next := base.Add(2 * time.Minute)
|
||
l.allowAt("192.168.0.1", sweepLimit, next)
|
||
}
|
||
}
|
||
|
||
// TestBenchmarkPrimeStates 断言基准预置状态与文件头部注释所述一致:
|
||
// 新实现饱和态 = limit 条,旧实现饱和态 = limit+1 条,且饱和后调用被拒绝、
|
||
// 记录数不再增长。两者相差 1 是边界语义差异的必然结果,不可宣称「两边相同」。
|
||
func TestBenchmarkPrimeStates(t *testing.T) {
|
||
const limit = int64(10)
|
||
now := time.Now()
|
||
ips := []string{"10.0.0.1"}
|
||
|
||
l := newTestLimiter()
|
||
if got, want := primeNewRejecting(l, ips, limit, now), expectedNewRejectingRecords(limit); got != want {
|
||
t.Fatalf("新实现饱和态记录数 = %d,期望 %d", got, want)
|
||
}
|
||
if l.allowAt(ips[0], limit, now) {
|
||
t.Fatal("新实现饱和态应拒绝")
|
||
}
|
||
if got, want := len(l.entries[ips[0]]), expectedNewRejectingRecords(limit); got != want {
|
||
t.Fatalf("被拒绝后新实现记录数 = %d,期望保持不变 %d", got, want)
|
||
}
|
||
|
||
entries := map[string][]time.Time{}
|
||
var mu sync.Mutex
|
||
if got, want := primeOldRejecting(entries, ips, limit, now), expectedOldRejectingRecords(limit); got != want {
|
||
t.Fatalf("旧实现饱和态记录数 = %d,期望 %d", got, want)
|
||
}
|
||
if allowOld(entries, &mu, ips[0], limit, now) {
|
||
t.Fatal("旧实现饱和态应拒绝")
|
||
}
|
||
if got, want := len(entries[ips[0]]), expectedOldRejectingRecords(limit); got != want {
|
||
t.Fatalf("被拒绝后旧实现记录数 = %d,期望保持不变 %d", got, want)
|
||
}
|
||
|
||
t.Logf("饱和态记录数:新实现 %d / 旧实现 %d(limit=%d,相差 1 即本 PR 修正的边界偏差)",
|
||
expectedNewRejectingRecords(limit), expectedOldRejectingRecords(limit), limit)
|
||
}
|
||
|
||
// TestSweepFixtureRecordsAllExpired 断言回收基准的填充夹具与注释一致:
|
||
// 每个 IP 恰好 sweepRecordsPerIP 条记录,且在触发回收的时刻全部已过期
|
||
// (这才是「一次 sweep 删除 5000 个 IP 键」的前提)。
|
||
func TestSweepFixtureRecordsAllExpired(t *testing.T) {
|
||
base := time.Now()
|
||
l := fixSweepLimiter(64, base) // 用小规模验证夹具本身
|
||
|
||
if got := len(l.entries); got != 64 {
|
||
t.Fatalf("填充后 IP 数 = %d,期望 64", got)
|
||
}
|
||
for ip, times := range l.entries {
|
||
if len(times) != sweepRecordsPerIP {
|
||
t.Fatalf("IP %s 记录数 = %d,期望 %d", ip, len(times), sweepRecordsPerIP)
|
||
}
|
||
}
|
||
|
||
next := base.Add(2 * time.Minute)
|
||
cutoff := next.Add(-l.window)
|
||
for ip, times := range l.entries {
|
||
for i, ts := range times {
|
||
if !ts.Before(cutoff) {
|
||
t.Fatalf("IP %s 第 %d 条记录 %v 未早于 cutoff %v,回收时不会过期", ip, i, ts, cutoff)
|
||
}
|
||
}
|
||
}
|
||
|
||
if !l.allowAt("192.168.0.1", sweepLimit, next) {
|
||
t.Fatal("新 IP 应放行")
|
||
}
|
||
if got := len(l.entries); got != 1 {
|
||
t.Fatalf("回收后应只剩新 IP(1 个),实际 %d", got)
|
||
}
|
||
t.Logf("回收夹具:64 个 IP × %d 条记录全部过期,一次 sweep 后仅剩新 IP", sweepRecordsPerIP)
|
||
}
|
||
|
||
// TestSweepTriggersWithStaleBase 守护触发时刻的算法:base 取一个**远早于现在**的
|
||
// 时刻(模拟基准长跑很久之后,循环外的固定 base 已经过期),触发时刻相对 base
|
||
// 计算就会早于新一轮限流器的 lastSweep(= newRateLimiter 时的真实 now),
|
||
// now.Sub(lastSweep) 为负 → 即便 sweepEvery=0 也**不会**触发回收。
|
||
// 基准必须按此每轮重算触发时刻;本测试把该陷阱固定下来。
|
||
func TestSweepTriggersWithStaleBase(t *testing.T) {
|
||
staleBase := time.Now().Add(-10 * time.Minute)
|
||
l := fixSweepLimiter(64, staleBase)
|
||
|
||
staleNext := staleBase.Add(2 * time.Minute) // 已早于真实 now
|
||
if !staleNext.Before(l.lastSweep) {
|
||
t.Fatal("前置条件不成立:staleNext 应早于 l.lastSweep")
|
||
}
|
||
|
||
if !l.allowAt("192.168.0.1", sweepLimit, staleNext) {
|
||
t.Fatal("新 IP 应放行")
|
||
}
|
||
if got := len(l.entries); got != 65 {
|
||
t.Fatalf("陈旧触发时刻下回收不应发生(应保留 64 个旧 IP + 1 个新 IP = 65),实际 %d", got)
|
||
}
|
||
|
||
// 相对本轮填充时刻重算后,回收必须发生。
|
||
l2 := fixSweepLimiter(64, staleBase)
|
||
freshNext := time.Now().Add(2 * time.Minute)
|
||
if !l2.allowAt("192.168.0.1", sweepLimit, freshNext) {
|
||
t.Fatal("新 IP 应放行")
|
||
}
|
||
if got := len(l2.entries); got != 1 {
|
||
t.Fatalf("相对当前时刻的触发应完成回收(仅剩 1 个新 IP),实际 %d", got)
|
||
}
|
||
t.Log("已确认:触发时刻必须每轮相对 newRateLimiter 的真实时刻重算,否则回收静默不触发")
|
||
}
|