{* YBB Header v4.0 — Alpine.js + 自定义 CSS *}

引言

在现代分布式系统中,限流(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,0000.8ms强一致
滑动窗口(ZSET)~42,0001.2ms强一致
令牌桶(Lua)~65,0000.9ms强一致
客户端预取 + 批量同步~180,000(本地命中率 95%+)0.1ms(本地)最终一致

结语

分布式限流器的设计没有银弹,关键在于理解不同算法的 trade-off:滑动窗口日志精确但耗内存,令牌桶节省内存但实现复杂,客户端预取性能最高但牺牲强一致性。实际生产中,建议从令牌桶 Lua 脚本方案起步,当单 Key 成为瓶颈时再引入分片和本地预取策略。最重要的是,限流器只是防御体系的最后一道防线,良好的架构设计(如自动扩容、服务降级、队列削峰)才是应对流量洪峰的根本之道。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部