限流(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 泄漏风险 | 每个用户/每个窗口产生 Key | Key 含时间戳,靠 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-benchmark | Redis 自带,测单命令吞吐 | 验证限流命令本身性能 |
wrk | 轻量、高并发、Lua 扩展 | HTTP 接口限流压测 |
ab (Apache Bench) | 简单易用 | 快速冒烟压测 |
| JMeter | 图形化、分布式压测 | 复杂场景编排 |
hey / go-wrk | Go 生态、简单 | 微服务压测 |
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巡检)
结语
限流是保护高并发系统最直接的手段,但方案远不止"写个计数器"。核心要点回顾:
- 算法选型:固定窗口简单但有边界突发;滑动窗口精确但内存大;令牌桶兼顾突发与速率是网关首选;漏桶适合保护恒定吞吐下游
- 原子性:
INCR + EXPIRE必须用 Lua 包裹成原子操作,避免计数与过期不一致 - 分布式:单机限流在多实例下失效,必须依赖 Redis 共享状态(Lua / Redisson)
- 分层限流:网关粗粒度 + 应用细粒度 + 热点专项,层层设防
- 协同治理:限流管入口、熔断管依赖、降级管兜底,三者配合形成完整高可用体系
- 验证驱动:每次调整限流阈值都必须通过压测确认吞吐与 p99 延迟符合预期
限流不是"拒绝用户",而是"保护更多用户"。精心设计的限流体系,应让 99% 的正常用户在峰值期依然流畅,只牺牲极小比例的过载流量。这才是高并发系统真正优雅的防洪闸。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。