Skip to content
Charles Shao
Go back

服务限流:窗口、漏桶、令牌桶与分布式落地

views

任何系统的处理能力都有上限。限流的本质是将请求到达速率压制在系统容量之内——代价是少量请求被拒绝或延迟,但这往往是在稳定性与可用性之间取得最优解的必要手段。

TL;DR

Table of contents

Open Table of contents

1. 四种经典限流算法

以下依次分析四种主流算法的原理与核心取舍。

1.1 固定窗口

固定窗口算法将时间划分为大小相等的窗口,在每个窗口内统计并限制请求数量。

以”接口每分钟最多访问 33 次”为例,实现思路如下:

固定窗口计数器限流示意:时间按固定窗口切分,窗口内计数达上限后拒绝请求,窗口结束后计数清零 固定窗口内计数达上限就拒绝,窗口结束再清零——实现简单但不平滑。

优点:实现简单,易于理解。

缺点

1.2 滑动窗口

滑动窗口是固定窗口的改进:将一个大窗口切分为若干小格子,计数窗口随时间连续滑动。

以”每分钟 60 次”为例:将 1 分钟拆分为 60 个 1 秒的小格,每隔 1 秒向前滑动一次,统计当前窗口内所有格子的请求总数。格子划分越细,滑动越平滑,限流统计越精确。

滑动窗口计数器限流示意:把时间切成更细的格子并随时间滚动,统计当前窗口内请求总和以做更平滑的限流 格子越细滚动越平滑,比固定窗口更能扛突发,但仍非真正匀速。

优点

缺点

1.3 漏桶

请求以任意速率注入”桶”中,以固定速率从桶底漏出处理;桶满时新请求被丢弃。实现上对应一个请求队列加定时消费(与消息队列削峰/限流的思路一致)。

漏桶算法示意:请求以任意速率注入桶中,以固定速率漏出处理,桶满则丢弃溢出请求 任意速率注入、固定速率漏出;桶满就丢——能控速但吃不了突发。

优点

缺点

1.4 令牌桶

系统以固定速率向桶中放入令牌,桶满则停止放入。每个请求在处理前须先从桶中取走一个令牌,取不到则拒绝或等待。

令牌桶算法示意:按速率往桶中放入令牌,请求需先取令牌才能被处理,桶满则不再放入令牌 按速率放令牌、请求先取令牌再处理——限平均速率又能吃突发。

优点

缺点

2. 限流对象与维度

确定限流算法后,还需要明确针对什么维度限流。常见维度:

此外,阿里 Sentinel 还支持 基于调用关系的限流(包括调用方限流、调用链入口限流、关联流量限流等)以及更细粒度的 热点参数限流(实时统计热点参数并对其资源调用进行流量控制)。

实际项目中,多种维度可以组合使用。

3. 单机限流实现

单机限流适用于单体架构,也可作为分布式系统中每个节点的本地兜底。

3.1 Guava RateLimiter

单机限流可直接使用 Google Guava 的 RateLimiter,底层基于令牌桶算法,支持突发流量吸收。

Guava 地址:https://github.com/google/guava

除基础令牌桶(平滑突发限流)外,RateLimiter 还提供平滑预热限流——预热期间速率从低逐步爬升到目标值,适合冷启动场景。

引入依赖:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.0.1-jre</version>
</dependency>

示例一:平滑突发限流

import com.google.common.util.concurrent.RateLimiter;

public class RateLimiterDemo {

    public static void main(String[] args) {
        // 1s 放 5 个令牌到桶里也就是 0.2s 放 1个令牌到桶里
        RateLimiter rateLimiter = RateLimiter.create(5);
        for (int i = 0; i < 10; i++) {
            double sleepingTime = rateLimiter.acquire(1);
            System.out.printf("get 1 tokens: %ss%n", sleepingTime);
        }
    }
}

输出:

get 1 tokens: 0.0s
get 1 tokens: 0.188413s
get 1 tokens: 0.197811s
get 1 tokens: 0.198316s
get 1 tokens: 0.19864s
get 1 tokens: 0.199363s
get 1 tokens: 0.193997s
get 1 tokens: 0.199623s
get 1 tokens: 0.199357s
get 1 tokens: 0.195676s

示例二:平滑预热限流

import com.google.common.util.concurrent.RateLimiter;
import java.util.concurrent.TimeUnit;

public class RateLimiterDemo {

    public static void main(String[] args) {
        // 1s 放 5 个令牌到桶里也就是 0.2s 放 1个令牌到桶里
        // 预热时间为3s,也就说刚开始的 3s 内发牌速率会逐渐提升到 0.2s 放 1 个令牌到桶里
        RateLimiter rateLimiter = RateLimiter.create(5, 3, TimeUnit.SECONDS);
        for (int i = 0; i < 20; i++) {
            double sleepingTime = rateLimiter.acquire(1);
            System.out.printf("get 1 tokens: %sds%n", sleepingTime);
        }
    }
}

输出:

get 1 tokens: 0.0s
get 1 tokens: 0.561919s
get 1 tokens: 0.516931s
get 1 tokens: 0.463798s
get 1 tokens: 0.41286s
get 1 tokens: 0.356172s
get 1 tokens: 0.300489s
get 1 tokens: 0.252545s
get 1 tokens: 0.203996s
get 1 tokens: 0.198359s

一句话:单机场景 Guava RateLimiter 够用;需要限流 + 熔断协同,优先考虑 Resilience4j。

3.2 Bucket4j

Bucket4j 是专注令牌桶/漏桶算法的限流库,功能比 Guava 更全面:同时支持单机和分布式限流,并可集成 Prometheus + Grafana 进行可观测性监控。

Bucket4j 地址:https://github.com/vladimir-bukhtoyarov/bucket4j

Spring Cloud Gateway 早期版本的单机限流即基于 Bucket4j 实现,后迁移至 Resilience4j。

3.3 Resilience4j

Resilience4j 是轻量级容错框架,灵感来自 Hystrix。Netflix 宣布停止积极维护 Hystrix 后,Spring 官方和 Netflix 均推荐以 Resilience4j 替代。

Resilience4j 地址: https://github.com/resilience4j/resilience4j

Resilience4j 将限流、熔断、负载保护、自动重试等高可用能力集成在同一框架内,生态成熟,被众多网关采用。在绝大多数生产场景下,Resilience4j 是更完整的选择;简单限流场景下 Guava 或 Bucket4j 同样适用。

4. 分布式限流实现

单机限流各自维护本地计数器,互不通信。一旦服务部署多实例,单机方案就会失真:假设下游 API 的全局配额是 500 QPS,10 台实例每台各自限流 500 QPS,全局实际放行量可达 10×500=5000 QPS——是设计阈值的 10 倍,限流形同虚设。要保住”全局阈值”这个约束,必须把计数状态集中存放,多台实例共享同一份计数,这正是分布式限流要解决的问题。常见方案:

4.1 Redis + Lua:为什么必须原子化

把”读计数 → 判断是否超限 → 写回计数”这三步拆成多条独立 Redis 命令执行,会出现经典的 check-then-act 竞态:假设阈值为 100,当前计数 count=99,两个并发请求几乎同时执行 GET count,都读到 99,都判断”99+1 ≤ 100”通过检查,于是都执行 INCR——最终 count=101,已经超过阈值却谁都没被拒绝。请求量越大、网络延迟越明显,这类竞态命中的概率就越高。

解决办法是把”读取、判断、自增、设置过期”打包进一段 Lua 脚本,交给 Redis 原子执行:

-- 注意:这是「固定窗口计数器」的原子实现,不是令牌桶!
-- KEYS[1] = 限流 key;ARGV[1] = 窗口内最大请求数;ARGV[2] = 窗口过期秒数
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current + 1 > tonumber(ARGV[1]) then
    return 0  -- 超限,拒绝
end
redis.call('INCR', KEYS[1])
if current == 0 then
    redis.call('EXPIRE', KEYS[1], ARGV[2])  -- 仅在窗口新建时设置过期
end
return 1  -- 放行

务必看清:上面这段脚本对应的是 §1.1 的固定窗口算法(在一个会过期的 key 上做原子计数),不是令牌桶——它不会按速率补充配额,也无法吸收跨窗口的突发。很多文章把这类脚本称作”Redis 令牌桶”,是不准确的。真正的分布式令牌桶需要在脚本里根据时间差补充令牌(见 §4.2),或直接用现成组件(见 §4.3)。

这段固定窗口脚本能生效的原因是 Redis 单线程执行命令:一段 Lua 脚本在被调度执行期间,其他客户端的命令(包括另一个并发请求的 Lua 脚本)不可能插进来打断它,脚本内的”读—判断—写”在 Redis 视角下是一个不可分割的整体,天然消除了上面的竞态。相比”多条命令 + 客户端自己判断”,Redis+Lua 还额外减少了网络往返——四步逻辑一次 EVAL 调用完成,而不是三四次独立的网络请求。

4.2 Redis + Lua:真正的分布式令牌桶

要在 Redis 上实现令牌桶(而非固定窗口),核心是把桶的状态(当前令牌数、上次补充时间)存进 Redis,每次请求时按”距上次补充经过的时间 × 速率”惰性补充令牌,再判断是否够扣。整段逻辑同样打进一段 Lua 脚本原子执行:

-- 分布式令牌桶:惰性补充 + 原子扣减
-- KEYS[1] = 令牌数 key;KEYS[2] = 上次补充时间戳 key
-- ARGV[1] = 桶容量;ARGV[2] = 每秒生成令牌数;ARGV[3] = 当前时间(秒,含小数);ARGV[4] = 本次请求令牌数
local capacity = tonumber(ARGV[1])
local rate     = tonumber(ARGV[2])
local now      = tonumber(ARGV[3])
local need     = tonumber(ARGV[4])

local tokens = tonumber(redis.call('GET', KEYS[1]) or capacity)
local last   = tonumber(redis.call('GET', KEYS[2]) or now)

-- 按经过的时间补充令牌,上限为桶容量
local filled = math.min(capacity, tokens + (now - last) * rate)

local allowed = 0
if filled >= need then
    filled = filled - need
    allowed = 1
end

-- 令牌耗尽到重新填满的最长时间作为 TTL,避免冷 key 常驻
local ttl = math.ceil(capacity / rate) + 1
redis.call('SET', KEYS[1], filled, 'EX', ttl)
redis.call('SET', KEYS[2], now, 'EX', ttl)
return allowed  -- 1 放行,0 拒绝

相比固定窗口脚本,令牌桶脚本多维护了一个”上次补充时间”,从而能按速率平滑补充、跨窗口吸收突发——这正是 §1.4 令牌桶的两大优势。生产中不必自己维护脚本,Redis 官方模块 redis-cell 直接提供了 CL.THROTTLE 命令(基于 GCRA 泛化信元速率算法,本质是令牌桶的等价实现),一条命令即可完成限流并返回剩余配额与建议重试时间:

# CL.THROTTLE key 桶容量 填充令牌数 填充周期(秒) 本次请求令牌数
> CL.THROTTLE bid:campaign:123 100 200 1 1
1) (integer) 0     # 0=放行, 1=拒绝
2) (integer) 100   # 桶容量
3) (integer) 99    # 剩余令牌
4) (integer) -1    # 被拒时需等待秒数(放行为 -1)
5) (integer) 1     # 桶重新填满需等待秒数(可直接作为 Retry-After)

4.3 Redisson

不想手写 Lua 脚本,可以直接使用 Redisson 的 RRateLimiter,底层已封装了 Lua + 令牌桶算法。

Redisson 是开源的 Java Redis 客户端,内置分布式锁、延迟队列、限流器等常用组件,支持 Redis 单机、Sentinel、Cluster 等多种部署模式。

RRateLimiter 使用示例:

// 创建一个 Redisson 客户端实例
RedissonClient redissonClient = Redisson.create();
// 获取一个名为 "javaguide.limiter" 的限流器对象
RRateLimiter rateLimiter = redissonClient.getRateLimiter("javaguide.limiter");
// 尝试设置限流器的速率为每小时 100 次
// RateType 有两种,OVERALL是全局限流,ER_CLIENT是单Client限流(可以认为就是单机限流)
rateLimiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.HOURS);

获取许可:

// 获取一个许可,如果超过限流器的速率则会等待
// acquire()是同步方法,对应的异步方法:acquireAsync()
rateLimiter.acquire(1);
// 尝试在 5 秒内获取一个许可,如果成功则返回 true,否则返回 false
// tryAcquire()是同步方法,对应的异步方法:tryAcquireAsync()
boolean res = rateLimiter.tryAcquire(1, 5, TimeUnit.SECONDS);

4.4 单机限流够不够:一句话判断

只要该接口部署了多台实例、且需要一个全局共享阈值,单机限流就不可信——除非业务能接受”全局吞吐 = 单机阈值 × 实例数”这种会随扩容而漂移的软约束。具体决策:

5. 分层限流:网关、服务、依赖三层各管一段

限流不是在某一个点设一个阈值就完事——一个成熟系统会在多个层次各设一道,职责互不重叠。把所有限流都堆在网关,则服务内部某个慢接口仍能自己把自己拖垮;只在服务层限流,则恶意刷量的洪峰会先打穿网关的连接资源。三层各管一段:

层次限流对象典型配额依据常用手段
网关层全站/租户/IP 的总入口流量集群总容量、防刷、DDoS 缓冲Nginx limit_req、Spring Cloud Gateway RedisRateLimiter、Sentinel 网关流控
服务层单个接口/资源的 QPS 或并发该接口压测饱和点 × 安全系数Sentinel 资源规则、Resilience4j RateLimiter
依赖调用层对某个下游/第三方的出向调用速率下游承诺的配额(如”500 QPS”)分布式令牌桶(Redis+Lua/Redisson)、客户端侧限流器

分层限流架构:请求洪峰依次经过网关层、服务层、依赖调用层三道限流闸门后到达下游,每层各设阈值、职责互不重叠 请求路径上三道闸各管一段:网关拦全局洪峰、服务护单接口容量、依赖调用守下游配额——叠加而非替代。

一句话:网关层护”进来的总量”,服务层护”单接口容量”,依赖调用层护”出去打下游的速率”——三者叠加而非互相替代。

6. 选型决策与反模式

6.1 算法怎么选

场景推荐算法理由
需要严格均匀输出速率(对下游调用保护)漏桶固定输出速率,不允许突发
通用限流,允许少量突发令牌桶兼顾平均速率与突发吸收,生产最常用
实现简单、准确度要求不高固定/滑动窗口易于理解和维护
对接第三方 API(有每分钟配额)滑动窗口细粒度更能防止配额超限

6.2 框架怎么选

部署模式推荐方案
单机,只需限流Guava RateLimiter
单机,需要限流 + 熔断 + 重试Resilience4j
分布式,Java 系Redisson RRateLimiter 或 Redis+Lua
网关层统一限流Spring Cloud Gateway RedisRateLimiter 或 Sentinel
大规模微服务Sentinel(支持集群模式、热点参数、QPS/线程数双维度)

6.3 常见反模式

  1. 在数据库层做限流:用行锁实现「同时只允许一个请求」,高并发下锁竞争极大,反而成为瓶颈;应移到 Redis 原子操作层。

  2. 阈值脱离压测数据:拍脑袋设定「每秒 100 次」,但未验证系统真实容量。阈值设低则正常请求被拒,设高则形同虚设;应以压测饱和点 × 安全系数(0.7–0.8)作为基准,参见 性能测试

  3. 全局单一粒度限流:对整个 /api/** 统一限流,粒度过粗——低价值接口消耗配额,核心高价值接口反被误杀;应按接口重要性分层设置阈值。

  4. 忽略限流后的用户体验:直接返回 429 Too Many Requests 但不携带 Retry-After 头,客户端无法知道何时重试,频繁轮询反而加重压力;应规范错误码并携带重试建议。

  5. 单机限流用于分布式场景:多实例部署时,每台机器各自按 100 QPS 限流,全集群实际通过 N×100 QPS,失去保护意义;多实例必须使用集中式分布式限流(Redis / Sentinel)。

小结

系列其他篇

参考


views
Share this post on:

Previous Post
依赖韧性:超时重试、熔断降级与舱壁隔离
Next Post
冗余与容灾:HA 集群、同城异地灾备与多活选型