漏桶必须请求驱动而非ticker驱动:每次Allow()调用时惰性计算漏水量,仅用atomic维护lastLeakTime和water,rate换算为整数纳秒粒度,严格拒绝超容请求;rate.Limiter是令牌桶,非漏桶,二者语义与适用场景不同。

Go 语言里用漏桶做接口限流,**不能靠 ticker 或 sleep 驱动,也不能直接拿 rate.Limiter 替代**——它本质是令牌桶,输出不匀速,不满足“防抖”需求。
为什么漏桶必须请求驱动、不能开 ticker
常见错误是起一个 time.Ticker 每 10ms 扣一次水位。这会导致:
- 高并发下大量 goroutine 竞争调度,
runtime.schedule开销飙升 - 水位更新和请求判断不同步:比如 Ticker 刚漏完,紧接着两个请求同时进来,第二个会误判为“桶空”,实际应排队
- 浮点误差累积,
float64做累加后精度漂移,长期运行水位失真
正确做法是每次调用 Allow() 时,才按 now - lastLeakTime 惰性算漏了多少,只用 atomic.LoadInt64 和 atomic.CompareAndSwapInt64 更新状态。
LeakyBucket.Allow() 的原子操作链怎么写
核心是两字段 + 一次 CAS 循环:
立即学习“go语言免费学习笔记(深入)”;
-
lastLeakTime:上一次成功漏水量更新的时间戳(纳秒) -
water:当前桶内“水量”,单位是「千分之一请求」这类整数粒度(避免浮点) -
ratePerNanosecond:速率换算成每纳秒漏多少整数单位,例如 100 QPS →100 * 1e6(放大 1e6 倍存整数)
伪代码逻辑:
func (l *LeakyBucket) Allow() bool {
now := time.Now().UnixNano()
for {
oldLast := atomic.LoadInt64(&l.lastLeakTime)
oldWater := atomic.LoadInt64(&l.water)
elapsed := now - oldLast
drain := elapsed * l.ratePerNanosecond / 1e9 // 整数除法,无浮点
newWater := oldWater - min(drain, oldWater) // 不能漏成负数
if newWater+1 > l.capacity { // +1 是本次请求要进桶
return false
}
if atomic.CompareAndSwapInt64(&l.lastLeakTime, oldLast, now) &&
atomic.CompareAndSwapInt64(&l.water, oldWater, newWater+1) {
return true
}
// CAS 失败,说明其他 goroutine 已更新,重试
}
}漏桶和令牌桶在 HTTP 中的语义差异必须分清
用 golang.org/x/time/rate.Limiter 实现的不是漏桶:
-
limiter.Allow()返回true只代表“此刻有令牌”,不保证后续请求节奏均匀;它允许突发——前 50ms 放行 100 次,后 950ms 完全空转 -
limiter.Reserve()返回的是延迟建议(Delay()),不是拒绝信号;而漏桶的Allow()必须返回明确的false表示“超容即拒” - 漏桶的
capacity和rate共同决定最大积压时长:capacity / rate秒;线上必须按下游 SLA 反推,比如 DB 写入要求 P99 ≤ 300ms,则设capacity ≤ rate × 0.3
防抖场景下漏桶的容量设置容易被忽略
很多人设 capacity=1000、rate=10,结果理论积压可达 100 秒——HTTP 请求早超时了,桶还在等。真正该做的:
- 先确认下游能容忍的最大排队延迟(如日志上报可忍 2s,DB 写入只敢给 300ms)
- 反推
capacity = int64(rate × maxDelaySeconds),并向下取整 - 若业务需要更长缓冲,必须配合上游 timeout 控制(比如 gin 中间件加
ctx, cancel := context.WithTimeout(r.Context(), 300*time.Millisecond))
漏桶不是“加大容量就能扛住流量”,而是用确定的输出节奏去匹配下游的刚性吞吐能力。这点一旦错配,防抖就变成假防抖。

















