引言:为什么需要限流?

在分布式高并发系统中,限流(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 / EnvoyIP / 用户维度粗粒度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 原子性和时钟一致性。理解各算法的适用场景与约束条件,结合多级防御架构与自适应策略,才能构建真正健壮的限流体系。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部