引言
在现代分布式系统中,限流(Rate Limiting)是保障系统稳定性的核心手段之一。无论是应对突发流量、防止 API 被刷、还是实现精细化的配额管理,限流器都扮演着不可替代的角色。本文将以 Redis 为基础,从经典的令牌桶算法开始,逐步深入探讨如何在分布式环境下实现一个生产级的高性能限流器。
为什么需要分布式限流?
单机限流(如 Java 的 Semaphore 或 Guava RateLimiter)在单体架构中表现良好,但在微服务架构下存在几个致命缺陷:
- 多实例协同问题:当同一服务部署了 N 个实例时,每个实例独立限流,实际总流量是单机阈值的 N 倍
- 状态不共享:某个实例重置计数器不会影响其他实例,导致限流失效
- 动态调整困难:修改限流阈值需要逐台重启或推送配置
Redis 作为高性能内存数据库,天然具备共享状态和原子操作的能力,是实现分布式限流器的理想选择。
从计数器到滑动窗口
最简单的限流方案是固定窗口计数器:
# Redis 命令
SET rate_limit:user:123 0 EX 60 NX
INCR rate_limit:user:123
# 如果值大于阈值则拒绝请求
但固定窗口存在临界问题:在窗口边界的瞬间,实际通过的请求量可能达到阈值的两倍。例如,窗口 [00:00, 01:00) 和 [01:00, 02:00) 的边界处,59 分 59 秒和 1 分 01 秒的两波请求可能都在同一秒内到达,导致实际流量翻倍。
滑动窗口日志(Sliding Window Log)通过为每个请求记录时间戳来解决这个问题:
-- Lua 脚本实现滑动窗口日志
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
-- 清除过期记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 统计当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count < limit then
-- 允许通过,记录本次请求
redis.call('ZADD', key, now, now .. ':' .. math.random())
redis.call('EXPIRE', key, window / 1000)
return 1
else
return 0
end
令牌桶算法的 Redis 实现
滑动窗口日志虽然精确,但内存消耗与请求量成正比。令牌桶(Token Bucket)算法在精确性和资源消耗之间取得了更好的平衡。
核心思想:系统以恒定速率向桶中放入令牌,每个请求消耗一个令牌。桶满时新令牌被丢弃,桶空时请求被拒绝。
-- 令牌桶 Lua 原子操作
local key = KEYS[1]
local rate = tonumber(ARGV[1]) -- 每秒生成的令牌数
local capacity = tonumber(ARGV[2]) -- 桶的最大容量
local now = tonumber(ARGV[3]) -- 当前时间戳(毫秒)
local requested = tonumber(ARGV[4]) -- 本次请求消耗的令牌数
-- 获取上次填充时间和当前令牌数
local fill_time = redis.call('HMGET', key, 'last_time', 'tokens')
local last_time = tonumber(fill_time[1]) or now
local tokens = tonumber(fill_time[2]) or capacity
-- 计算经过的时间应填充的令牌数
local elapsed = math.max(0, now - last_time)
local new_tokens = elapsed * rate / 1000
tokens = math.min(capacity, tokens + new_tokens)
-- 判断是否有足够令牌
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
-- 更新状态
redis.call('HMSET', key, 'last_time', now, 'tokens', tokens)
redis.call('EXPIRE', key, math.ceil(capacity / rate) * 2)
-- 返回结果:{是否允许, 当前令牌数}
return {allowed, math.floor(tokens)}
进阶:消除浮点数精度问题
上述实现中 tokens 使用浮点数存储,在长时间运行后可能出现精度漂移。生产级方案应使用整数运算:
-- 使用 "微令牌" 概念避免浮点数
-- 1 个真实令牌 = 1000 个微令牌
local rate_micro = rate * 1000 -- 每秒微令牌数
-- 计算时全部使用整数,返回时再除以 1000
local elapsed_ms = now - last_time
local new_micro_tokens = (elapsed_ms * rate_micro) / 1000
-- Lua 5.3+ 的整数除法 //
new_micro_tokens = (elapsed_ms * rate_micro) // 1000
分布式场景下的关键优化
1. 降低 Redis 网络开销
高频限流场景下,每次请求一次 Redis 可能成为瓶颈。客户端本地预取(Prefetch)是一种有效的优化策略:
type LocalPrefetchLimiter struct {
redisClient *redis.Client
localTokens int
capacity int
lastRefill time.Time
mu sync.Mutex
}
func (l *LocalPrefetchLimiter) Allow() bool {
l.mu.Lock()
defer l.mu.Unlock()
// 尝试从本地令牌中消耗
if l.localTokens > 0 {
l.localTokens--
return true
}
// 本地令牌耗尽,从 Redis 批量获取
batch := l.fetchBatchFromRedis(50)
if batch > 0 {
l.localTokens = batch - 1
return true
}
return false
}
2. 热点 Key 分片
对于超高频的 API 接口(如首页接口),所有请求都打向同一个 Redis Key,形成热点。可以通过分片解决:
# 将一个逻辑限流器拆分为 N 个分片
import hashlib
def is_allowed(user_id, action, limit=100, window=60):
# 随机选择一个分片,降低单个 Key 压力
shard = hash(user_id + action) % SHARD_COUNT
key = f"rate_limit:{action}:shard:{shard}:{user_id}"
# 如果分片拒绝,可以尝试其他分片(放宽限制)
for attempt in range(SHARD_COUNT):
current_key = key.replace(f"shard:{shard}", f"shard:{(shard + attempt) % SHARD_COUNT}")
if check_single_redis_key(current_key, limit // SHARD_COUNT, window):
return True
return False
3. Redis Cluster 下的多 Key 问题
在 Redis Cluster 模式下,Lua 脚本要求所有 Key 必须在同一个 Slot(即使用 Hash Tag)。解决办法是用 Hash Tag 将相关 Key 路由到同一节点:
# 使用 Hash Tag 确保同一用户的所有限流 Key 在同一节点
{user:123}:api_login
{user:123}:api_search
{user:123}:api_order
生产环境实践建议
- 降级策略:Redis 不可用时应自动放行(fail-open)还是拒绝(fail-close)取决于业务场景。支付类接口建议 fail-close,查询类接口可以 fail-open
- 监控告警:记录限流触发频率、Redis 延迟百分位,设置当限流率超过阈值时告警
- 动态调参:将限流阈值存入配置中心,支持实时推送调整而无需重启服务
- 用户体验:限流返回 429 状态码时,配合 Retry-After 头告知客户端何时重试
性能基准测试
在 8 核 16G 单机环境下,对本地嵌入 Redis(避免网络延迟)进行压测:
| 实现方案 | QPS(单次 Redis 往返) | P99 延迟 | 一致性保证 |
|---|---|---|---|
| 固定窗口(INCR) | ~85,000 | 0.8ms | 强一致 |
| 滑动窗口(ZSET) | ~42,000 | 1.2ms | 强一致 |
| 令牌桶(Lua) | ~65,000 | 0.9ms | 强一致 |
| 客户端预取 + 批量同步 | ~180,000(本地命中率 95%+) | 0.1ms(本地) | 最终一致 |
结语
分布式限流器的设计没有银弹,关键在于理解不同算法的 trade-off:滑动窗口日志精确但耗内存,令牌桶节省内存但实现复杂,客户端预取性能最高但牺牲强一致性。实际生产中,建议从令牌桶 Lua 脚本方案起步,当单 Key 成为瓶颈时再引入分片和本地预取策略。最重要的是,限流器只是防御体系的最后一道防线,良好的架构设计(如自动扩容、服务降级、队列削峰)才是应对流量洪峰的根本之道。

发表评论 取消回复