Files
ghproxy/ratelimit_test.go
6ab7886865 perf: optimize request limiting and config concurrency (#2)
* 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>
2026-10-11 12:25:22 +08:00

215 lines
6.0 KiB
Go

package main
import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
)
// newTestLimiter 关闭自动回收,便于测试精确控制时间。
func newTestLimiter() *rateLimiter {
l := newRateLimiter()
l.sweepEvery = time.Hour
return l
}
// 1. 关闭限流(limit <= 0)时应始终放行且不记录任何状态。
func TestLimiterDisabled(t *testing.T) {
l := newTestLimiter()
now := time.Now()
for i := 0; i < 1000; i++ {
if !l.allowAt("1.1.1.1", 0, now) {
t.Fatalf("关闭限流时应始终放行,第 %d 次被拒绝", i+1)
}
if !l.allowAt("2.2.2.2", -5, now) {
t.Fatalf("limitRate 为负时应始终放行")
}
}
if len(l.entries) != 0 {
t.Fatalf("关闭限流时不应记录任何 IP,实际记录 %d 个", len(l.entries))
}
}
// 2. 请求数恰好达到限额时应全部放行。
func TestLimiterExactlyAtLimit(t *testing.T) {
l := newTestLimiter()
now := time.Now()
const limit = 3
for i := 0; i < limit; i++ {
if !l.allowAt("1.1.1.1", limit, now) {
t.Fatalf("第 %d 次请求应放行(恰好达到限额)", i+1)
}
}
}
// 3. 超过限额时应被拒绝。
func TestLimiterOverLimitRejected(t *testing.T) {
l := newTestLimiter()
now := time.Now()
const limit = 2
if !l.allowAt("1.1.1.1", limit, now) || !l.allowAt("1.1.1.1", limit, now) {
t.Fatal("前两次请求应放行")
}
if l.allowAt("1.1.1.1", limit, now) {
t.Fatal("第 3 次请求应被拒绝")
}
if l.allowAt("1.1.1.1", limit, now) {
t.Fatal("超限后应持续拒绝")
}
}
// 4. 时间窗口过期后应恢复放行,并验证窗口边界不出现 off-by-one。
func TestLimiterWindowExpiry(t *testing.T) {
l := newTestLimiter()
t0 := time.Now()
const limit = 2
l.allowAt("1.1.1.1", limit, t0)
l.allowAt("1.1.1.1", limit, t0)
if l.allowAt("1.1.1.1", limit, t0.Add(30*time.Second)) {
t.Fatal("窗口内应继续拒绝")
}
// 恰好在窗口边界(age == window)时记录仍有效,应拒绝。
if l.allowAt("1.1.1.1", limit, t0.Add(time.Minute)) {
t.Fatal("窗口边界上的记录仍应计入,必须拒绝")
}
// 刚超过一个窗口后,旧记录过期,应恢复放行。
if !l.allowAt("1.1.1.1", limit, t0.Add(time.Minute+time.Nanosecond)) {
t.Fatal("窗口过期后应恢复放行")
}
}
// 5. 多个 IP 之间互不影响。
func TestLimiterMultipleIPsIndependent(t *testing.T) {
l := newTestLimiter()
now := time.Now()
const limit = 1
if !l.allowAt("a", limit, now) {
t.Fatal("a 第一次应放行")
}
if l.allowAt("a", limit, now) {
t.Fatal("a 第二次应拒绝")
}
if !l.allowAt("b", limit, now) {
t.Fatal("b 不应受 a 的限制影响")
}
if l.allowAt("b", limit, now) {
t.Fatal("b 第二次应拒绝")
}
}
// 6a. 同一 IP 的并发请求(固定时间注入):放行总数必须恰好等于限额。
func TestLimiterConcurrentSameIP(t *testing.T) {
l := newTestLimiter()
now := time.Now()
const limit = 50
const goroutines = 500
var allowed int64
var wg sync.WaitGroup
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if l.allowAt("same", limit, now) {
atomic.AddInt64(&allowed, 1)
}
}()
}
wg.Wait()
if allowed != limit {
t.Fatalf("并发放行数应为 %d,实际为 %d", limit, allowed)
}
}
// 6b. 使用真实生产入口(锁内采样 time.Now)并发请求:
// 放行数恰当,且 entries[ip] 必须保持按时间升序,验证时间戳顺序修复。
func TestLimiterConcurrentRealClockOrdering(t *testing.T) {
l := newTestLimiter()
const limit = 100
const goroutines = 2000
var allowed int64
var wg sync.WaitGroup
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if l.allow("same", limit) {
atomic.AddInt64(&allowed, 1)
}
}()
}
wg.Wait()
if allowed != limit {
t.Fatalf("并发放行数应为 %d,实际为 %d", limit, allowed)
}
times := l.entries["same"]
if len(times) != limit {
t.Fatalf("窗口内记录数应为 %d,实际 %d", limit, len(times))
}
for i := 1; i < len(times); i++ {
if times[i].Before(times[i-1]) {
t.Fatalf("时间戳乱序:索引 %d (%v) 早于 %d (%v)", i, times[i], i-1, times[i-1])
}
}
}
// 7. 大量不同 IP 请求后,过期条目应被回收,避免无界增长。
func TestLimiterReclaimsInactiveIPs(t *testing.T) {
l := newTestLimiter()
base := time.Now()
const n = 5000
for i := 0; i < n; i++ {
ip := fmt.Sprintf("10.%d.%d.%d", (i>>16)&0xff, (i>>8)&0xff, i&0xff)
l.allowAt(ip, 10, base)
}
if got := len(l.entries); got != n {
t.Fatalf("应记录 %d 个 IP,实际 %d", n, got)
}
// 强制在窗口之后触发一次回收,清掉全部不活跃 IP。
l.sweepEvery = 0
after := base.Add(2 * time.Minute)
if !l.allowAt("192.168.0.1", 10, after) {
t.Fatal("新 IP 应放行")
}
if got := len(l.entries); got != 1 {
t.Fatalf("过期 IP 应被回收,仅剩新 IP(1 个),实际 %d", got)
}
}
// 8. 直接验证 sweep 的目标代码路径:裁剪活跃 IP 的过期记录、回收不活跃 IP,
// 且回收不破坏仍然有效的窗口内记录。
func TestLimiterSweepPrunesActiveAndReclaimsInactive(t *testing.T) {
l := newTestLimiter()
base := time.Now()
// 直接构造 entries,使旧记录在 sweep 被调用时确实仍然存在,
// 不经过 allowAt 触发的 prune,从而真正覆盖 sweepLocked 的裁剪逻辑。
l.entries["active"] = []time.Time{base, base.Add(80 * time.Second)}
l.entries["stale"] = []time.Time{base}
now := base.Add(90 * time.Second) // cutoff = now - window = base + 30s
l.mu.Lock()
l.sweepLocked(now)
l.mu.Unlock()
// 活跃 IP:旧记录被裁剪,窗口内记录保留。
active := l.entries["active"]
if len(active) != 1 || !active[0].Equal(base.Add(80*time.Second)) {
t.Fatalf("active 应仅保留窗口内的 1 条记录,实际 %v", active)
}
// 不活跃 IP:被正确回收。
if _, ok := l.entries["stale"]; ok {
t.Fatal("不活跃 IP 应被回收")
}
// 回收不破坏仍有效的限流:active 在窗口内已有 1 条记录,limit=1 时下一次应被拒绝。
if l.allowAt("active", 1, now) {
t.Fatal("活跃 IP 的窗口内记录应仍然生效(limit=1 时应拒绝)")
}
}