高并发限流器设计:固定窗口、令牌桶与分布式限流实战

高并发限流器设计指南:固定窗口/滑动窗口/令牌桶/漏桶算法对比与实现代码、Redis 限流(INCR+EXPIRE 与 Lua 原子脚本)、分布式限流(Redisson RateLimiter)、网关层限流(Nginx/Spring Cloud Gateway)、限流与熔断降级协同、热点防护与动态配额、压测验证

限流(Rate Limiting)是高并发系统的第一道防洪闸。当瞬时流量超过系统承载能力时,若不加以约束,连接池耗尽、下游被打爆、缓存雪崩会连锁发生。Redis 凭借原子命令、Lua 脚本与高吞吐特性,成为实现分布式限流的事实标准。

本文从四种经典限流算法出发,深入基于 Redis 的生产级实现:INCR + EXPIRE 简单窗口、滑动窗口 ZSet、令牌桶 Lua 脚本、Redisson RateLimiter,再到网关层限流与压测验证,构建一套可落地、可观测、可调优的限流体系。


一、为什么要限流:高并发下的雪崩源头

1.1 限流解决的问题

电商大促场景中,峰值 QPS 可能是平时的 100 倍。系统容量固定,超出部分的请求如果不被拦截,会造成三类典型故障:

  • 资源耗尽:线程池打满、连接池排队、GC 压力剧增,最终 OOM 或拒绝服务
  • 下游击穿:缓存未命中时流量直达数据库,慢 SQL 拖垮存储层
  • 级联雪崩:一个服务被打挂,依赖它的上游全部超时,故障像多米诺骨牌扩散

限流的本质是用部分失败的确定性,换取整体服务的不确定性可控。它与熔断(Circuit Breaker)、降级(Degradation)共同构成高可用的"三驾马车"。

1.2 限流、熔断、降级的区别

机制关注对象触发依据响应动作
限流请求流量单位时间请求数/速率拒绝多余请求(429)
熔断下游依赖错误率/超时率阈值快速失败,短路调用
降级业务功能资源紧张/依赖故障返回兜底数据或关闭非核心功能

三者目标一致但层次不同:限流管"入口",熔断管"出口依赖",降级管"业务内容"。生产实践往往是三者叠加使用。

1.3 限流的分层位置

限流应分层布置:网关层(客户端→网关)做粗粒度全局防护,按 IP/用户维度拦截异常流量;应用层(网关→服务)做细粒度业务限流,按接口/资源维度精确控制;数据层(服务→Redis/DB)做热点保护,防止热点 Key 打穿存储。


二、四种经典限流算法对比

2.1 算法总览

算法实现复杂度突发容忍内存开销精确度典型场景
固定窗口极低有(边界突发)1 个 Key粗糙每分钟调用次数
滑动窗口中可控ZSet/切片较高严格控制单位时间速率
令牌桶中好(可积累令牌)2~3 字段高API 网关、允许突发
漏桶低无(恒定速率)1 个 Key高保护恒定吞吐下游

2.2 固定窗口(Fixed Window)

将时间划分为固定窗口(如 1 秒/1 分钟),每个窗口内允许一定数量请求,窗口结束计数器清零。

致命缺点:窗口边界"双倍突发"。限流 100 次/分钟时,请求在 00:59:59 打满 100 次,01:00:00 窗口重置后又可打 100 次,两秒内实际放行 200 次请求。

2.3 滑动窗口(Sliding Window)

将窗口细分为多个小切片,通过滑动求和精确控制单位时间速率,消除边界突发。用 ZSet 记录窗口内每个请求的时间戳即可实现(见第四节 Lua 代码)。

2.4 令牌桶(Token Bucket)

系统以恒定速率往桶里放令牌,桶有上限。请求到达时取一个令牌,取到放行、取不到拒绝。令牌桶允许突发:桶积满时可瞬间放行一批请求,之后以恒定速率恢复,因此比漏桶更适合 API 网关。

2.5 漏桶(Leaky Bucket)

请求以任意速率进入桶,桶以恒定速率漏出(处理),桶满丢弃新请求。输出速率完全恒定,适合保护能力固定的下游,但突发流量会被削平,不适合需要响应的交互式接口。


三、基于 Redis 的固定窗口限流:INCR + EXPIRE

3.1 最小可用实现

# 用户 1001 在 1 秒窗口内的第一次请求
redis-cli INCR rate:user:1001:20260927-100000
# (integer) 1
redis-cli EXPIRE rate:user:1001:20260927-100000 1

# 同一窗口内第 101 次请求
redis-cli INCR rate:user:1001:20260927-100000
# (integer) 101  -> 超过限流阈值 100

但 INCR + EXPIRE 是两条命令,非原子,高并发下存在"计数了却没设置过期时间"的竞态,必须用 Lua 原子执行。

3.2 原子化的固定窗口 Lua 脚本

-- fixed_window.lua
-- KEYS[1]  限流 Key,如 rate:user:1001
-- ARGV[1]  窗口内允许的最大请求数
-- ARGV[2]  窗口时长(秒)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call('INCR', key)
if current == 1 then
    redis.call('EXPIRE', key, window)
end
if current > limit then
    return 0   -- 被限流
end
return 1       -- 放行
# 将脚本加载到 Redis,得到 SHA
redis-cli SCRIPT LOAD "$(cat fixed_window.lua)"
# "e0f2c9d8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2"

# 用 EVALSHA 原子调用(避免每次传脚本,性能更好)
redis-cli EVALSHA e0f2c9d8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2 1 rate:user:1001 100 1
# (integer) 1

生产环境应使用 SCRIPT LOAD + EVALSHA 组合,并把 SHA 缓存在客户端。若收到 NOSCRIPT 错误再回退到 EVAL 重载脚本。

3.3 固定窗口的工程局限

局限说明缓解方式
边界突发窗口边界可双倍放行换用滑动窗口
Key 泄漏风险每个用户/每个窗口产生 KeyKey 含时间戳,靠 EXPIRE 自动回收
冷启动风暴大量新 Key 同窗口创建预置 Key 池或加长窗口

四、滑动窗口与令牌桶的 Redis + Lua 原子实现

4.1 滑动窗口:基于 ZSet

用 ZSet 记录窗口内每个请求的时间戳(score 即请求时刻),先剔除滑出窗口的旧记录,再统计窗口内请求数。

-- sliding_window.lua
-- KEYS[1]  窗口 Key,如 rate:ip:10.0.0.5
-- ARGV[1]  当前时间戳(毫秒)
-- ARGV[2]  窗口大小(毫秒),如 60000
-- ARGV[3]  窗口内最大请求数
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

-- 1. 剔除已滑出窗口的请求
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 2. 统计窗口内请求数
local count = redis.call('ZCARD', key)
if count < limit then
    -- 3. 记录本次请求(member 加随机数保证唯一)
    redis.call('ZADD', key, now, now .. '-' .. math.random(100000))
    redis.call('PEXPIRE', key, window)
    return 1
end
return 0
# 毫秒时间戳:date +%s%3N
redis-cli EVAL "$(cat sliding_window.lua)" 1 rate:ip:10.0.0.5 $(date +%s%3N) 60000 60

注意:ZSet 每个请求占一条记录,高 QPS 下内存偏大。若窗口精度要求不高,可用多个固定窗口切片(如 6 个 10 秒桶)做近似滑动窗口,内存从 O(请求数) 降到 O(切片数)。

4.2 令牌桶:基于 Hash 的原子实现

令牌桶需要两个状态:当前令牌数、上次补充时间。用 Hash 存储,在 Lua 中按流逝时间补令牌。

-- token_bucket.lua
-- KEYS[1]  令牌桶 Key,如 rate:app:order
-- ARGV[1]  令牌补充速率(个/秒)
-- ARGV[2]  桶容量(最大令牌数)
-- ARGV[3]  当前时间戳(毫秒)
-- ARGV[4]  本次请求需要令牌数(默认 1)
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4]) or 1

local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(data[1]) or capacity
local last_refill = tonumber(data[2]) or now

-- 按流逝时间补充令牌
local elapsed = (now - last_refill) / 1000.0
tokens = math.min(capacity, tokens + elapsed * rate)

if tokens >= requested then
    tokens = tokens - requested
    redis.call('HSET', key, 'tokens', tokens, 'last_refill', now)
    redis.call('PEXPIRE', key, 10000)
    return 1   -- 放行
end

redis.call('HSET', key, 'tokens', tokens, 'last_refill', now)
return 0       -- 拒绝

对于"同一时刻最多 N 个并发"的并发数限流,令牌桶不适用,应改用计数信号量:INCR 后判断是否超过上限,请求结束 DECR 归还计数。注意 INCR 首次后要设置 EXPIRE 兜底防 Key 泄漏。


五、分布式限流:Redis + Lua 与 Redisson RateLimiter

5.1 为什么单机限流不够

Guava RateLimiter 等单机限流器在多实例水平扩展下会失效:100 QPS 的阈值,部署 10 个实例后实际放行 1000 QPS。分布式限流的核心是共享状态——所有实例把计数/令牌存到同一 Redis。代价是每次多一次网络往返(约 0.1~0.5ms),可通过本地缓存 + 批量取令牌缓解。

5.2 Redisson RateLimiter 使用

Redisson 基于令牌桶实现了成熟的 RRateLimiter:

import org.redisson.api.RRateLimiter;
import org.redisson.api.RateIntervalUnit;
import org.redisson.api.RateType;

// 初始化:整体速率 100/秒,桶容量 200
RRateLimiter limiter = redisson.getRateLimiter("rate:pay:user:1001");
limiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.SECONDS);

// 尝试获取 1 个令牌(非阻塞)
boolean allowed = limiter.tryAcquire(1);
if (!allowed) {
    throw new TooManyRequestsException();   // 返回 429
}
RateType含义适用场景
OVERALL全实例共享一个桶全局接口限流
PER_CLIENT每个客户端独立桶按用户/设备维度限流

Redisson RateLimiter 底层正是 EVALSHA 调用令牌桶 Lua 脚本,通过 trySetRate 持久化速率配置,业务方无需关心状态机。若不使用 Redisson,可在客户端用 SCRIPT LOAD + EVALSHA 自行封装原子限流(Go 的 go-redis 提供 redis.NewScript 封装 Lua 调用),保持与本文第四节的 Lua 脚本一致。

5.4 分布式限流的可靠性问题

问题影响对策
Redis 单点故障限流失效Redis 高可用 + 客户端本地兜底
时钟偏差窗口/令牌时间计算偏差统一使用 Redis 服务器时间
网络抖动限流调用本身变慢设置超时 + 失败降级策略
数据倾斜热点 Key 集中单分片限流 Key 带分片前缀

关于 Redis 故障时的取舍:安全敏感场景应 fail-closed(拒绝放行),可用性优先场景应 fail-open(放行并告警),务必在架构评审时明确。


六、网关层限流:Nginx 与 Spring Cloud Gateway

6.1 Nginx 限流:limit_req / limit_conn

Nginx 内置 ngx_http_limit_req_module(令牌桶)与 ngx_http_limit_conn_module(并发连接)限流。

# nginx.conf
http {
    # 按 IP 维度定义限流区,rate=100r/s
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/s;

    server {
        listen 80;

        location /api/ {
            # burst=200: 允许突发积压 200 个;nodelay: 积压请求不排队
            limit_req zone=api_limit burst=200 nodelay;
            limit_req_status 429;   # 超限返回 429
            proxy_pass http://backend;
        }
    }
}

并发连接数则用 limit_conn_zone $binary_remote_addr zone=conn_limit:10m; 定义区域,配合 limit_conn conn_limit 10; 限制单 IP 最大连接数。

6.2 Spring Cloud Gateway:RequestRateLimiter

RequestRateLimiter 过滤器内置了基于 Redis 的令牌桶实现(官方 RedisRateLimiter Lua 脚本),支持按 IP、用户、请求头自定义 Key。

spring:
  redis:
    host: redis-gateway
    port: 6379
  cloud:
    gateway:
      routes:
        - id: order-route
          uri: lb://order-service
          predicates:
            - Path=/api/order/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 100   # 每秒补充令牌数
                redis-rate-limiter.burstCapacity: 200   # 桶容量(允许突发)
                redis-rate-limiter.requestedTokens: 1   # 每次请求消耗令牌数
                key-resolver: "#{@ipKeyResolver}"       # 自定义 KeyResolver

对应的 KeyResolver 只需实现一个返回维度值的方法,例如按 IP:exchange.getRequest().getRemoteAddress().getAddress().getHostAddress(),按用户:读取请求头 X-User-Id。KeyResolver 返回的值会拼进 Redis 限流 Key:request_rate_limiter.{Key值}.tokens 与 request_rate_limiter.{Key值}.timestamp,可用 KEYS request_rate_limiter.* 排查。

6.3 网关限流与业务限流的协同

层次维度阈值建议目的
网关IP、设备宽松(如 1000/s)挡 DDoS、异常扫描
网关全局限流系统容量 80%防整体雪崩
应用接口/用户业务容量精确控制资源占用
应用热点 Key动态防缓存击穿

七、限流与熔断、降级的协同

7.1 三种机制的配合模式

单一限流不能解决所有问题:限流挡住超额请求,但下游故障(DB 慢查询、第三方不可用)导致的错误率飙升需要熔断兜底;熔断触发后的降级流量又需要限流保护。典型调用链为:请求先过限流(Redis 判断),被拒则返回 429;通过的请求进入熔断器,熔断打开时快速失败;业务异常时降级返回兜底数据。

7.2 Resilience4j 熔断器配置

以 Resilience4j 为例,熔断器关注错误率阈值(如 50%)、熔断持续时间(如 30s)、半开状态探测请求数(如 10)等参数;调用前先 rateLimiter.tryAcquire(1) 做限流判断,通过后再交给熔断器执行下游调用,形成"限流在前、熔断在后"的调用链。

CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .failureRateThreshold(50)
    .waitDurationInOpenState(Duration.ofSeconds(30))
    .permittedNumberOfCallsInHalfOpenState(10)
    .slidingWindowSize(100)
    .build();
CircuitBreaker cb = CircuitBreaker.of("order-cb", config);

boolean allowed = rateLimiter.tryAcquire(1);   // 先限流
if (allowed) {
    cb.executeSupplier(() -> orderService.createOrder(order));   // 再熔断保护
} else {
    throw new TooManyRequestsException();
}

7.3 降级策略分类

降级类型触发条件降级内容
默认值降级依赖故障/超时返回缓存快照或默认空数据
读降级缓存失效降级为只读本地缓存
写降级DB 压力大写入 MQ 异步落库
功能降级非核心功能关闭搜索、推荐、实时统计

降级核心原则:核心链路保命,非核心让路。限流阈值应为核心接口留足余量、非核心接口优先丢弃。


八、热点防护与动态配额

8.1 热点参数限流

普通限流按固定维度计数,真正的杀手是热点:某商品 ID、某用户瞬间涌入百万流量。阿里巴巴 Sentinel 提供热点参数限流(Param Flow),按调用参数的具体取值限流——例如 ParamFlowRule 设置 setParamIdx(0) 对"商品 ID"参数做独立配额,单个热点商品的访问量超阈值即拒绝,而非整条链路限流。

8.2 Redis 热点 Key 的探测与限流

基于访问频率识别热点 Key:将 maxmemory-policy 设为 allkeys-lfu(或 volatile-lfu)后,可用 OBJECT FREQ key 查看单个 Key 的近似访问频率(0~255),用 redis-cli --hotkeys 扫描全库热点。识别出热点后,为热点 Key 单独设置更高的限流桶(如 1000/s),同时配置本地缓存兜底,把压力从 Redis 分散到应用内存。

8.3 动态配额调整

静态阈值难以应对业务波动,可通过配置中心(Nacos/Apollo)下发限流参数,应用收到变更后调用 trySetRate 重新设定速率,无需重启:

# 动态调整某接口的限流阈值(无需重启)
redis-cli EVALSHA <token_bucket_sha> 1 rate:api:order 500 1000 $(date +%s%3N) 1

九、压测验证与调优

9.1 压测工具选型

工具特点适用场景
redis-benchmarkRedis 自带,测单命令吞吐验证限流命令本身性能
wrk轻量、高并发、Lua 扩展HTTP 接口限流压测
ab (Apache Bench)简单易用快速冒烟压测
JMeter图形化、分布式压测复杂场景编排
hey / go-wrkGo 生态、简单微服务压测

9.2 压测 Redis 限流命令性能

# 压测 Lua 限流脚本(先 SCRIPT LOAD 得到 SHA 后 EVALSHA,-P 8 开启 pipeline)
redis-benchmark -h 127.0.0.1 -p 6379 -n 500000 -c 200 -P 8 \
  evalsha e0f2c9d8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2 1 rate:bench:u1 100 1

压测重点关注:Redis 单实例 QPS(评估限流本身开销)与 p99 延迟(评估对业务拖累)。Lua 脚本在 1ms 内完成时,业务侧几乎无感。

9.3 端到端验证

用 wrk 压测 HTTP 接口,同时统计网关/应用日志中 429 响应比例,验证拦截是否符合预期:

wrk -t 8 -c 100 -d 30s --latency http://localhost:8080/api/order
grep -c '"status":429' /tmp/access.log   # 429 数量应约等于超限请求数

9.4 限流参数调优清单

  • 窗口与桶容量:burstCapacity ≈ 2 × replenishRate 容忍秒级突发;核心接口建议 1.5~3 倍
  • 限流 Key 粒度:过粗(全局限流)误杀正常用户,过细(每用户)内存开销大
  • Redis 侧优化:Pipeline 批量获取令牌(每次取 5~10 个令牌本地缓冲,降低往返)
  • 客户端超时:限流调用超时 10~50ms,避免限流本身成为瓶颈
  • 监控指标:限流拒绝率(应 <5%)、限流命令 p99 延迟、限流 Key 数量(防泄漏,可用 redis-cli --scan --pattern "rate:*" | wc -l 巡检)

结语

限流是保护高并发系统最直接的手段,但方案远不止"写个计数器"。核心要点回顾:

  1. 算法选型:固定窗口简单但有边界突发;滑动窗口精确但内存大;令牌桶兼顾突发与速率是网关首选;漏桶适合保护恒定吞吐下游
  2. 原子性:INCR + EXPIRE 必须用 Lua 包裹成原子操作,避免计数与过期不一致
  3. 分布式:单机限流在多实例下失效,必须依赖 Redis 共享状态(Lua / Redisson)
  4. 分层限流:网关粗粒度 + 应用细粒度 + 热点专项,层层设防
  5. 协同治理:限流管入口、熔断管依赖、降级管兜底,三者配合形成完整高可用体系
  6. 验证驱动:每次调整限流阈值都必须通过压测确认吞吐与 p99 延迟符合预期

限流不是"拒绝用户",而是"保护更多用户"。精心设计的限流体系,应让 99% 的正常用户在峰值期依然流畅,只牺牲极小比例的过载流量。这才是高并发系统真正优雅的防洪闸。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「redis」更多文章

  1. Redis 向量检索实战:RediSearch、HNSW 与 Embedding 管道集成
  2. 缓存一致性终极方案:双删、binlog 订阅与最终一致性架构
  3. 高级数据结构实战:Bitmap、HyperLogLog、GEO、布隆过滤器与 Stream