tunnl.gg 令牌桶限流器源码解析:KeyedRateLimiter 如何兼顾性能与内存

发布时间:2026/11/4 6:55:59
tunnl.gg 令牌桶限流器源码解析:KeyedRateLimiter 如何兼顾性能与内存

【免费下载链接】tunnl.gg

A minimal SSH tunneling service. Expose your local apps to the internet with a single command.

项目地址:https://gitcode.com/gh_mirrors/tu/tunnl.gg
点击查看免费下载

tunnl.gg 是一个极简的 SSH 内网穿透服务:一条ssh命令就能把本地应用暴露到公网。每条隧道背后都可能被人狂刷请求,为此它内置了自研的令牌桶限流器——仅百余行 Go 代码,就同时做到了“按访问者独立限流、零定时器开销、内存严格有界”。本文带你读懂 pkg/tunnel/ratelimiter.go 中KeyedRateLimiter的三大核心设计。

为什么 tunnl.gg 需要令牌桶限流?

tunnl.gg 的流量模型有个特殊风险:请求最终要转发到用户自己的电脑上。如果某个访问者狂刷你的隧道,被打挂的是你自己的开发服务器。因此服务默认开启多层限流,核心指标如下(来自 README.md 的 Limits and protection 一节):

限制数值说明
每访问者请求速率25 次/秒,突发 200按访问者 IP 计数(IPv6 按 /64 网段合并),超出返回 429
每隧道请求速率50 次/秒,突发 400全部访问者共享,超出返回 429
跟踪的访问者数量1024 个/隧道超出的访问者共享一个"溢出桶"

这些数值定义在 pkg/config/config.go#L31-L40。而实现它们的关键,就是本文的主角KeyedRateLimiter。

令牌桶算法:没有定时器的"懒"设计 🪣

先说最基础的bucket结构(ratelimiter.go#L9-L14),它只有 4 个字段:

type bucket struct { tokens float64 // 当前令牌数 maxTokens float64 // 桶容量(突发值) refillRate float64 // 每秒补充的令牌数 lastRefill time.Time }

它没有任何后台线程或定时器。所谓"补充令牌"是在每次请求到来时才按经过的时间一次性结算的:新增令牌 = 距上次结算的秒数 × 速率,并截断到桶容量。这种"惰性结算"(lazy refill)是第一个性能亮点——限流器的空闲成本为零,只有请求进来才付出几纳秒的计算。

KeyedRateLimiter:每个访问者一个独立桶

单一令牌桶只能限制总量,无法做到"某个访问者刷爆请求,不影响其他正常访问者"。KeyedRateLimiter用一张map[string]*bucket为每个访问者(通常是 IP)维护独立桶,结构定义见 ratelimiter.go#L66-L74:

type KeyedRateLimiter struct { mu sync.Mutex rate float64 burst int maxKeys int buckets map[string]*bucket // 每个访问者一个桶 overflow *bucket // 超员访问者共享的溢出桶 lastPrune time.Time }

整个限流逻辑由一把互斥锁保护,锁内只做一次 map 查找加几次浮点运算,临界区极短,并发开销很小。

它在隧道请求路径上的调用点位于 pkg/tunnel/tunnel.go#L152-L160:

func (t *Tunnel) AllowRequest(visitor string) bool { if !t.visitorLimiter.Allow(visitor) { return false } return t.rateLimiter.Allow() }

注意检查顺序:先扣访问者自己的桶,再扣隧道总桶。这样已被限流的访问者被拦下时,根本不会消耗隧道的全局配额,避免了"一个捣乱者挤占所有人的预算"。

三大设计:性能与内存如何兼顾 ⚖️

1️⃣ 惰性填充:零后台开销

如前所述,桶的补充不依赖定时器,天然省掉了一个 goroutine 和周期性唤醒,也避免了"为 1024 个桶分别维护时间片"的复杂度。

2️⃣ 满桶剪枝:让 map 不会无限膨胀

如果 map 只增不减,攻击者伪造海量不同 IP 就能耗尽内存。KeyedRateLimiter的答案是剪枝(ratelimiter.go#L126-L134):

  • 当 map 满员(达到maxKeys)且来了新 key 时,扫描一遍 map,只删除已经"充满"的桶;
  • 删除是安全的:一个补满的桶和刚创建的新桶行为完全一致(源码注释明确写道 "A full bucket behaves exactly like a new one"),所以删掉后该访问者下次请求会重新建桶,限流语义零损失;
  • 剪枝频率被minPruneInterval(1 秒)节流,防止"新 key 洪水"把每次请求都变成 O(maxKeys) 的全表扫描。

这正是"内存有上限"与"行为无副作用"的平衡点:最坏情况下,每条隧道跟踪的访问者桶不超过 1024 个。

3️⃣ 溢出桶:超员访问者的"共享收容所"

如果剪枝之后 map 仍然满员(比如攻击者用大量仍在被限流状态的新 IP),新 key 不再建桶,而是统一落到一个共享的overflow桶上。效果是:

  • 内存占用被硬性钉死在 1024 + 1 个桶;
  • 超员攻击者集体共享一份预算,比逐个跟踪更能抵御洪水;
  • 代码分支极少,快路径(命中已有桶)不需要任何额外判断。

实战:限流器在 tunnl.gg 中的两处应用 🔐

KeyedRateLimiter在 pkg/tunnel/tunnel.go#L68-L77 中被实例化了两次:

实例参数用途
visitorLimiter25 次/秒,突发 200限制每个访问者的请求速率
passwordFails0.1 次/秒,突发 10限制每个访问者的错误密码尝试次数

第二个用法很妙:密码错误的桶 10 秒只能补 1 个令牌,连输 10 次后几乎要等 10 秒才能再试一次,天然抵御暴力破解。配合 tunnel.go#L162-L171 中的Available()方法——它只窥探桶里是否还有令牌而不消耗,用于"验证前先告知还能试几次",避免了泄露信息。

测试与延伸阅读 📚

配套的单元测试覆盖了三个关键行为(pkg/tunnel/ratelimiter_test.go):

  • 键相互独立:a桶耗尽不影响b;
  • 剪枝生效:补满的桶在新 key 到来时被清除,腾出名额;
  • 溢出共享:超员的新 key 共享同一桶,map 大小不超过上限。

更多资料:

  • 限流器实现:pkg/tunnel/ratelimiter.go
  • 测试用例:pkg/tunnel/ratelimiter_test.go
  • 限流参数配置:pkg/config/config.go#L28-L40
  • 隧道请求入口:pkg/tunnel/tunnel.go#L152-L171
  • 架构说明(Rate Limiter 一节):ARCHITECTURE.md#L233-L244

小结

KeyedRateLimiter用一套非常克制的组合拳解决了"按 key 限流"的三大难题:惰性填充消除后台开销,满桶剪枝保证语义无损地回收内存,溢出桶为极端攻击兜底。百余行代码、一把锁、一张有界的 map,就是 tunnl.gg 敢于让任何人都把本机暴露到公网的底气所在。如果你想给自己的 Go 服务加上类似的防刷能力,这个实现值得直接借鉴。

【免费下载链接】tunnl.gg

A minimal SSH tunneling service. Expose your local apps to the internet with a single command.

项目地址:https://gitcode.com/gh_mirrors/tu/tunnl.gg
点击查看免费下载

相关推荐

上一篇:Flectar Mail界面定制指南:完整工作区、极简布局与个性化配色的实用技巧
下一篇:从Brand DNA到内容架构:gtm-engineer-skills的AEO/GEO研究阶段实战

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考