限流是高并发系统的"安全阀"。当流量超出系统承载能力时,限流决定了系统是优雅降级还是雪崩坍塌。但限流从来不是"选个算法"那么简单——不同的算法在精度、内存、突发流量处理上各有取舍,而分布式环境下的限流还要解决原子性与时钟同步问题。本文对比四种主流限流算法,并给出基于 Redis + Lua 的生产级实现。
一、为什么需要限流:从雪崩说起
在没有限流的系统里,一个被推上热搜的接口会在几秒内吸引数倍于设计容量的请求。这些请求会迅速耗尽线程池、数据库连接池与缓存,导致原本正常的请求也排队超时。更糟的是,上游服务因超时重试,进一步放大流量,形成"重试风暴"。这就是典型的雪崩。
限流的核心目标不是"拒绝所有多余请求",而是在系统承载力与用户体验之间找平衡:让系统能处理的请求尽量成功,超出部分快速失败、快速返回,避免排队拖垮整体。限流的维度也很多——按接口、按用户、按 IP、按租户,甚至按资源组——本文聚焦最基础的"按 Key 限流"。
限流与熔断、降级是高可用三件套:限流是"门口控制进入人数",熔断是"发现里面出事就停止进入",降级是"主动放弃部分功能保核心"。三者协同才能构成完整的防护链。
二、四种限流算法对比
2.1 固定窗口计数器(Fixed Window)
把时间切分成固定窗口(如每秒一个窗口),每个窗口维护一个计数器,请求到达时计数器加一,超过阈值则拒绝。窗口结束时计数器清零。实现简单、内存占用小,但存在"临界突发"问题:在窗口切换的瞬间,前后两个窗口各自都没超限,但短时间内通过了接近两倍阈值的请求。
// 固定窗口伪代码
key = "rate:user:1001"
count = INCR(key)
if count == 1:
EXPIRE(key, 1) // 首次进入设置过期
if count > limit:
return REJECTED
2.2 滑动窗口(Sliding Window)
滑动窗口把固定窗口的"硬边界"打散。一种常见实现是把窗口细分为多个小格子(如 1 秒分成 10 个 100ms 格子),统计当前时刻往前推一个窗口长度内所有格子的总和。格子越细,精度越高,但内存占用也越大。它有效缓解了临界突发问题,是工程中最常用的近似平滑限流。
2.3 漏桶(Leaky Bucket)
漏桶以恒定速率"漏水"(处理请求),请求到达时若桶未满则入桶,否则拒绝。它强制输出速率恒定,无论突发流量多大,处理速率都稳定。适合需要严格平滑的场景(如对接第三方 API),但对合理突发不友好——即便系统空闲,也无法加速处理积压。
2.4 令牌桶(Token Bucket)
令牌桶以恒定速率向桶里放令牌,请求到达时取一个令牌,取不到则拒绝。桶有容量上限,允许积累令牌应对突发——空闲时攒下的令牌可以让短时突发流量通过。令牌桶是云厂商 API 限流(如 AWS、阿里云)的事实标准,因为它既保证长期平均速率,又允许合理突发。
选型速查:要严格平滑、不允许突发 → 漏桶;要允许突发且控平均速率 → 令牌桶;要实现简单、能接受临界误差 → 固定窗口;要平衡精度与复杂度 → 滑动窗口。
三、单机限流的实现要点
单机限流的核心是"原子计数"。在单进程内,可用语言提供的原子操作或单线程模型实现。Java 中 Guava 的 RateLimiter 基于令牌桶,Go 中 golang.org/x/time/rate 同样是令牌桶。下面是一个 Go 单机令牌桶示例:
// Go 单机令牌桶(基于 time/rate)
import "golang.org/x/time/rate"
// 每秒 100 个令牌,桶容量 200(允许 200 的突发)
limiter := rate.NewLimiter(100, 200)
func Handle(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
// 正常处理
}
单机限流的局限在于无法应对分布式部署:每个实例各自限流,总流量会是"实例数 × 单机阈值"。要做全局限流,必须借助共享存储。
四、分布式限流:Redis + Lua 的生产实现
分布式限流的关键是"读-改-写"的原子性。Redis 单线程模型配合 Lua 脚本,可以保证一段脚本在执行期间不被其他命令打断,是实现分布式限流的利器。下面是一个基于 Redis 的滑动窗口 Lua 实现,使用有序集合(ZSET)记录请求时间戳。
-- sliding_window.lua
-- KEYS[1] = 限流 key
-- ARGV[1] = 窗口大小(毫秒)
-- ARGV[2] = 阈值
-- ARGV[3] = 当前时间戳(毫秒)
-- ARGV[4] = 唯一请求ID
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local uid = ARGV[4]
-- 清理窗口外的旧记录
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, uid)
redis.call('PEXPIRE', key, window)
return 1 -- 放行
调用方传入当前时间戳与唯一 ID,脚本先清理过期记录,再判断当前窗口内请求数是否超限。由于整个脚本在 Redis 单线程内原子执行,即使有上百个实例并发调用,也不会出现计数错乱。下面是 Go 侧的调用封装:
// Go 调用 Lua 脚本
func Allow(ctx context.Context, rdb *redis.Client, key string, window time.Duration, limit int) (bool, error) {
now := time.Now().UnixMilli()
uid := fmt.Sprintf("%d:%d", now, rand.Int63())
res, err := rdb.Eval(ctx, luaScript, []string{key},
window.Milliseconds(), limit, now, uid).Int()
if err != nil {
return false, err
}
return res == 1, nil
}
五、限流的工程化与多维治理
5.1 Key 的设计
Key 决定了限流的粒度。常见组合:rate:{api}:{user_id} 按用户限流、rate:{api}:{ip} 按 IP 限流、rate:{tenant}:{api} 按租户限流。生产环境通常多层叠加:全局兜底 + 接口级 + 用户级,形成纵深防御。
5.2 被拒绝请求的处理
- 返回
429 Too Many Requests,并带上Retry-After头,告知客户端多久后重试。 - 对内部调用,可降级到缓存或默认值,而非直接报错。
- 记录被限流日志,用于容量规划与异常流量分析。
5.3 动态阈值与预热
固定阈值难以适应流量波动。可将阈值配置化,接入配置中心动态调整;或实现预热算法,在系统启动时逐步放大限流阈值,避免冷启动被瞬间打满。
六、常见陷阱与避坑
- 用客户端时间戳:分布式环境下客户端时钟可能不准,应以 Redis
TIME命令的时间为准,避免时钟漂移导致窗口错乱。 - 限流 Key 膨胀:按用户限流时,每个用户一个 ZSET,长期不活跃用户的 key 要及时过期回收。
- 限流变成瓶颈:Redis 单点限流的 QPS 上限约 10 万级,超大规模流量需做分片或本地 + 全局两级限流。
- 只限不告警:限流命中率高时要有告警,否则等于"静默拒绝",用户投诉时才发现问题。
限流的最高境界是"让用户感觉不到限流的存在"——通过平滑的算法、合理的阈值与优雅的降级,把拒绝藏在不影响体验的边界之外。这需要对系统能力的精确测量与持续调优。
结语
限流没有"最好的算法",只有"最合适的算法"。令牌桶适合多数 API 场景,滑动窗口适合需要精确控制的资源类接口,漏桶适合对接外部稳定速率系统。在 mrgr.cn 的实践中,我们采用"本地令牌桶做快速拒绝 + Redis 滑动窗口做全局精确控制"的两级架构,既保证了响应速度,又控制了全局精度。下一篇我们会继续拆解熔断与降级的实现,欢迎在问答社区交流你的限流方案。