流量突增是边缘服务最常见的稳定性威胁,一次营销活动、一个热门接口被爬虫盯上,都可能让后端服务在几秒内被打垮。限流就是在这种场景下给系统加一道闸门,超过容量的请求直接拒绝或排队。目前业界主流的三种算法是漏桶、令牌桶和滑动窗口,它们思路不同,实现的复杂度和应对突发流量的表现也差异很大。这篇文章会把三种算法的原理拆开讲清楚,并给出可以直接运行的代码实现。

漏桶算法:恒定速率的守护者
漏桶算法的思路非常直观:请求先进入一个固定容量的桶里排队,桶以恒定速率向外漏水,也就是以固定速率处理请求。当桶被填满时,新到的请求直接被丢弃。这个模型保证了无论外部流量多汹涌,出口的处理速率始终稳定,对下游服务非常友好。
漏桶的核心价值在于“整流”。假设后端数据库每秒只能稳定承受500次查询,上游流量却是忽高忽低的,漏桶可以把毛刺削平,让下游看到的永远是一条平稳的直线。但它也有明显的短板:无法利用系统的空闲处理能力。即使下游在空闲期明明可以跑满1000 QPS,漏桶也只会按500的速率放行,突发的大量请求会在桶满后被大量拒绝,用户体验较差。
工程上漏桶通常不会真的维护一个请求队列,而是用“上次放行时间 + 速率”来计算当前水位,这样实现是无锁友好的,内存开销也极小。下面是一个Go语言版本的实现:
package main
import (
"sync"
"time"
)
type LeakyBucket struct {
mu sync.Mutex
rate float64 // 每秒放行的请求数
capacity float64 // 桶容量
water float64 // 当前水量
lastTime time.Time // 上次更新时间
}
func NewLeakyBucket(rate, capacity float64) *LeakyBucket {
return &LeakyBucket{
rate: rate,
capacity: capacity,
water: 0,
lastTime: time.Now(),
}
}
// Allow 判断请求是否放行
func (lb *LeakyBucket) Allow() bool {
lb.mu.Lock()
defer lb.mu.Unlock()
now := time.Now()
// 根据时间差漏掉的水量
elapsed := now.Sub(lb.lastTime).Seconds()
lb.water -= elapsed * lb.rate
if lb.water < 0 {
lb.water = 0
}
lb.lastTime = now
// 尝试加水
if lb.water+1 > lb.capacity {
return false // 桶满,拒绝
}
lb.water++
return true
}注意这个实现把“漏水”放在判断之前,用懒计算的方式同步水位,避免了后台定时器的开销。每次调用的时间复杂度是O(1),内存只依赖几个字段,非常适合在高并发网关的每个接口上各挂一个实例。
令牌桶算法:允许突发的弹性方案
令牌桶是漏桶的镜像:系统以恒定速率往桶里投放令牌,请求到来时先取令牌,取到就放行,取不到就拒绝。桶有容量上限,令牌数不会无限增长。关键区别在于,当系统空闲一段时间后,桶里会积累一批令牌,此时如果突发流量到来,可以一次性消耗积攒的令牌快速放行,这就是令牌桶“允许突发”的特性。
举个例子,桶容量是100,速率是每秒10个令牌。系统空闲10秒后桶满,此时瞬间来150个请求,前100个可以立即通过,之后的请求按每秒10个的速率放行。这种弹性让令牌桶在真实业务中比漏桶更受欢迎——大多数系统的负载本来就有波动,允许短时突发更符合业务诉求。Nginx的limit_req模块、Guava的RateLimiter底层都是令牌桶的变体。
Guava还实现了平滑预热版本的令牌桶,冷启动时放令牌的速度从低到高逐渐爬升,避免刚重启的服务被瞬间打满。下面是核心的Java实现逻辑,可以配合Redis实现分布式令牌桶:
public class TokenBucket {
private final double capacity; // 桶容量
private final double rate; // 每秒生成令牌数
private double tokens; // 当前令牌数
private long lastRefillTime; // 上次补充时间戳(毫秒)
public TokenBucket(double capacity, double rate) {
this.capacity = capacity;
this.rate = rate;
this.tokens = capacity; // 初始满桶,允许启动突发
this.lastRefillTime = System.currentTimeMillis();
}
public synchronized boolean tryAcquire(int permits) {
refill();
if (tokens >= permits) {
tokens -= permits;
return true;
}
return false;
}
private void refill() {
long now = System.currentTimeMillis();
double add = (now - lastRefillTime) / 1000.0 * rate;
tokens = Math.min(capacity, tokens + add);
lastRefillTime = now;
}
}实现时有个细节值得注意:初始令牌数是否给满。如果服务刚启动就给满桶令牌,重启瞬间可能放出超出下游承受能力的流量;生产环境更稳妥的做法是初始令牌数设为容量的一半,或者干脆从零开始预热。
滑动窗口:精度与开销的折中
固定窗口是最朴素的计数限流:每分钟一个计数器,超过阈值就拒绝。它的问题在于临界突刺——前一分钟的后10秒和后一分钟的前10秒加起来,实际可以在20秒内放过两倍阈值的流量。滑动窗口就是为了解决这个问题:不再按自然时间切分,而是统计“过去N秒”这个不断滑动的区间内的请求数。
精确的滑动窗口需要记录每个请求的时间戳,内存开销随请求量线性增长,显然不适合边缘节点。实际工程中普遍采用滑动窗口的近似实现:把大窗口切成多个细小的格子,每个格子独立计数,统计时把窗口覆盖到的格子加总。窗口滑动时只是推进格子指针,精度取决于格子粒度。比如1分钟的窗口切成60个1秒的格子,误差可以控制在单格计数的范围内,而内存只需60个整数。
package main
import (
"sync"
"time"
)
type SlidingWindow struct {
mu sync.Mutex
window time.Duration // 窗口总时长
buckets []int64 // 各子窗口计数
size int // 子窗口数量
lastSlot int // 当前子窗口下标
lastTime time.Time
}
func NewSlidingWindow(window time.Duration, size int) *SlidingWindow {
return &SlidingWindow{
window: window,
buckets: make([]int64, size),
size: size,
lastTime: time.Now(),
}
}
func (sw *SlidingWindow) Allow(limit int64) bool {
sw.mu.Lock()
defer sw.mu.Unlock()
now := time.Now()
slotDur := sw.window / time.Duration(sw.size)
// 计算当前请求属于哪个子窗口
cur := int(now.UnixNano()/int64(slotDur)) % sw.size
elapsed := int(now.Sub(sw.lastTime) / slotDur)
// 清空滑过的子窗口
for i := 0; i < elapsed && i < sw.size; i++ {
idx := (sw.lastSlot + 1 + i) % sw.size
sw.buckets[idx] = 0
}
sw.lastSlot = cur
sw.lastTime = now
// 统计窗口内总数
var total int64
for _, c := range sw.buckets {
total += c
}
if total >= limit {
return false
}
sw.buckets[cur]++
return true
}统计总数这一步是O(n),n为子窗口数量。对精度要求极高的场景可以把子窗口数量加到几百个,总耗时仍然在微秒级;如果追求极致性能,可以用循环前缀和的方式把统计降为O(1),代价是每次写入多一次累加。
三种算法如何选择
从流量整形角度看,漏桶输出最平滑,适合保护脆弱的下游,比如调用第三方付费API、写入单机MySQL这类对平稳性敏感的场景。令牌桶兼顾了平均速率和突发容忍,是面向用户的HTTP接口限流的首选,绝大多数网关默认方案都是它。滑动窗口本质上是在做统计限流而非整形,它不控制请求放出的节奏,只控制总量,适合配额类场景,比如“每个IP每分钟最多1000次请求”。
在边缘节点部署时还要考虑分布式问题。单机算法实例各自独立计数,在多节点负载均衡下,实际总阈值会被放大N倍。解决思路是把算法状态外移到Redis:令牌桶可以用Lua脚本原子地执行补充和扣减,滑动窗口可以用ZSET记录请求时间戳配合ZRANGEBYCOUNT统计。但引入Redis会增加一次网络往返,高QPS场景下可以采用“本地预扣减 + 异步回补”的分层策略,边缘节点先按本地配额快速拦截大部分流量,剩余少量不确定的请求再走集中式判断。
最后给一个简单直接的选型建议:保护下游用漏桶,接口限流用令牌桶,配额统计用滑动窗口。如果流量规模大到单机限流不够用,优先在接入层做本地粗筛,再配合Redis做精确控制,两级配合比单层的集中式限流在性能和精度上都能拿到更好的平衡。