> 技术文档 > 分布式限流算法与组件

分布式限流算法与组件


固定时间窗口算法(计数法)

当 Redis 版本过低(不支持 CL.THROTTLE 命令)时,可以基于 Redis 的 INCR (原子递增)和 EXPIRE (设置过期时间)命令,自己实现一个简单的固定窗口限流算法。下面结合 ASP.NET Core 框架,以 Test 接口的 GET 请求为例,完整实现:

核心思路(固定窗口算法)

  1. 时间窗口划分:比如 60 秒一个窗口(如 00:00:00-00:00:59 为一个窗口)。
  2. 计数器:用 Redis 键存储当前窗口的请求数(键名包含窗口标识,如 rate_limit:api:test:202407231200 ,最后几位是分钟)。
  3. 原子操作:
  • 每次请求进来,先计算当前窗口的 Redis 键。
  • 用 INCR 命令递增计数器(原子操作,避免并发问题)。
  • 若计数器是第一次创建,用 EXPIRE 设置过期时间(略大于窗口时长,如 61 秒,避免窗口切换时键未清理)。
  • 若计数器值 ≤ 阈值,允许请求;否则限流。

具体实现(ASP.NET Core 接口)

步骤1:注入 Redis 服务(同上,确保已安装 StackExchange.Redis )

步骤2:编写限流工具类(封装核心逻辑)

using StackExchange.Redis;using System;public class RedisRateLimiter{ private readonly IDatabase _redisDb; public RedisRateLimiter(ConnectionMultiplexer redis) { _redisDb = redis.GetDatabase(); } ///  /// 检查是否允许请求 ///  /// 限流资源名(如接口标识) /// 窗口内最大请求数 /// 窗口时长(秒) /// true=允许,false=限流 public async Task<bool> AllowRequestAsync(string resource, int maxRequests, int windowSeconds) { // 1. 生成当前窗口的 Redis 键(包含时间戳,确保每个窗口唯一) // 例如:rate_limit:api:test:1721721600(时间戳=当前时间/窗口秒数,取整数) long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / windowSeconds; string redisKey = $\"rate_limit:{resource}:{timestamp}\"; // 2. 原子递增计数器(+1) long currentCount = await _redisDb.StringIncrementAsync(redisKey, 1); // 3. 若第一次创建键,设置过期时间(窗口时长+1秒,避免窗口切换时键残留) if (currentCount == 1) { await _redisDb.KeyExpireAsync(redisKey, TimeSpan.FromSeconds(windowSeconds + 1)); } // 4. 判断是否超过阈值 return currentCount <= maxRequests; }}

步骤3:在控制器中使用限流工具类

using Microsoft.AspNetCore.Mvc;using StackExchange.Redis;[ApiController][Route(\"api/[controller]\")]public class DemoController : ControllerBase{ private readonly RedisRateLimiter _rateLimiter; // 构造函数注入限流工具类 public DemoController(ConnectionMultiplexer redis) { _rateLimiter = new RedisRateLimiter(redis); } [HttpGet(\"Test\")] public async Task<IActionResult> Test() { // 限流规则:60秒内最多允许100个请求(无突发容忍,如需可调整阈值) bool isAllowed = await _rateLimiter.AllowRequestAsync( resource: \"api:test\", // 资源标识(对应接口) maxRequests: 100, // 窗口内最大请求数 windowSeconds: 60 // 窗口时长(秒) ); if (isAllowed) { // 允许请求:执行业务逻辑 return Ok(\"请求成功,返回数据\"); } else { // 限流:返回429状态码 return StatusCode(429, \"请求过于频繁,请稍后再试\"); } }}

步骤4:在 Program.cs 中注册服务

var builder = WebApplication.CreateBuilder(args);

// 注册 Redis 连接(单例)
builder.Services.AddSingleton(
ConnectionMultiplexer.Connect(builder.Configuration[“Redis:ConnectionString”])
);

// 其他服务注册…

var app = builder.Build();
// …启动应用

关键说明

  1. 窗口键的生成:
    用 当前时间戳 / 窗口秒数 得到窗口标识(如 60 秒窗口,时间戳 1721721600 和 1721721659 会被分到同一个窗口),确保同一窗口内的请求共用一个计数器。
    计算 redisKey 时用的是「当前时间戳除以窗口时长」,结果会把同一窗口内的不同时间映射到同一个键。举个具体例子就清楚了:
    举例说明(以 60 秒窗口为例)
    假设窗口时长是 60 秒,当前时间戳(Unix 秒数)如下:

请求 1 时间:1721721600 秒(比如 2024-07-23 12:00:00)
请求 2 时间:1721721630 秒(同一分钟内的 12:00:30)
请求 3 时间:1721721659 秒(同一分钟内的 12:00:59)
请求 4 时间:1721721660 秒(下一分钟的 12:01:00)

计算 timestamp = 时间戳 / 60:

请求 1:1721721600 / 60 = 28695360
请求 2:1721721630 / 60 = 28695360(和请求 1 相同)
请求 3:1721721659 / 60 = 28695360(和请求 1 相同)
请求 4:1721721660 / 60 = 28695361(进入下一个窗口,键不同)
因此:
请求 1、2、3 会生成同一个 redisKey(如 rate_limit:api:test:28695360),共用一个计数器,确保 60 秒内总请求数被限制。
请求 4 进入下一个 60 秒窗口,生成新的 redisKey,计数器重新开始计算。

  1. 原子性保证:
    StringIncrementAsync 是 Redis 原子命令,即使多个服务器同时请求,也能保证计数器准确递增,不会出现重复计数问题。
  2. 过期时间设置:
    只在计数器第一次创建时设置过期时间( currentCount == 1 ),避免重复设置;过期时间比窗口时长多 1 秒,确保窗口结束后键被自动清理。
  3. 突发请求处理:
    若需要容忍突发请求,可临时提高 maxRequests (如允许 10 个突发,就设为 110),但固定窗口算法在窗口切换时可能出现“临界问题”(如窗口末尾和开头各发 100 次,实际 2 秒内 200 次),如需更精准可改用滑动窗口(实现稍复杂)。

总结
这种基于 Redis INCR + EXPIRE 的实现,无需依赖高版本 Redis,兼容所有版本,且能满足大部分分布式限流场景(中小型系统足够用)。缺点是不精确,如果追求更精准的限流(如滑动窗口、令牌桶),可基于此思路扩展,或使用成熟库(如 Polly 的限流策略结合 Redis)。

滑动时间窗口算法

using StackExchange.Redis;using System;using System.Threading.Tasks;public class SlidingWindowRateLimiter{ private readonly IDatabase _redisDb; private readonly string _prefix = \"sliding_rate_limit:\"; public SlidingWindowRateLimiter(IConnectionMultiplexer redisConnection) { _redisDb = redisConnection.GetDatabase(); } ///  /// 判断请求是否允许通过滑动窗口限流 ///  /// 限流的资源标识(如API路径) /// 窗口内最大允许请求数 /// 窗口时长(秒) /// 是否允许请求通过 public async Task<bool> AllowRequestAsync(string resource, int maxRequests, int windowSeconds) { // 生成Redis键 string redisKey = $\"{_prefix}{resource}\"; // 获取当前Unix时间戳(秒) long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); // 计算窗口的起始时间(当前时间 - 窗口时长) long windowStartTime = now - windowSeconds; // 生成唯一标识,用于SortedSet的成员 string requestId = Guid.NewGuid().ToString(); // 使用Lua脚本确保操作的原子性 string luaScript = @\" -- 1. 移除窗口外的请求记录 redis.call(\'ZREMRANGEBYSCORE\', KEYS[1], 0, ARGV[1]) -- 2. 统计当前窗口内的请求数 local currentCount = redis.call(\'ZCARD\', KEYS[1]) -- 3. 如果未超过阈值,添加当前请求 if currentCount < tonumber(ARGV[2]) then redis.call(\'ZADD\', KEYS[1], ARGV[3], ARGV[4]) -- 设置过期时间,避免内存泄漏 redis.call(\'EXPIRE\', KEYS[1], ARGV[5]) return 1 end return 0 \"; // 执行Lua脚本 var result = await _redisDb.ScriptEvaluateAsync( luaScript, new RedisKey[] { redisKey }, new RedisValue[] {  windowStartTime, // ARGV[1]: 窗口起始时间 maxRequests, // ARGV[2]: 最大请求数 now,  // ARGV[3]: 当前时间戳(作为score) requestId, // ARGV[4]: 请求唯一标识(作为member) windowSeconds + 1 // ARGV[5]: 过期时间(窗口时长+1秒,确保窗口外的键被清理) } ); // 结果为1表示允许请求,0表示限流 return (long)result == 1; }}

这段代码是基于 Redis 实现的滑动窗口限流算法,核心通过 Lua 脚本保证操作的原子性,避免并发场景下的计数误差。我们来逐部分解析:
一、核心数据结构:Redis 有序集合(Sorted Set,ZSet)
整个限流逻辑依赖 Redis 的有序集合(ZSet) 实现,原因是 ZSet 有两个关键特性:

每个成员(member)对应一个分数(score),可按分数范围高效操作
支持按分数范围删除成员、统计成员数量等操作,适合时间窗口内的计数场景
二、Lua 脚本详解(核心限流逻辑)
Lua 脚本的作用是将多个 Redis 操作打包成一个原子操作(Redis 会单线程执行整个脚本,中间不会被其他请求打断),确保限流逻辑的准确性。
脚本变量说明
KEYS[1]:Redis 中的键名(唯一),对应一个 ZSet,用于存储当前窗口内的请求记录
ARGV:参数数组,包含 5 个具体值(下文会对应到 C# 代码中的参数)
脚本逻辑分步解析
lua
– 1. 移除窗口外的请求记录 不存在按照空集合处理,不会报错
redis.call(‘ZREMRANGEBYSCORE’, KEYS[1], 0, ARGV[1])

作用:清理 “当前统计窗口” 之外的历史请求(避免旧数据干扰计数)
命令解释:ZREMRANGEBYSCORE 是 ZSet 的命令,用于删除 “分数在 [0, ARGV [1]] 范围内” 的所有成员
变量对应:ARGV[1] 是 “窗口起始时间戳”(C# 代码中的windowStartTime),即只保留 “时间戳> 窗口起始时间” 的请求(这些是当前窗口内的请求)

lua
– 2. 统计当前窗口内的请求数 不存在按照空集合处理,不会报错
local currentCount = redis.call(‘ZCARD’, KEYS[1])

作用:计算当前窗口内还剩多少请求(经过第一步清理后)
命令解释:ZCARD 是 ZSet 的命令,用于获取集合的成员总数
结果:currentCount 就是当前窗口内的请求数量

lua
– 3. 如果未超过阈值,添加当前请求
if currentCount < tonumber(ARGV[2]) then
– 第一个请求时,会自动创建一个zset数据结构
redis.call(‘ZADD’, KEYS[1], ARGV[3], ARGV[4])
– 设置过期时间,避免内存泄漏 每一次调用会刷新过期时间
redis.call(‘EXPIRE’, KEYS[1], ARGV[5])
return 1
end

return 0

条件判断:ARGV[2] 是 “最大允许请求数”(C# 代码中的maxRequests),如果当前请求数小于阈值,则允许本次请求
添加当前请求:ZADD 是 ZSet 的命令,用于添加成员。这里:
ARGV[3] 是 “当前请求的时间戳”(C# 代码中的now),作为该成员的score(用于后续窗口判断)
ARGV[4] 是 “请求唯一标识”(C# 代码中的requestId),作为该成员的member(确保每个请求唯一)
设置过期时间:EXPIRE 用于给 ZSet 设置过期时间,ARGV[5] 是 “窗口时长 + 1 秒”(C# 代码中的windowSeconds + 1),避免键永久占用内存
返回值:1 表示允许请求,0 表示触发限流
三、C# 代码参数对应关系
C# 代码中通过ScriptEvaluateAsync执行 Lua 脚本,参数对应关系如下:

Lua 中的变量C# 代码中的参数含义
KEYS[1]redisKeyRedis 中 ZSet 的键名(唯一标识一个限流窗口,如 “rate_limit:user123”)
ARGV[1]windowStartTime窗口起始时间戳(如当前时间 - 窗口时长,单位通常是毫秒)
ARGV[2]maxRequests窗口内允许的最大请求数(限流阈值,如 100 次 / 分钟)
ARGV[3]now当前请求的时间戳(作为 ZSet 成员的 score)
ARGV[4]requestId当前请求的唯一标识(作为 ZSet 成员的 member,避免重复计数)
ARGV[5]windowSeconds + 1ZSet 的过期时间(窗口时长 + 1 秒,确保窗口外的键被自动清理)
四、整体逻辑总结
清理旧数据:先删除窗口外的请求(时间戳 < 窗口起始时间),确保只统计当前窗口内的请求
统计当前请求数:计算当前窗口内剩余的请求数量
判断是否限流:如果当前数量 < 阈值,则添加本次请求并允许通过;否则拒绝请求

通过 ZSet 的分数(时间戳)管理请求的时间范围,结合 Lua 的原子性,实现了高效、准确的滑动窗口限流。

在上述滑动时间窗口的 C# 实现中,并没有显式地将时间窗口分割成固定数量的小格子,而是采用了更精细的 “基于每个请求时间戳” 的实时计算方式,核心逻辑是:直接以 “当前时间往前推 windowSeconds 秒” 作为动态窗口,通过 Redis 的 SortedSet 实时过滤并统计这个动态窗口内的所有请求。
与 “固定小格子拆分” 的区别
如果用 “固定小格子拆分” 的思路(例如将 120 秒窗口拆分为 6 个 20 秒格子),需要预先定义格子数量和每个格子的时长。但上述实现采用了更灵活的方式:

不依赖预设的格子数量,而是通过ZREMRANGEBYSCORE命令,实时删除所有 “时间戳 < 当前时间 - windowSeconds” 的请求(即超出当前窗口的请求)。
剩余的请求均属于 “当前时间往前推 windowSeconds 秒” 的动态窗口内,直接通过ZCARD统计总数,无需拆分格子。
为什么不拆分格子?
这种实现的优势在于:

更高精度:每个请求的时间戳直接参与计算,无需依赖格子粒度,避免了 “格子拆分过粗导致的精度不足” 问题。
简化逻辑:无需维护格子的编号和对应关系,通过 Redis 的 SortedSet 天然支持按时间戳范围过滤和统计。
动态适配:无论 windowSeconds 是 120 秒还是其他值,逻辑无需调整,通用性更强。

漏桶限流算法

using StackExchange.Redis;using System;using System.Threading.Tasks;public class DistributedLeakyBucketRateLimiter{ private readonly IConnectionMultiplexer _redis; public DistributedLeakyBucketRateLimiter(IConnectionMultiplexer redis) { _redis = redis; } public async Task<bool> AllowRequestAsync(string resource, int maxRequests, int windowSeconds) { var db = _redis.GetDatabase(); var now = DateTime.UtcNow; // 资源键名,避免冲突 var key = $\"leaky_bucket:{resource}\"; // 使用 Lua 脚本保证原子操作 var luaScript = @\" -- 获取当前时间戳(毫秒) local now = tonumber(ARGV[1]) -- 解析参数 local maxRequests = tonumber(ARGV[2]) local windowSeconds = tonumber(ARGV[3]) -- 获取当前桶状态 local state = redis.call(\'HMGET\', KEYS[1], \'volume\', \'last_update\') local volume = tonumber(state[1]) local lastUpdate = tonumber(state[2]) -- 初始化桶状态(如果不存在) if not volume then volume = 0 lastUpdate = now end -- 计算漏出量 local elapsed = (now - lastUpdate) / 1000 -- 转换为秒 local leakRate = maxRequests / windowSeconds local leakedVolume = elapsed * leakRate -- 更新桶状态 volume = math.max(0, volume - leakedVolume) lastUpdate = now -- 检查是否可以处理请求 if volume < maxRequests then volume = volume + 1 -- 更新 Redis redis.call(\'HMSET\', KEYS[1],  \'volume\', volume,  \'last_update\', lastUpdate) -- 设置过期时间(窗口的2倍) redis.call(\'EXPIRE\', KEYS[1], windowSeconds * 2) return 1 -- 允许请求 else -- 更新最后更新时间(但不增加水量) redis.call(\'HSET\', KEYS[1], \'last_update\', lastUpdate) redis.call(\'EXPIRE\', KEYS[1], windowSeconds * 2) return 0 -- 拒绝请求 end \"; // 执行 Lua 脚本(原子操作) var result = (int)await db.ScriptEvaluateAsync( luaScript, new RedisKey[] { key }, new RedisValue[] { now.Ticks / TimeSpan.TicksPerMillisecond, // 当前时间戳(毫秒) maxRequests, windowSeconds }); return result == 1; }}

例如,假设资源名为\"api1\",则Redis中会有一个键为leaky_bucket:api1的Hash。其内容可能如下:
HGETALL leaky_bucket:api1

  1. “volume”
  2. “3”
  3. “last_update”
  4. “1650000000000”

令牌桶算法

using StackExchange.Redis;using System;using System.Threading.Tasks;public class DistributedTokenBucketRateLimiter{ private readonly IConnectionMultiplexer _redis; public DistributedTokenBucketRateLimiter(IConnectionMultiplexer redis) { _redis = redis; } public async Task<bool> AllowRequestAsync(string resource, int maxRequests, int windowSeconds) { var db = _redis.GetDatabase(); var now = DateTime.UtcNow; // 资源键名 var key = $\"token_bucket:{resource}\"; // 计算令牌生成速率(每秒) double refillRate = (double)maxRequests / windowSeconds; // Lua 脚本保证原子操作 var luaScript = @\" -- 获取当前时间戳(毫秒) local now = tonumber(ARGV[1]) -- 解析参数 local capacity = tonumber(ARGV[2]) local refillRate = tonumber(ARGV[3]) local requested = 1 -- 每次请求消耗1个令牌 -- 获取当前桶状态 local state = redis.call(\'HMGET\', KEYS[1], \'tokens\', \'last_refill\') local tokens = state[1] local lastRefill = state[2] -- 初始化桶状态(如果不存在) if not tokens then tokens = capacity lastRefill = now else tokens = tonumber(tokens) lastRefill = tonumber(lastRefill) end -- 计算需要补充的令牌数 local elapsed = (now - lastRefill) / 1000 -- 转换为秒 local refillAmount = elapsed * refillRate -- 补充令牌(不超过桶容量) if refillAmount > 0 then tokens = math.min(capacity, tokens + refillAmount) lastRefill = now end -- 检查是否有足够令牌 local allowed = false if tokens >= requested then tokens = tokens - requested allowed = true end -- 更新 Redis redis.call(\'HMSET\', KEYS[1], \'tokens\', tokens, \'last_refill\', lastRefill) -- 设置过期时间(窗口的2倍) redis.call(\'EXPIRE\', KEYS[1], ARGV[4]) return allowed and 1 or 0 \"; // 执行 Lua 脚本 var result = (int)await db.ScriptEvaluateAsync( luaScript, new RedisKey[] { key }, new RedisValue[] { now.Ticks / TimeSpan.TicksPerMillisecond, // 当前时间戳(毫秒) maxRequests, // 桶容量 refillRate, // 令牌补充速率 windowSeconds * 2 // 过期时间 }); return result == 1; }}

令牌桶 vs 漏桶 关键区别

特性令牌桶漏桶
数据结构存储可用令牌数 (tokens)存储当前水量 (volume)
请求处理消耗令牌 (令牌数-1)增加水量 (水量+1)
速率控制固定速率生成令牌固定速率漏水
突发流量允许突发(消耗积攒的令牌)严格平滑(固定出水速率)
典型应用场景API限流、网络流量控制流量整形、严格平滑控制
Redis更新逻辑先补充令牌再消耗先漏水再加水

限流组件

Sentinel(阿里巴巴开源)

核心功能:流量控制、熔断降级、系统自适应保护、热点参数限流。

特点:

无缝集成 Spring Boot 、.net Asp core等web框架,Spring Cloud、Dubbo 等微服务框架,支持动态规则配置49。

提供实时监控面板(Dashboard),可动态调整限流策略9。

适用于 API 网关、微服务接口限流等场景。

典型使用:通过 @SentinelResource 注解定义资源并配置 QPS 阈值。

Nginx 的限流能力

  1. 限流对象灵活
    全局限流:对整个域名(如 server_name)的所有接口生效79。

接口级限流:通过 location 匹配特定 URL 路径,仅对该路径下的请求限流17。

客户端级限流:按 IP($binary_remote_addr)限制单个客户端的请求速率或并发连接数310。

  1. 支持的限流类型
    请求频率限流(ngx_http_limit_req_module)
    基于漏桶算法,限制每秒/每分钟请求数(QPS),适用于防刷接口16。

nginx
http {
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; # 定义限流区域
server {
location /api/user { # 针对特定接口
limit_req zone=api_limit burst=20 nodelay; # 限流规则
}
}
}
并发连接数限流(ngx_http_limit_conn_module)
限制同一时刻的并发连接数,防止后端资源耗尽310。

nginx
http {
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
server {
location /api/order { # 针对订单接口
limit_conn conn_limit 5; # 单IP并发连接≤5
}
}
}
🎯 二、如何针对单个接口限流?
通过 location 路径匹配 实现接口级限流。以下是典型配置示例:

nginx
http {
# 定义限流规则:10MB 存储空间,每秒处理10个请求
limit_req_zone $binary_remote_addr zone=per_api:10m rate=10r/s;

server { location /api/payment { # 支付接口 limit_req zone=per_api burst=5 nodelay; # 突发流量缓冲5个请求 proxy_pass http://backend_service; # 转发到后端 } location /api/info { # 非关键信息接口 limit_req zone=per_api burst=10; # 无突发延迟 }}

}
关键参数:

burst:允许的突发请求队列大小(缓冲桶容量)26。

nodelay:立即处理突发请求(否则按速率延迟处理)27。

效果:

超过 rate + burst 的请求直接返回 503 错误(可自定义状态码)14。
⚙️ 三、高级场景配置

  1. 黑白名单过滤
    结合 deny 或 allow 拦截恶意 IP:

nginx
location /api/sensitive {
deny 192.168.1.100; # 封禁特定IP
allow 10.0.0.0/8; # 允许内网IP
deny all; # 其他全部拒绝
limit_req …; # 限流规则
}
:cite[7]
2. 后端服务保护(ngx_http_upstream_module)
限制转发到后端服务器的并发连接数:

nginx
upstream backend {
server 192.168.1.2:8080 max_conns=50; # 单台后端最大并发50
}
server {
location / {
proxy_pass http://backend;
}
}
:cite[9]