引言:为什么需要限流?
在分布式高并发系统中,限流(Rate Limiting)是保护核心服务稳定的最后一道防线。当流量洪峰来袭——无论是突发热点事件、恶意攻击还是上游重试风暴——限流能够确保系统在可控负载下平稳运行,避免因资源耗尽导致的级联故障。本文将从原理到实战,深入剖析工业界主流的分布式限流算法及其工程实现。
一、限流算法基础分类
限流算法按维度可分为单机限流与分布式限流两大类。单机限流部署简单,适用于中小规模服务;分布式限流则通过中心存储协调多实例的流量配额,确保全局一致性。按时间窗口划分,又可分为固定窗口、滑动窗口和令牌桶三大流派。
二、固定窗口计数器(Fixed Window Counter)
算法原理:将时间划分为等长的窗口(如1秒),每个窗口维护一个计数器。请求到来时递增计数器,超过阈值则拒绝;窗口切换时计数器归零。
数学表达:对于窗口时长 T(秒)、阈值 N,任意连续 T 秒内最多处理 N 个请求。
边界问题:固定窗口存在致命的「窗口边界效应」——在窗口切换的瞬间,如果前一窗口末尾和后一窗口开头都有大量请求,则 2T 时间内的实际流量可能达到 2N,超出预期一倍。
package main
import (
"sync"
"time"
)
type FixedWindowCounter struct {
mu sync.Mutex
count int
limit int
window time.Duration
resetTime time.Time
}
func NewFixedWindowCounter(limit int, window time.Duration) *FixedWindowCounter {
return &FixedWindowCounter{
limit: limit,
window: window,
resetTime: time.Now().Add(window),
}
}
func (fwc *FixedWindowCounter) Allow() bool {
fwc.mu.Lock()
defer fwc.mu.Unlock()
now := time.Now()
if now.After(fwc.resetTime) {
fwc.count = 0
fwc.resetTime = now.Add(fwc.window)
}
if fwc.count >= fwc.limit {
return false
}
fwc.count++
return true
}
三、滑动窗口计数器(Sliding Window Counter)
算法原理:滑动窗口是对固定窗口的改进,将当前窗口与前一个窗口的计数器按时间比例加权,从而消除边界效应。
加权计算:设当前时间点距离窗口起点已过比例为 α,则有效计数 = 前一窗口计数 × (1-α) + 当前窗口计数。当一整个窗口滑过后,前一窗口的计数完全过期。
优势:相比固定窗口,流量曲线更平滑,严格保证任意连续 T 秒内请求不超过 N 次,实现 O(1) 空间复杂度。
四、漏桶算法(Leaky Bucket)
算法原理:系统以一个恒定速率(r 请求/秒)处理请求,涌入的水先进入容量为 C 的桶中;桶满则溢出丢弃。无论输入流量如何波动,输出流量始终是匀速的。
漏桶参数:桶容量 C(burst 缓冲)、出水速率 r。漏桶的核心目标是将突发流量整形为均匀输出,适合下游处理能力固定的场景(如数据库写操作)。
局限性:漏桶无法应对突发流量。即使系统空闲,漏桶也不会提前储备请求处理能力——它始终以匀速输出,导致突发流量的排队延迟较高。
五、令牌桶算法(Token Bucket)
算法原理:系统以固定速率 r 向桶中注入令牌,桶容量上限为 C。请求需消耗一个令牌才可被处理,无令牌则等待或拒绝。桶满时新令牌丢弃。
与漏桶对比:漏桶控制的是「出水管」的速率,桶只是缓冲;令牌桶控制的是「注水管」的速率,允许突发流量瞬间消耗桶中积累的令牌。令牌桶既能限制平均速率,又能在一定范围内容忍突发。
| 对比维度 | 漏桶(Leaky Bucket) | 令牌桶(Token Bucket) |
|---|---|---|
| 控制对象 | 出水速率(输出整形) | 注水速率(输入准入) |
| 突发流量 | 排队等待,无法加速 | 可瞬间消耗积累的令牌 |
| 桶满行为 | 溢出丢弃(被动) | 令牌丢弃(主动) |
| 输出速率 | 严格恒定 | 平均速率,允许突发 |
| 适用场景 | 下游能力固定(DB、MQ) | API 网关、微服务限流 |
Go 实现示例
type TokenBucket struct {
mu sync.Mutex
tokens float64
capacity float64
fillRate float64
lastTime time.Time
}
func NewTokenBucket(capacity, fillRate float64) *TokenBucket {
return &TokenBucket{
tokens: capacity,
capacity: capacity,
fillRate: fillRate,
lastTime: time.Now(),
}
}
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
elapsed := now.Sub(tb.lastTime).Seconds()
tb.tokens = min(tb.capacity, tb.tokens + elapsed*tb.fillRate)
tb.lastTime = now
if tb.tokens >= 1.0 {
tb.tokens--
return true
}
return false
}
六、Redis + Lua 分布式限流实战
单机限流无法应对多实例部署场景——10个节点各限流 100 QPS,实际入口已达 1000 QPS。分布式限流需要借助 Redis 等中心存储实现跨节点协调。
6.1 固定窗口 Redis 实现
Lua 脚本:利用 Redis 单线程特性,原子化执行「读取→判断→递增」三步操作,避免竞态条件。
-- KEYS[1]: rate:{client_id}:window_timestamp
-- ARGV[1]: limit阈值, ARGV[2]: 窗口大小(秒)
local current_time = tonumber(ARGV[3])
local window = tonumber(ARGV[2])
local last_window = math.floor(current_time / window)
local key = KEYS[1] .. ":" .. last_window
local count = redis.call('INCR', key)
if count == 1 then
redis.call('EXPIRE', key, window * 2)
end
local limit = tonumber(ARGV[1])
if count > limit then
return 0
end
return 1
6.2 Redis 滑动窗口 ZSet 实现
核心设计:使用 Redis Sorted Set,score 为请求的微秒时间戳,member 设置为请求唯一ID。每次请求先清理窗口外的过期元素,再判断窗口内元素总数是否超限。
-- KEYS[1]: sliding_window:{client_id}
-- ARGV[1]: 当前时间戳(微秒), ARGV[2]: 窗口大小(微秒), ARGV[3]: 限流阈值
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local key = KEYS[1]
-- 清理窗口外的记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 统计当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count >= limit then
return 0
end
-- 添加当前请求记录
redis.call('ZADD', key, now, ARGV[4]) -- ARGV[4] = request_id
redis.call('EXPIRE', key, math.ceil(window / 1e6))
return 1
6.3 Redis Cluster 分布式令牌桶
实现思路:将令牌桶的状态(当前令牌数、上次填充时间)存储在 Redis Hash 中,通过脚本化计算当前应填充的令牌数。
-- KEYS[1]: token_bucket:{client_id}
-- ARGV[1]: 现在时间戳(毫秒), ARGV[2]: fill_rate(令牌/秒),
-- ARGV[3]: capacity(桶容量), ARGV[4]: cost(消耗令牌数)
local key = KEYS[1]
local now = tonumber(ARGV[1])
local fill_rate = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_time')
local tokens = tonumber(bucket[1]) or capacity
local last_time = tonumber(bucket[2]) or now
local elapsed = math.max(0, (now - last_time) / 1000.0)
tokens = math.min(capacity, tokens + elapsed * fill_rate)
if tokens >= cost then
tokens = tokens - cost
redis.call('HMSET', key, 'tokens', tokens, 'last_time', now)
redis.call('EXPIRE', key, 60)
return 1
end
-- 令牌不足
redis.call('HSET', key, 'last_time', now)
return 0
七、限流策略的工程实践
7.1 多级限流架构
在生产环境中,限流通常采用多层防御体系:
| 层次 | 位置 | 粒度 | 典型工具 |
|---|---|---|---|
| 接入层 | Nginx / Envoy | IP / 用户维度粗粒度 | ngx_http_limit_req / Rate Limit Filter |
| 应用层 | 服务内部 | API Key / 租户细粒度 | Redis + Lua / Sentinel |
| 资源层 | DB / Cache 连接池 | 并发访问量 | HikariCP / Druid 上限 |
7.2 自适应限流(Adaptive Limiting)
静态阈值在流量模式变化时显得力不从心。自适应限流根据系统实时负载动态调整限流阈值:监测 CPU 使用率、RT(响应时间)、错误率等指标,当系统负载升高时自动降低阈值。Google 的 networking 团队提出的 adaptive limiting 以及 Netflix 的 concurrency-limits 库都采用 PID 控制器的思想来动态调整并发上限。
7.3 限流返回与用户体验
被限流时返回 HTTP 429 Too Many Requests,并在 Retry-After 头中指示客户端重试等待时间。对业务应采用「优雅降级」策略:返回缓存数据或排队提示,而非直接拒绝。
八、常见坑与最佳实践
坑1:Lua 脚本超时。Redis 默认脚本执行时间上限为 5 秒,滑动窗口的 ZCARD 操作在大 key 场景下可能超时。建议窗口精度选择秒级,或使用 Redis Stream + 消费者组代替 ZSet。
坑2:时钟漂移。分布式环境下各节点时钟不一致,可能导致滑动窗口的过期清理不准确。建议统一使用 Redis 服务器时间(TIME 命令)而非本地时间。
坑3:Redis 热 Key。高频请求同一限流 Key 会造成单分片压力。可通过对 Key 加随机后缀(分桶计数再求和)或使用本地缓存预热减轻 Redis 压力。
坑4:集群重定向。Redis Cluster 模式下,Lua 脚本访问的 Key 必须在同一 slot。可使用 Hash Tag(如 rate:{client_id_123}:window)确保 Key 落在同一分片。
总结
限流是分布式系统稳定性的基石,但不存在「银弹」算法。固定窗口简单但有边界效应;滑动窗口平滑但占用内存略多;漏桶适合恒定整形;令牌桶兼顾平均速率与突发容忍;Redis 实战方案需注意 Lua 原子性和时钟一致性。理解各算法的适用场景与约束条件,结合多级防御架构与自适应策略,才能构建真正健壮的限流体系。

发表评论 取消回复