tunnl.gg 令牌桶限流器源码解析:KeyedRateLimiter 如何兼顾性能与内存
【免费下载链接】tunnl.gg
A minimal SSH tunneling service. Expose your local apps to the internet with a single command.
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 中被实例化了两次:
| 实例 | 参数 | 用途 |
|---|---|---|
visitorLimiter | 25 次/秒,突发 200 | 限制每个访问者的请求速率 |
passwordFails | 0.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.
相关推荐
用autocannon测试API限流:令牌桶与漏桶算法性能对比
用autocannon测试API限流:令牌桶与漏桶算法性能对比 还在为API限流策略选择发愁?高并发场景下令牌桶与漏桶算法如何抉择?本文将使用autocanno
性能测试测试开发工具Sentinel 限流算法源码解析:漏桶与令牌桶(预热冷启动)的实现原理
Sentinel 限流算法源码解析:漏桶与令牌桶(预热冷启动)的实现原理 Sentinel 作为面向分布式服务架构的流量控制组件,其限流能力的核心由两类算法支撑
文档技术博客知识库后端深入理解Codecov Action工作原理:从CLI到覆盖率报告
深入理解Codecov Action工作原理:从CLI到覆盖率报告 Codecov Action是一款强大的GitHub Action工具,能够帮助开发者轻松将
运维开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考