【.NET并发编程 - 18】限流与并发控制:保护系统的第一道防线
18. 限流与并发控制:保护系统的第一道防线
本章 GitHub 仓库:csharp-concurrency-cookbook ⭐
欢迎 Star 和 Fork!所有代码示例都可以在仓库中找到并运行。
🎯 本章导读
📌 本文目标:深入理解限流算法的工作原理,掌握 ASP.NET Core 限流中间件的实战应用,能够在实际项目中选择合适的限流方案。
不知道各位有没有经历过自己开发设计的系统,在线上被突如其来的流量洪峰给瞬间打崩?页面无法打开,所有请求全部无法响应,整个系统直接宕机,运营和开发急成一团乱麻,甚至半夜接到夺命连环CALL,被迫爬起来处理问题。
今天,我们就来聊聊如何用限流与并发控制,给你的系统装上一道坚实的防护墙。这不是什么高深的黑科技,而是每个后端开发者都应该掌握的基本功。
在前面的章节中,我们学习了异步编程(第 3-9 章)、并行处理(第 10 章)、锁机制(第 11 章)。这些技术让我们的代码跑得更快、更高效。但是,再快的代码,也架不住无限涌入的请求。
就像再强壮的人,也扛不住几十个人同时往他身上扑;再好的水管,也承受不了突然爆发的洪水。
限流,就是给你的系统装上"闸门"。
0️⃣ 故事开始:一次真实的生产事故
自己第一次遇见流量洪峰把系统打到崩溃,是十多年前。那时候参与一个话费充值系统的开发和维护,很多人在淘宝店铺上充话费,可能就会到这个充值系统里面。有一次做活动推广,优惠力度挺大,支付宝首页充值中心的话费充值入口,也会在活动开始后,切换到我们系统的接口。我们内部当然不敢怠慢,紧急提升了服务器硬件配置,平时3台服务器进行负载就能抗住,那次也扩容到了8台,也提前进行了数轮压测模拟。可是吧,我们远远低估了支付宝首页入口带来的恐怖流量,活动生效不到两分钟,我们的系统就直接被打崩了,毫无抵抗能力,脆弱的跟张纸一样。很多用户投诉,提交了半天没反应,也有的用户投诉说钱扣了,但是话费并没有到账......
事后,运维说那两分钟的流量,大概是我们压测时的服务器能承受的极限流量的70多倍。
复盘:问题到底出在哪里?
我们仔细分析了日志和监控数据,发现了几个致命问题:
问题 1:没有限制并发数
- 远超服务器处理极限的请求同时涌入,每个都要查数据库、改库存、创建订单,再掉用运营商接口进行充值......
- 数据库连接池设置的连接数瞬间被占满
- 后续请求全部等待,超时雪崩
问题 2:没有限制请求频率
- 分析日志,发现一些"黄牛"通过脚本,每秒发起上几百上千次请求来“薅羊毛”
- 正常用户的请求被挤掉了,服务器忙着处理机器人的请求
问题 3:没有资源隔离
- 活动接口和普通店铺的充值接口共享了同一套系统,同一个线程池、数据库连接池
- 活动的高流量把整个系统瞬间打垮,也影响到了其他正常店铺的运营。
问题 4:没有降级策略
- 系统过载时,没有快速拒绝部分请求
- 反而试图处理所有请求,结果全部超时
"我们只关注了代码本身的性能,却忽略了流量控制" ,事后的大会,当时的技术总监说了这样一句话。
这就是我们今天要讲的主题:限流与并发控制。
1️⃣ 为什么需要限流?三个血淋淋的教训
限流不是可选项,而是生产环境的必需品。
教训 1:系统资源是有限的
你的服务器配置再高,也有物理极限:
| 资源 | 典型上限 | 耗尽后果 |
|---|---|---|
| 线程池 | 数百到数千个线程 | 线程创建开销大,上下文切换频繁 |
| 数据库连接池 | 50-200 个连接 | 连接等待超时,大量请求失败 |
| 内存 | 取决于 GC 能力 | OOM 或 GC 压力过大导致 STW |
| CPU | 100% | 系统无响应,所有请求超时 |
| 网络带宽 | 取决于网卡 | 丢包,连接超时 |
案例:某社交平台上线新功能,用户疯狂刷新。没有限流保护,10 分钟内就把数据库连接池耗尽,整个平台宕机 2 小时。
教训 2:不限流 = 所有人都慢
假设你的服务器每秒最多处理 1000 个请求(QPS = 1000)。某天突然来了 10000 个并发请求:
场景 1:不限流
- 服务器尝试处理所有 10000 个请求
- 每个请求等待时间从 50ms 暴增到 5 秒
- 用户看到的是"转圈圈",然后全部超时
- 结果:10000 个请求,0 个成功
场景 2:限流
- 立即处理 1000 个请求(50ms 响应)
- 剩余 9000 个请求直接返回 429(Too Many Requests)
- 用户看到"请求过于频繁,请稍后再试"
- 结果:1000 个成功,9000 个友好提示
你会选哪个?答案显而易见。
教训 3:级联故障是连锁反应
微服务架构中,一个服务的故障会像多米诺骨牌一样传递:
用户 → API网关 → 订单服务 → 库存服务 → 数据库
(QPS 10000) (QPS 1000) (QPS 500) (最多 200 连接)
不限流的连锁反应:
- 10000 个请求打到订单服务
- 订单服务调用库存服务(只能处理 500 QPS)
- 库存服务被打爆,响应变慢
- 订单服务大量线程等待库存服务响应,线程池耗尽
- API 网关大量线程等待订单服务响应,也被耗尽
- 整个系统崩溃
限流后的保护:
- 库存服务限流 500 QPS,超出直接拒绝
- 订单服务快速失败,不会被拖死
- API 网关继续正常服务其他接口
这就是为什么我们说限流是"第一道防线"。
2️⃣ 限流算法深度解析:四种武器,各有千秋
现在我们来深入理解限流算法。注意,这里不是简单的代码搬运,而是要搞清楚为什么这么设计,在什么场景下会遇到问题。
2.1 固定窗口计数器(Fixed Window)—— 简单但有"临界突刺"问题
核心思想
想象一个计数器,每分钟重置一次:
窗口 1 (10:00:00 - 10:00:59):计数器 = 0
窗口 2 (10:01:00 - 10:01:59):计数器 = 0
...
- 每个请求到来,计数器 +1
- 如果计数器 ≤ 限额,通过请求
- 如果计数器 > 限额,拒绝请求
- 到达下一个时间窗口,计数器重置为 0
简单吧?但它有个致命缺陷...
临界突刺问题(Boundary Burst)
假设限制:每分钟最多 100 个请求。
看起来很安全对吧?但现实是残酷的:
时间轴:10:00:30 ───────> 10:01:00 ───────> 10:01:30
窗口 1 (10:00:00-10:00:59) | 窗口 2 (10:01:00-10:01:59)
----------------------------|---------------------------
10:00:30 来了 100 个请求 | 10:01:00 来了 100 个请求
(窗口内总共 100 个) ✅ | (窗口内总共 100 个) ✅
【问题】:在 10:00:30 到 10:01:30 这 1 分钟内,
实际通过了 200 个请求!(2倍于限制)
为什么会这样?
因为固定窗口只关心"当前窗口"内的计数,不关心"滑动的 1 分钟"内有多少请求。
10:00:30 的 100 个请求在窗口 1 的尾部,10:01:00 的 100 个请求在窗口 2 的头部。它们在不同窗口,但实际只相隔 30 秒!
实现代码
public class FixedWindowRateLimiter(int limit, TimeSpan window)
{
private int _count;
private DateTime _windowStart = DateTime.UtcNow;
private readonly int _limit = limit;
private readonly TimeSpan _window = window;
private readonly Lock _lock = new();
public bool TryAcquire()
{
lock (_lock)
{
var now = DateTime.UtcNow;
// 窗口过期,重置计数器
if (now - _windowStart >= _window)
{
_count = 0;
_windowStart = now;
}
// 检查是否超过限制
if (_count < _limit)
{
_count++;
return true;
}
return false;
}
}
public int GetCurrentCount()
{
lock (_lock)
{
var now = DateTime.UtcNow;
if (now - _windowStart >= _window)
{
return 0;
}
return _count;
}
}
public TimeSpan GetTimeUntilReset()
{
lock (_lock)
{
var now = DateTime.UtcNow;
var elapsed = now - _windowStart;
return elapsed >= _window ? TimeSpan.Zero : _window - elapsed;
}
}
}
优缺点分析
优点:
- ✅ 实现简单:只需要一个计数器和一个时间戳
- ✅ 内存占用低:O(1) 空间复杂度
- ✅ 性能高:锁竞争小,适合高并发
缺点:
- ❌ 临界突刺:窗口边界处可能出现2倍流量
- ❌ 不够精确:无法保证任意1分钟内的精确限流
适用场景:
- 对精度要求不高的场景
- 流量相对平稳的场景
- 需要极致性能的场景
2.2 滑动窗口(Sliding Window)—— 精确但内存开销大
为什么需要它?
固定窗口有"临界突刺"问题,那怎么解决?
答案:让窗口"滑动"起来!
不再用固定的时间边界(整点),而是以当前时间为基准,往前推一个窗口大小。
固定窗口:
10:00:00-10:00:59 | 10:01:00-10:01:59
↑ 边界固定,窗口跳跃式切换
滑动窗口:
10:00:30 时,窗口是 09:59:30-10:00:30
10:00:31 时,窗口是 09:59:31-10:00:31
↑ 窗口随时间滑动,无边界问题
工作原理
核心:记录每个请求的时间戳,计算时清理过期的时间戳。
假设限制:每分钟最多 100 个请求
时间戳队列:[10:00:10, 10:00:15, 10:00:50, 10:01:05, ...]
当前时间:10:01:30
↓
1. 删除 10:00:30 之前的时间戳(已超过1分钟)
2. 剩余时间戳数量 = 当前窗口内的请求数
3. 如果 < 100,允许请求并添加时间戳 10:01:30
4. 如果 ≥ 100,拒绝请求
实现代码
public class SlidingWindowRateLimiter(int limit, TimeSpan window)
{
private readonly Queue<DateTime> _timestamps = new();
private readonly int _limit = limit;
private readonly TimeSpan _window = window;
private readonly Lock _lock = new();
public bool TryAcquire()
{
lock (_lock)
{
var now = DateTime.UtcNow;
// 移除过期的时间戳
while (_timestamps.Count > 0 && now - _timestamps.Peek() >= _window)
{
_timestamps.Dequeue();
}
// 检查是否超过限制
if (_timestamps.Count < _limit)
{
_timestamps.Enqueue(now);
return true;
}
return false;
}
}
public int GetCurrentCount()
{
lock (_lock)
{
var now = DateTime.UtcNow;
// 移除过期的时间戳
while (_timestamps.Count > 0 && now - _timestamps.Peek() >= _window)
{
_timestamps.Dequeue();
}
return _timestamps.Count;
}
}
public DateTime? GetOldestTimestamp()
{
lock (_lock)
{
return _timestamps.Count > 0 ? _timestamps.Peek() : null;
}
}
}
优缺点分析
优点:
- ✅ 精确限流:任意时间点往前推1分钟,都严格遵守限额
- ✅ 无临界突刺:解决了固定窗口的边界问题
- ✅ 统计准确:可以精确知道当前窗口内的请求数
缺点:
- ❌ 内存占用高:需要存储所有请求的时间戳(O(N),N = 限额)
- ❌ 性能开销:每次请求都要遍历队列清理过期数据
- ❌ 锁竞争:高并发下锁等待时间长
内存分析:
限制 1000 QPS,窗口 1 分钟
→ 最多存储 60,000 个时间戳
→ 每个时间戳 8 字节(DateTime)
→ 约 480 KB 内存(单个限流器)
如果有 100 个不同的限流维度(如按用户ID),
那就是 48 MB 内存!
适用场景:
- 对限流精度要求极高的场景
- 流量不是特别大的场景(如 < 1000 QPS)
- 内存充足的场景
2.3 令牌桶(Token Bucket)—— 兼顾精确与突发,强烈推荐 ⭐
为什么令牌桶是"最优解"?
固定窗口不够精确,滑动窗口内存开销大。有没有既精确又高效的方案?
答案:令牌桶!
它的核心思想是:用"令牌"来控制流量,而不是直接计数请求。
工作原理
想象一个桶,里面装着令牌:
令牌生产者(固定速率)
↓
┌──────────────────┐
│ 🪙🪙🪙🪙🪙🪙🪙 │ ← 令牌桶(容量 100)
└──────────────────┘
↑
请求来了,拿走1个令牌
规则:
1. 桶以固定速率补充令牌(如每秒10个)
2. 桶有容量上限(如100个)
3. 请求到来时,尝试拿走1个令牌
- 有令牌 → 请求通过,令牌 -1
- 没令牌 → 请求被拒绝
令牌桶 vs 滑动窗口
关键区别:处理突发流量的能力
场景:限制每秒 10 个请求,系统空闲了 10 秒
滑动窗口:
- 10 秒后来了 50 个请求
- 只能通过 10 个(因为窗口内只允许 10 个)
- 剩余 40 个被拒绝
令牌桶(容量 100):
- 10 秒空闲,桶补充了 100 个令牌(已满)
- 50 个请求来了,全部通过!(消耗 50 个令牌)
- 桶中还剩 50 个令牌,可以继续处理突发
结论:令牌桶允许"积攒"令牌来应对突发流量!
这对于真实业务非常重要。比如:
- 凌晨 3 点系统空闲
- 早上 8 点突然大量用户登录
- 令牌桶可以用积攒的令牌平滑处理,而不是直接拒绝
实现代码
public class TokenBucketRateLimiter(int capacity, double refillRate)
{
private double _tokens = capacity;
private DateTime _lastRefill = DateTime.UtcNow;
private readonly int _capacity = capacity;
private readonly double _refillRate = refillRate; // 每秒添加的令牌数
private readonly Lock _lock = new();
public bool TryAcquire(int tokensRequired = 1)
{
lock (_lock)
{
Refill();
if (_tokens >= tokensRequired)
{
_tokens -= tokensRequired;
return true;
}
return false;
}
}
private void Refill()
{
var now = DateTime.UtcNow;
var elapsed = (now - _lastRefill).TotalSeconds;
// 补充令牌(不超过容量)
_tokens = Math.Min(_capacity, _tokens + elapsed * _refillRate);
_lastRefill = now;
}
public double GetAvailableTokens()
{
lock (_lock)
{
Refill();
return _tokens;
}
}
public TimeSpan GetTimeUntilNextToken()
{
lock (_lock)
{
Refill();
if (_tokens >= 1)
{
return TimeSpan.Zero;
}
var tokensNeeded = 1 - _tokens;
var secondsNeeded = tokensNeeded / _refillRate;
return TimeSpan.FromSeconds(secondsNeeded);
}
}
}
优缺点分析
优点:
- ✅ 允许突发流量:桶容量 = 最大突发量
- ✅ 内存占用低:只需存储令牌数和时间戳(O(1))
- ✅ 性能高:简单的数学计算,无需遍历
- ✅ 灵活:可以支持不同大小的请求(如消耗多个令牌)
缺点:
- ⚠️ 需要精确的时间计算(但这不是大问题)
适用场景:
- 绝大多数场景(通用首选)
- 需要允许合理突发流量的场景
- 高并发场景
- 微服务限流
参数调优建议
refillRate(补充速率)= 平均 QPS
capacity(桶容量)= refillRate * 允许突发时长(秒)
示例:
平均 QPS = 100
允许 5 秒突发 → capacity = 100 * 5 = 500
这意味着:
- 正常情况:每秒通过 100 个请求
- 短暂空闲后:可以处理最多 500 个突发请求
2.4 漏桶(Leaky Bucket)—— 强制平滑输出
核心思想
令牌桶允许突发,但有些场景不希望突发,要求绝对平滑的输出。
这就是漏桶的设计初衷。
令牌桶:输入有弹性,允许突发
漏桶: 输入有弹性,但输出必须平滑
请求涌入(可能突发)
↓
┌──────────────┐
│ 🚰 💧💧💧💧 │ ← 漏桶(有容量限制)
│ │
└──────┬───────┘
│ 恒定速率"漏出"
↓
处理请求(速率固定)
漏桶 vs 令牌桶的本质区别
令牌桶:
- 令牌以恒定速率补充
- 请求以实际到达速率处理(只要有令牌)
→ 输入和输出都可能突发
漏桶:
- 请求进入队列(桶)
- 以恒定速率从队列取出处理
→ 输出速率绝对平滑
示例:
令牌桶(容量 100,速率 10/秒):
空闲 10 秒后,突然来 50 个请求
→ 50 个请求瞬间处理完(有 100 个令牌)
漏桶(容量 100,速率 10/秒):
空闲 10 秒后,突然来 50 个请求
→ 50 个请求进入桶,但仍以 10/秒速率处理
→ 需要 5 秒才能处理完
实现代码
public class LeakyBucketRateLimiter(int capacity, TimeSpan leakInterval)
{
private readonly Queue<DateTime> _queue = new();
private readonly int _capacity = capacity;
private readonly TimeSpan _leakInterval = leakInterval; // 漏出间隔
private DateTime _lastLeak = DateTime.UtcNow;
private readonly Lock _lock = new();
public bool TryAcquire()
{
lock (_lock)
{
Leak();
if (_queue.Count < _capacity)
{
_queue.Enqueue(DateTime.UtcNow);
return true;
}
return false;
}
}
private void Leak()
{
var now = DateTime.UtcNow;
var elapsed = now - _lastLeak;
// 计算应该漏出多少个请求
var leakCount = (int)(elapsed / _leakInterval);
for (int i = 0; i < leakCount && _queue.Count > 0; i++)
{
_queue.Dequeue();
}
if (leakCount > 0)
{
_lastLeak = now;
}
}
public int GetCurrentCount()
{
lock (_lock)
{
Leak();
return _queue.Count;
}
}
public int GetAvailableSlots()
{
lock (_lock)
{
Leak();
return _capacity - _queue.Count;
}
}
}
优缺点分析
优点:
- ✅ 输出速率绝对平滑:保护下游系统不被突发流量冲击
- ✅ 适合流量整形:将不规则流量整形为平滑流量
缺点:
- ❌ 无法应对突发流量:即使系统空闲,也不能加速处理
- ❌ 队列内存开销:需要存储请求(类似滑动窗口)
- ❌ 延迟增加:请求可能在队列中等待
适用场景:
- 需要保护脆弱的下游系统(如老旧数据库)
- 对外提供 API 时,需要严格控制输出速率
- 流量整形场景
漏桶的真实使用案例
案例:调用第三方支付API
第三方限制:每秒最多 10 个请求,超过会封 IP
我们的流量:突发性很强,可能 1 秒内来 100 个支付请求
解决方案:
- 使用漏桶(容量 50,速率 10/秒)
- 100 个请求涌入时,50 个进入桶,50 个快速失败
- 桶中的 50 个以 10/秒速率调用第三方API
- 保证不会触发第三方的封禁规则
2.5 四种算法对比与选择指南
综合对比表
| 算法 | 突发流量 | 内存占用 | 实现复杂度 | 并发性能 | 精确度 | 分布式友好 |
|---|---|---|---|---|---|---|
| 固定窗口 | ❌ 差(临界突刺) | ✅ 低(O(1)) | ✅ 简单 | ✅ 高 | ❌ 低 | ✅ 易实现 |
| 滑动窗口 | ✅ 好 | ❌ 高(O(N)) | ⚠️ 中等 | ⚠️ 中 | ✅ 高 | ⚠️ 较复杂 |
| 令牌桶 | ✅ 好(可控) | ✅ 低(O(1)) | ⚠️ 中等 | ✅ 高 | ✅ 高 | ✅ 易实现 |
| 漏桶 | ❌ 差(强制平滑) | ⚠️ 中(O(K)) | ⚠️ 中等 | ⚠️ 中 | ✅ 高 | ⚠️ 较复杂 |
内存说明:N = 限流阈值,K = 队列容量
本想做一下四种算法的性能差异对比,但是时间有限,后面补上吧😑
决策树:如何选择限流算法
需要限流吗?
│
├─ 是 → 需要精确到毫秒级吗?
│ │
│ ├─ 是 → 流量 < 1000 QPS 且内存充足?
│ │ │
│ │ ├─ 是 → 【滑动窗口】
│ │ └─ 否 → 【令牌桶】(推荐)
│ │
│ └─ 否 → 需要保护脆弱下游系统?
│ │
│ ├─ 是 → 【漏桶】
│ └─ 否 → 允许短暂突发?
│ │
│ ├─ 是 → 【令牌桶】(推荐)
│ └─ 否 → 实现要求极简?
│ │
│ ├─ 是 → 【固定窗口】
│ └─ 否 → 【令牌桶】(推荐)
│
└─ 否 → 只需限制并发数?
│
├─ 是 → 【SemaphoreSlim】
└─ 否 → 不需要限流
实战场景匹配建议
场景 1:API 网关
需求:保护后端服务,允许合理突发
推荐:令牌桶 ⭐⭐⭐
理由:桶容量可以吸收短期突发,长期流量平滑
配置:capacity = 平均QPS × 5秒, refillRate = 平均QPS
场景 2:登录接口防暴力破解
需求:按 IP 限制,每分钟最多 5 次
推荐:固定窗口 ⭐⭐
理由:实现简单,临界突刺影响小(最多10次)
配置:window = 1分钟, limit = 5
场景 3:秒杀系统
需求:精确控制库存,不能超卖
推荐:滑动窗口 + 分布式锁 ⭐⭐⭐
理由:精确统计,避免边界问题
配置:window = 1秒, limit = 库存数 / 预计时长
场景 4:调用第三方 API
需求:第三方限制 100 QPS,超过会封 IP
推荐:漏桶 ⭐⭐⭐
理由:绝对平滑输出,保证不触发限制
配置:capacity = 100, leakRate = 100/秒
场景 5:报表生成接口
需求:CPU 密集型,限制并发数为 5
推荐:SemaphoreSlim 或并发限流器 ⭐⭐⭐
理由:控制并发,避免 CPU 100%
配置:permitLimit = 5
场景 6:微服务间调用
需求:多实例部署,需要全局限流
推荐:Redis + 令牌桶(分布式) ⭐⭐⭐
理由:跨实例共享限流状态
实现:见下文分布式限流章节
常见误区
误区 1:固定窗口够用了,为什么要用令牌桶?
反例:某电商促销活动,限制 1000/分钟
- 用固定窗口
- 用户在 10:00:59 秒发起 1000 个请求(通过)
- 用户在 10:01:00 秒再发起 1000 个请求(通过)
- 实际 1 秒内处理 2000 个请求 → 服务器崩溃
正解:用令牌桶或滑动窗口
误区 2:令牌桶和漏桶是一回事
错误!它们的输出特性完全不同:
令牌桶:允许突发输出
- 空闲 10 秒后来 100 个请求 → 瞬间处理完(有积攒的令牌)
漏桶:强制平滑输出
- 空闲 10 秒后来 100 个请求 → 仍以固定速率慢慢处理
选择依据:你的下游系统能承受突发吗?
- 能承受 → 令牌桶(性能更好)
- 不能承受 → 漏桶(保护下游)
误区 3:滑动窗口总是最好的
不一定!内存和性能代价:
限制 10000 QPS,窗口 1 分钟
→ 需要存储 600,000 个时间戳
→ 约 4.8 MB 内存(单个限流器)
如果有 1000 个用户(按用户限流)
→ 4.8 GB 内存!
而令牌桶只需要 2 个变量(tokens + lastRefill)
→ 16 字节 × 1000 用户 = 16 KB
结论:除非必须极度精确,否则令牌桶更优
选择总结(快速参考)
| 你的需求 | 推荐算法 | 原因 |
|---|---|---|
| 不知道选啥 | 令牌桶 ⭐⭐⭐ | 万金油,适合95%场景 |
| 实现要简单 | 固定窗口 | 代码最短,但有边界问题 |
| 必须100%精确 | 滑动窗口 | 内存换精确度 |
| 保护下游 | 漏桶 | 绝对平滑输出 |
| 只限并发数 | SemaphoreSlim | 不限速率,只限并发 |
| 分布式部署 | Redis + 令牌桶 | 跨实例共享状态 |
3️⃣ 使用 SemaphoreSlim 限制并发数
在讲解 ASP.NET Core 限流中间件之前,我们先回顾一个简单但强大的工具:SemaphoreSlim。
3.1 SemaphoreSlim 的并发控制
在第 11 章《锁机制完全指南》中,我们详细讲解过 SemaphoreSlim,它可以限制同时访问资源的线程数。
场景:限制同时访问数据库的连接数为 10。
public class DatabaseService(ILogger<DatabaseService> logger)
{
private readonly SemaphoreSlim _connectionLimit = new(10); // 最多 10 个并发连接
private readonly ILogger<DatabaseService> _logger = logger;
private int _activeConnections;
public async Task<string> QueryAsync(string sql, CancellationToken cancellationToken = default)
{
_logger.LogInformation($"等待数据库连接槽位,当前活跃连接数: {_activeConnections}");
// 获取连接槽位
await _connectionLimit.WaitAsync(cancellationToken);
try
{
Interlocked.Increment(ref _activeConnections);
_logger.LogInformation($"获得数据库连接,当前活跃连接数: {_activeConnections}");
// 模拟数据库查询
await Task.Delay(1000, cancellationToken);
return $"查询结果:{sql}";
}
finally
{
Interlocked.Decrement(ref _activeConnections);
_connectionLimit.Release();
_logger.LogInformation($"释放数据库连接,当前活跃连接数: {_activeConnections}");
}
}
}
测试代码:
var dbService = new DatabaseService();
// 同时发起 100 个请求
var tasks = Enumerable.Range(1, 100)
.Select(i => Task.Run(async () =>
{
Console.WriteLine($"请求 {i} 开始 - {DateTime.Now:HH:mm:ss.fff}");
await dbService.QueryAsync($"SELECT * FROM users WHERE id = {i}");
Console.WriteLine($"请求 {i} 完成 - {DateTime.Now:HH:mm:ss.fff}");
}))
.ToArray();
await Task.WhenAll(tasks);
运行结果:
请求 1 开始 - 10:00:00.000
请求 2 开始 - 10:00:00.001
...
请求 10 开始 - 10:00:00.009 ← 前 10 个立即开始
请求 11 开始 - 10:00:00.110 ← 第 11 个等待第 1 个完成
请求 12 开始 - 10:00:00.111
...
3.2 SemaphoreSlim 的优缺点
优点:
- ✅ 简单易用
- ✅ 异步友好(
WaitAsync) - ✅ 精确控制并发数
缺点:
- ❌ 只能限制并发数,不能限制速率(QPS)
- ❌ 无法按用户、IP 等维度限流
- ❌ 单机限流,分布式部署时失效
适用场景:
- 限制数据库连接数
- 限制外部 API 调用并发数
- 限制 CPU 密集型任务并发数
3.3 SemaphoreSlim vs ASP.NET Core 限流中间件
很多开发者会问:"我用 SemaphoreSlim 不就够了吗?为什么还要用限流中间件?"
让我们来对比一下:
| 特性 | SemaphoreSlim | ASP.NET Core 限流中间件 |
|---|---|---|
| 限制维度 | 并发数 | 并发数 + 速率(QPS/QPM) |
| 按用户限流 | ❌ 需要自己维护字典 | ✅ 内置 PartitionedRateLimiter |
| 按 IP 限流 | ❌ 需要自己实现 | ✅ 直接支持 |
| 队列排队 | ✅ WaitAsync |
✅ QueueLimit |
| 自定义拒绝响应 | ❌ 需要手动实现 | ✅ OnRejected 回调 |
| 统计监控 | ❌ 需要自己记录 | ✅ 内置 Metrics |
| 配置灵活性 | ❌ 硬编码 | ✅ 配置文件 / 动态调整 |
| 分布式支持 | ❌ | ⚠️ 需要自定义(Redis) |
使用建议:
场景 1:单个资源的并发控制(如数据库连接)
→ 用 SemaphoreSlim(简单直接)
场景 2:HTTP 接口的速率限制(QPS)
→ 用 ASP.NET Core 限流中间件(更强大)
场景 3:按用户 / IP 维度限流
→ 用 ASP.NET Core 限流中间件(内置支持)
场景 4:分布式部署的全局限流
→ 用 Redis + 自定义限流器(见下文)
代码对比:
// 方式 1:SemaphoreSlim(适合单资源并发控制)
private readonly SemaphoreSlim _limiter = new(10);
[HttpGet("resource")]
public async Task<IActionResult> GetResource()
{
await _limiter.WaitAsync();
try
{
// 业务逻辑
return Ok("数据");
}
finally
{
_limiter.Release();
}
}
// 方式 2:ASP.NET Core 中间件(适合接口速率限制)
[HttpGet("resource")]
[EnableRateLimiting("concurrency")] // ← 一行搞定
public async Task<IActionResult> GetResource()
{
// 业务逻辑
return Ok("数据");
}
可以看到,对于 HTTP 接口限流,中间件更简洁优雅。但对于内部资源(如数据库连接池),SemaphoreSlim 更直接。
它们不是互斥的,可以组合使用:
// 组合示例:报表生成接口
[HttpPost("report")]
[EnableRateLimiting("report")] // ← 限制速率:每分钟最多 10 个请求
public async Task<IActionResult> GenerateReport()
{
// ↓ 限制并发:最多 5 个同时生成
await _reportGenerationLimiter.WaitAsync();
try
{
var result = await _reportService.GenerateAsync();
return Ok(result);
}
finally
{
_reportGenerationLimiter.Release();
}
}
这样就实现了"双重保护":
- 中间件限制请求速率(防止瞬时大量请求)
SemaphoreSlim限制并发数(防止 CPU 爆炸)
4️⃣ ASP.NET Core 内置限流中间件
从 ASP.NET Core 7.0 开始,微软提供了内置的限流中间件 Microsoft.AspNetCore.RateLimiting,支持多种限流算法。
4.1 基础用法
1. 添加服务:
builder.Services.AddRateLimiter(options =>
{
// 添加固定窗口限流策略
options.AddFixedWindowLimiter("fixed", limiterOptions =>
{
limiterOptions.Window = TimeSpan.FromSeconds(10); // 窗口大小
limiterOptions.PermitLimit = 100; // 窗口内最多 100 个请求
limiterOptions.QueueLimit = 10; // 队列最多 10 个请求
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
});
2. 启用中间件:
app.UseRateLimiter(); // 必须在 UseRouting 之后
3. 应用到端点:
app.MapGet("/api/data", () => "Success")
.RequireRateLimiting("fixed");
测试:
// 10 秒内发起 110 个请求
for (int i = 1; i <= 110; i++)
{
var response = await httpClient.GetAsync("/api/data");
Console.WriteLine($"请求 {i}: {response.StatusCode}");
}
结果:
请求 1: OK
请求 2: OK
...
请求 100: OK
请求 101: OK ← 进入队列
...
请求 110: OK ← 进入队列
请求 111: TooManyRequests (429) ← 队列满了
4.2 四种内置限流算法
1. 固定窗口限流(Fixed Window)
options.AddFixedWindowLimiter("fixed", limiterOptions =>
{
limiterOptions.Window = TimeSpan.FromSeconds(10);
limiterOptions.PermitLimit = 100;
});
2. 滑动窗口限流(Sliding Window)
options.AddSlidingWindowLimiter("sliding", limiterOptions =>
{
limiterOptions.Window = TimeSpan.FromSeconds(10);
limiterOptions.PermitLimit = 100;
limiterOptions.SegmentsPerWindow = 10; // 将窗口分为 10 段
});
- 将窗口分为多个段,更平滑地限流
SegmentsPerWindow = 10表示每秒一个段
3. 令牌桶限流(Token Bucket)
options.AddTokenBucketLimiter("token", limiterOptions =>
{
limiterOptions.TokenLimit = 100; // 桶容量
limiterOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(1); // 补充周期
limiterOptions.TokensPerPeriod = 10; // 每周期补充 10 个令牌
limiterOptions.AutoReplenishment = true; // 自动补充
});
4. 并发限流(Concurrency)
options.AddConcurrencyLimiter("concurrency", limiterOptions =>
{
limiterOptions.PermitLimit = 10; // 最多 10 个并发请求
limiterOptions.QueueLimit = 5; // 队列最多 5 个
});
- 等价于
SemaphoreSlim - 请求完成后自动释放槽位
4.3 按 IP 限流(自定义分区键)
场景:不同 IP 独立限流,防止单个 IP 占用所有资源。
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("perIp", httpContext =>
{
var ipAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: ipAddress,
factory: _ => new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(1),
PermitLimit = 10 // 每个 IP 每分钟 10 次
});
});
});
应用:
app.MapGet("/api/limited", () => "Success")
.RequireRateLimiting("perIp");
效果:
- IP
192.168.1.100每分钟最多 10 次请求 - IP
192.168.1.101每分钟最多 10 次请求 - 互不影响
4.4 按用户限流(不同等级不同限额)
场景:VIP 用户每分钟 1000 次请求,普通用户每分钟 100 次。
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("perUser", httpContext =>
{
var userId = httpContext.User.Identity?.Name ?? "anonymous";
var isVip = httpContext.User.IsInRole("VIP");
return RateLimitPartition.GetTokenBucketLimiter(
partitionKey: userId,
factory: _ => new TokenBucketRateLimiterOptions
{
TokenLimit = isVip ? 1000 : 100,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
TokensPerPeriod = isVip ? 1000 : 100,
AutoReplenishment = true
});
});
});
4.5 全局限流 vs 端点限流
全局限流:应用到所有端点
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: "global",
factory: _ => new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromSeconds(10),
PermitLimit = 1000 // 全局每 10 秒 1000 次
});
});
});
端点限流:只应用到特定端点
app.MapGet("/api/heavy", () => "Heavy task")
.RequireRateLimiting("token");
app.MapGet("/api/light", () => "Light task"); // 不限流
禁用限流:
app.MapGet("/health", () => "OK")
.DisableRateLimiting(); // 健康检查端点不限流
4.6 自定义限流响应
默认响应:HTTP 429 (Too Many Requests)
自定义响应:
builder.Services.AddRateLimiter(options =>
{
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
// 获取重试时间
var retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfterValue)
? retryAfterValue.TotalSeconds
: null;
await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "请求过于频繁,请稍后再试",
retryAfter = retryAfter
}, cancellationToken);
};
});
响应示例:
{
"error": "请求过于频繁,请稍后再试",
"retryAfter": 5.2
}
5️⃣ 分布式限流(Redis 实现)
5.1 单机限流的致命缺陷
前面讲的所有限流算法(固定窗口、滑动窗口、令牌桶、漏桶)都有一个共同的问题:它们都是单机限流。
这在单体应用时代没问题,但在微服务和云原生时代,几乎没有服务是单实例部署的。
看个例子:
场景:限制某个 API 每秒最多 1000 次请求
单实例部署:
┌────────────┐
│ 实例 1 │ ← 限流器:1000 QPS
└────────────┘
✅ 实际效果:1000 QPS
3 实例部署(负载均衡):
┌─────────────┐
用户 ──►│ 负载均衡器 │
└─────────────┘
│
┌────────┼────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ 实例 1 │ │ 实例 2 │ │ 实例 3 │
└────────┘ └────────┘ └────────┘
1000 QPS 1000 QPS 1000 QPS
❌ 实际效果:3000 QPS(限流失效!)
问题根源:每个实例的限流器是独立的,它们之间不共享状态。
后果:
- 限流阈值失控:预期 1000 QPS,实际可能是 N × 1000 QPS(N = 实例数)
- 扩容时更危险:新增实例反而让保护失效
- 无法实现全局限流策略(如"所有用户共享 10000 QPS")
5.2 分布式限流的核心思想
要实现分布式限流,核心是:让所有实例共享同一个限流状态。
方案 1:中心化存储(推荐)
┌─────────────┐
用户 ──►│ 负载均衡器 │
└─────────────┘
│
┌────────┼────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ 实例 1 │ │ 实例 2 │ │ 实例 3 │
└───┬────┘ └───┬────┘ └───┬────┘
└─────┬────┴────┬─────┘
▼ ▼
┌───────────────┐
│ Redis │ ← 共享限流状态
│ (计数器/令牌) │
└───────────────┘
✅ 效果:全局 1000 QPS,真正的分布式限流
方案 2:Gossip 协议(复杂)
- 实例间互相通信同步状态
- 实现复杂,延迟高
- 不推荐(除非不能用 Redis)
为什么选 Redis?
- ✅ 原子操作:Lua 脚本保证限流逻辑的原子性
- ✅ 高性能:内存操作,延迟通常 < 1ms
- ✅ 自动过期:通过 TTL 自动清理过期数据
- ✅ 高可用:Redis Cluster / Sentinel 保证可用性
5.3 Redis + 令牌桶:分布式限流的最佳实践
为什么用 Lua 脚本?
Redis 的限流操作需要多个步骤:
- 读取当前令牌数
- 计算补充令牌
- 判断是否足够
- 扣减令牌并更新时间
如果用普通命令,这些步骤不是原子的:
// ❌ 错误示例:非原子操作
var tokens = await db.StringGetAsync("tokens");
var lastRefill = await db.StringGetAsync("last_refill");
// ⚠️ 问题:两个请求同时执行到这里
// 都认为有足够的令牌,导致超发
if (tokens >= 1)
{
await db.StringDecrementAsync("tokens"); // 两个都扣了
return true; // 都通过了(超限!)
}
解决方案:Lua 脚本
Lua 脚本在 Redis 服务器端执行,保证原子性:
-- 整个脚本作为一个原子操作执行
local tokens = redis.call('GET', 'tokens')
if tokens >= 1 then
redis.call('DECR', 'tokens')
return 1
else
return 0
end
完整的代码:
using StackExchange.Redis;
namespace RateLimiting.DistributedLimiters;
/// <summary>
/// 基于 Redis 的分布式令牌桶限流器
/// 适用于多实例部署的场景
/// </summary>
public class RedisTokenBucketRateLimiter(IConnectionMultiplexer redis, int capacity, double refillRate)
{
private readonly IConnectionMultiplexer _redis = redis;
private readonly int _capacity = capacity;
private readonly double _refillRate = refillRate;
/// <summary>
/// 尝试获取令牌
/// </summary>
/// <param name="key">限流的键(如用户ID、IP地址)</param>
/// <param name="tokensRequired">需要的令牌数(默认1)</param>
/// <returns>是否获取成功</returns>
public async Task<bool> TryAcquireAsync(string key, int tokensRequired = 1)
{
var db = _redis.GetDatabase();
// 使用 Lua 脚本确保原子性
var script = @"
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local tokens_required = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
-- 获取当前令牌数和上次补充时间
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
-- 计算需要补充的令牌
local elapsed = now - last_refill
tokens = math.min(capacity, tokens + elapsed * refill_rate)
-- 尝试消费令牌
if tokens >= tokens_required then
tokens = tokens - tokens_required
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 60) -- 设置过期时间
return 1 -- 成功
else
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 60)
return 0 -- 失败
end
";
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
var result = await db.ScriptEvaluateAsync(
script,
keys: [key],
values: [_capacity, _refillRate, tokensRequired, now]
);
return (int)result == 1;
}
/// <summary>
/// 获取当前可用令牌数
/// </summary>
public async Task<double> GetAvailableTokensAsync(string key)
{
var db = _redis.GetDatabase();
var bucket = await db.HashGetAllAsync(key);
if (bucket.Length == 0)
{
return _capacity;
}
var tokens = double.Parse(bucket.FirstOrDefault(h => h.Name == "tokens").Value);
var lastRefill = double.Parse(bucket.FirstOrDefault(h => h.Name == "last_refill").Value);
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
var elapsed = now - lastRefill;
return Math.Min(_capacity, tokens + elapsed * _refillRate);
}
}
配置和使用
1. 注册 Redis 和限流器:
// Program.cs
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
{
var config = ConfigurationOptions.Parse("localhost:6379");
config.AbortOnConnectFail = false; // 连接失败不抛异常
config.ConnectTimeout = 5000; // 连接超时 5 秒
return ConnectionMultiplexer.Connect(config);
});
builder.Services.AddSingleton(sp =>
{
var redis = sp.GetRequiredService<IConnectionMultiplexer>();
// 容量 100,每秒补充 10 个令牌
return new RedisTokenBucketRateLimiter(redis, capacity: 100, refillRate: 10);
});
2. 在 Controller 中使用:
[ApiController]
[Route("api/[controller]")]
public class ApiGatewayController : ControllerBase
{
private readonly RedisTokenBucketRateLimiter _limiter;
public ApiGatewayController(RedisTokenBucketRateLimiter limiter)
{
_limiter = limiter;
}
[HttpGet("protected")]
public async Task<IActionResult> GetProtectedData()
{
// 按用户 ID 限流
var userId = User.Identity?.Name ?? "anonymous";
var key = $"ratelimit:user:{userId}";
if (!await _limiter.TryAcquireAsync(key))
{
return StatusCode(429, new
{
error = "请求过于频繁,请稍后再试",
retryAfter = 10
});
}
// 业务逻辑
return Ok(new { message = "Success", data = "..." });
}
}
3. 效果验证:
部署 3 个实例,每个实例配置相同的 Redis 限流器
测试:同时发起 200 个请求
结果:
- 前 100 个请求通过(令牌桶容量)
- 后 100 个请求被拒绝(429)
- ✅ 全局限流生效,不会因为实例数量导致超限
5.4 Redis + 滑动窗口:精确的分布式限流
对于需要极高精确度的场景,我们也可以用 Redis 实现分布式滑动窗口:
using StackExchange.Redis;
namespace RateLimiting.DistributedLimiters;
/// <summary>
/// 基于 Redis 的分布式滑动窗口限流器
/// 使用 Sorted Set 实现
/// </summary>
public class RedisSlidingWindowRateLimiter(IConnectionMultiplexer redis, int limit, TimeSpan window)
{
private readonly IConnectionMultiplexer _redis = redis;
private readonly int _limit = limit;
private readonly TimeSpan _window = window;
/// <summary>
/// 尝试获取配额
/// </summary>
public async Task<bool> TryAcquireAsync(string key)
{
var db = _redis.GetDatabase();
// 使用 Lua 脚本确保原子性
var script = @"
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- 移除过期的时间戳
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 获取当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count < limit then
-- 添加当前时间戳
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window)
return 1
else
return 0
end
";
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var windowMs = (long)_window.TotalMilliseconds;
var result = await db.ScriptEvaluateAsync(
script,
keys: [key],
values: [windowMs, _limit, now]
);
return (int)result == 1;
}
/// <summary>
/// 获取当前窗口内的请求数
/// </summary>
public async Task<long> GetCurrentCountAsync(string key)
{
var db = _redis.GetDatabase();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var windowMs = (long)_window.TotalMilliseconds;
// 先清理过期数据
await db.SortedSetRemoveRangeByScoreAsync(key, 0, now - windowMs);
// 返回当前数量
return await db.SortedSetLengthAsync(key);
}
}
使用示例:
builder.Services.AddSingleton(sp =>
{
var redis = sp.GetRequiredService<IConnectionMultiplexer>();
// 每分钟最多 100 个请求
return new RedisSlidingWindowRateLimiter(redis, limit: 100, windowSize: TimeSpan.FromMinutes(1));
});
滑动窗口 vs 令牌桶(Redis):
| 特性 | Redis 令牌桶 | Redis 滑动窗口 |
|---|---|---|
| 精确度 | 高 | 极高 |
| 内存占用 | 低(2个字段) | 高(N个时间戳) |
| Redis负载 | 低 | 中(需要清理过期数据) |
| 突发处理 | ✅ 允许 | ❌ 不允许 |
| 推荐场景 | 大多数场景 | 防刷(如登录、验证码) |
5.5 分布式限流的性能优化
优化 1:本地缓存 + Redis(两级限流)
每次请求都查 Redis 会增加延迟。可以结合本地限流:
public class HybridRateLimiter
{
private readonly TokenBucketRateLimiter _localLimiter; // 本地限流器
private readonly RedisTokenBucketRateLimiter _redisLimiter; // 分布式限流器
public async Task<bool> TryAcquireAsync(string key)
{
// 先本地限流(快速失败)
if (!_localLimiter.TryAcquire())
return false;
// 再 Redis 限流(全局保护)
if (!await _redisLimiter.TryAcquireAsync(key))
return false;
return true;
}
}
效果:
- 本地限流器挡住大部分请求(无网络开销)
- Redis 限流器提供全局保护
- 延迟降低 90%+
优化 2:Redis 管道(Pipeline)
如果需要批量检查多个key:
public async Task<Dictionary<string, bool>> TryAcquireBatchAsync(string[] keys)
{
var db = _redis.GetDatabase();
var batch = db.CreateBatch();
var tasks = new Dictionary<string, Task<RedisResult>>();
foreach (var key in keys)
{
tasks[key] = batch.ScriptEvaluateAsync(...); // Lua脚本
}
batch.Execute();
await Task.WhenAll(tasks.Values);
return tasks.ToDictionary(
kvp => kvp.Key,
kvp => (int)kvp.Value.Result == 1
);
}
优化 3:降级策略
Redis 不可用时的降级方案:
public async Task<bool> TryAcquireAsync(string key)
{
try
{
return await _redisLimiter.TryAcquireAsync(key);
}
catch (RedisException)
{
// Redis 故障时,降级到本地限流
_logger.LogWarning("Redis 不可用,降级到本地限流");
return _localLimiter.TryAcquire();
}
}
5.6 分布式限流的最佳实践
1. Key 的设计原则
// ✅ 好的 key 设计
$"ratelimit:api:user:{userId}" // 按用户限流
$"ratelimit:api:ip:{ipAddress}" // 按 IP 限流
$"ratelimit:api:endpoint:{endpoint}:user:{userId}" // 多维度限流
// ❌ 不好的 key 设计
$"user:{userId}" // 太短,可能冲突
$"ratelimit:{DateTime.Now.Ticks}" // 动态生成,无法复用
2. 设置合理的过期时间
// 令牌桶:过期时间 = 窗口大小 * 2
redis.call('EXPIRE', key, window_size * 2)
// 滑动窗口:过期时间 = 窗口大小 + 缓冲
redis.call('EXPIRE', key, window_size + 10)
3. 监控 Redis 性能
// 记录 Redis 延迟
var sw = Stopwatch.StartNew();
await _redisLimiter.TryAcquireAsync(key);
sw.Stop();
if (sw.ElapsedMilliseconds > 10) // 超过 10ms
{
_logger.LogWarning($"Redis 限流延迟过高: {sw.ElapsedMilliseconds}ms");
}
4. 高可用部署
推荐架构:
- Redis Sentinel(主从 + 自动故障转移)
- 或 Redis Cluster(分片 + 高可用)
- 配置降级策略(Redis 故障时的备用方案)
6️⃣ 限流参数调优指南:如何确定合适的阈值?
这是一个非常实际的问题:令牌桶的容量设置为 100 还是 1000?固定窗口的限额设置为多少?这些参数不是拍脑袋决定的,而是需要综合考虑多个因素。
6.1 参数调优的核心原则
黄金法则:限流参数应该略高于系统正常承载能力,但低于系统崩溃临界点。
系统压力分层:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
崩溃点 ↑ (系统挂掉)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
限流阈值 ↑ (开始拒绝请求)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
正常峰值 ↑ (业务高峰)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
平均负载 ↑ (日常流量)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
理想的限流阈值 = 正常峰值 × 1.2 ~ 1.5
为什么是 1.2 ~ 1.5 倍?
- 太低(如 1.0 倍):正常高峰期也会频繁触发限流,用户体验差
- 太高(如 3.0 倍):失去保护作用,异常流量仍会打垮系统
- 1.2 ~ 1.5 倍:既能应对正常波动,又能拦截异常流量
6.2 影响限流参数的关键因素
因素 1:硬件资源上限
CPU 密集型接口:
示例:图像处理、视频转码、数据分析
步骤 1:压测找到 CPU 饱和点
- 单核 CPU 处理一个请求需要 100ms
- 8 核 CPU → 理论最大并发 = 8 / 0.1 = 80 个/秒
步骤 2:考虑系统开销(GC、网络 I/O 等)
- 实际可用算力 ≈ 70%
- 实际并发 = 80 × 0.7 = 56 个/秒
步骤 3:设置限流阈值
- 并发限流:permitLimit = 8(不超过核心数)
- 速率限流:QPS = 56 × 1.3 ≈ 70 个/秒(留 30% 余量)
I/O 密集型接口:
示例:数据库查询、外部 API 调用
步骤 1:确定瓶颈资源
- 数据库连接池:100 个连接
- 平均查询耗时:200ms
- 理论 QPS = 100 / 0.2 = 500 个/秒
步骤 2:考虑慢查询和超时
- 实际可用连接 ≈ 80%(留给其他接口)
- 实际 QPS = 500 × 0.8 = 400 个/秒
步骤 3:设置限流阈值
- 并发限流:permitLimit = 80(保护连接池)
- 速率限流:QPS = 400 × 1.2 = 480 个/秒
因素 2:网络带宽
示例:文件上传/下载接口
服务器带宽:1 Gbps = 125 MB/s
平均文件大小:10 MB
理论 QPS = 125 / 10 = 12.5 个/秒
考虑其他流量占用:
实际可用带宽 ≈ 60%
实际 QPS = 12.5 × 0.6 = 7.5 个/秒
限流配置:
QPS = 7.5 × 1.3 ≈ 10 个/秒(令牌桶)
并发 = 5 个(防止带宽瞬间被占满)
因素 3:下游依赖的承载能力
微服务架构中,限流不仅要考虑自己,还要保护下游:
场景:订单服务调用库存服务
- 库存服务承载能力:500 QPS
- 订单服务实例数:3 个
- 每个实例的限流阈值 = 500 / 3 × 0.8 ≈ 130 个/秒
(留 20% 余量给其他服务调用库存服务)
6.3 令牌桶的两个关键参数
Capacity(桶容量):允许多大的突发?
公式:capacity = refillRate × 允许突发时长(秒)
场景 1:API 网关
- 平均 QPS = 100
- 允许 5 秒突发(用户刷新页面)
- capacity = 100 × 5 = 500
场景 2:后台任务调度
- 平均 QPS = 10
- 允许 10 秒突发(定时任务集中触发)
- capacity = 10 × 10 = 100
场景 3:秒杀活动
- 平均 QPS = 1000
- 不允许突发(库存有限)
- capacity = 1000 × 1 = 1000(最小突发窗口)
RefillRate(补充速率):长期限制多少?
公式:refillRate = 目标 QPS
确定目标 QPS:
方法 1:压测法
- 使用 JMeter/k6 压测
- 找到响应时间开始明显上升的拐点
- 该拐点的 QPS × 0.8 = 目标 QPS
方法 2:资源反推法
- CPU 密集型:refillRate = 核心数 / 单请求耗时
- 数据库依赖:refillRate = 连接池大小 / 平均查询时间
- 内存密集型:refillRate = 可用内存 / 单请求内存
方法 3:业务需求法
- 订阅制 API:Free = 100/天,Pro = 10000/天
- refillRate = 日限额 / 86400
6.4 实际调优示例
案例:电商秒杀接口
系统配置:
- 4 核 8G 服务器
- 库存 1000 件
- 预计 10 万人参与,活动时长 10 分钟
步骤 1:确定理论 QPS
库存消耗速率 = 1000 件 / 600 秒 ≈ 1.7 件/秒
考虑超卖保护,取 2 件/秒
步骤 2:考虑无效请求
实际下单成功率约 5%(库存耗尽、重复点击等)
允许总请求 QPS = 2 / 0.05 = 40 个/秒
步骤 3:配置限流
令牌桶:
- refillRate = 40 个/秒(长期限制)
- capacity = 40 × 2 = 80(允许 2 秒突发)
原因:
- 秒杀开始瞬间会有大量请求涌入
- 80 的容量可以快速处理前 2 秒的突发
- 之后以 40/秒的速率平稳处理
并发限制:
- permitLimit = 20(保护数据库连接池)
- 防止所有 40 个请求同时查数据库
6.5 监控与动态调整
限流不是一次性配置,而是持续优化的过程。
必须监控的指标:
// 监控代码示例
public class RateLimitMetrics
{
public long TotalRequests { get; set; }
public long RejectedRequests { get; set; }
public double RejectionRate => TotalRequests > 0
? (double)RejectedRequests / TotalRequests
: 0;
}
// 日志记录
if (rejectionRate > 0.1) // 拒绝率超过 10%
{
_logger.LogWarning($"限流拒绝率过高: {rejectionRate:P2},考虑提高阈值");
}
else if (rejectionRate < 0.01 && cpuUsage < 50) // 拒绝率很低且 CPU 空闲
{
_logger.LogInformation("限流阈值可能过高,系统资源未充分利用");
}
调整策略:
情况 1:拒绝率 > 10%,系统 CPU < 60%
→ 阈值设置过低,可以提高 30%
情况 2:拒绝率 < 1%,系统 CPU > 80%
→ 阈值设置过高,应该降低 20%
情况 3:拒绝率 5-10%,系统 CPU 70-80%
→ 阈值合理,保持现状
情况 4:拒绝率波动剧烈(1%-30%)
→ 流量不稳定,考虑增大 capacity(提高突发容忍度)
生产环境建议:
- 灰度调整:不要一次性大幅修改,每次调整 20-30%
- 分时段调整:高峰期(白天)和低峰期(夜间)可以使用不同阈值
- A/B 测试:部分实例使用新阈值,观察效果后全量推广
- 保留应急开关:能快速关闭限流(用于紧急流量需求)
7️⃣ 性能对比与选择建议
7.1 性能对比
| 实现方式 | 吞吐量 | 内存占用 | 分布式支持 | 复杂度 |
|---|---|---|---|---|
| SemaphoreSlim | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ❌ | ⭐ |
| 固定窗口 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⚠️ | ⭐⭐ |
| 滑动窗口 | ⭐⭐⭐ | ⭐⭐⭐ | ⚠️ | ⭐⭐⭐ |
| 令牌桶 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⚠️ | ⭐⭐⭐ |
| Redis 分布式 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ | ⭐⭐⭐⭐ |
7.2 选择建议
1. 单机应用:
- 限制并发数 →
SemaphoreSlim或并发限流器 - 限制速率 → 令牌桶限流器
- 简单场景 → 固定窗口限流器
2. 分布式应用:
- 精确限流 → Redis + Lua 脚本
- 简单限流 → 网关层限流(Nginx、API Gateway)
3. 按场景:
- 登录接口 → 固定窗口(按 IP)
- 高成本 API → 并发限流
- API 网关 → 令牌桶(按 API Key)
- 防止 DDoS → 网关层 + 应用层多层防护
8️⃣ 常见问题与最佳实践
8.1 限流后的降级策略
问题:被限流的请求怎么处理?直接返回 429 吗?
策略:
- 立即拒绝:返回 429,告知重试时间
- 排队等待:设置
QueueLimit,请求进入队列 - 降级处理:返回缓存数据或默认值
- 重定向:引导用户到其他服务
示例:返回缓存数据
app.MapGet("/api/data", async (HttpContext context, IMemoryCache cache) =>
{
var limiter = context.RequestServices.GetRequiredService<TokenBucketRateLimiter>();
if (limiter.TryAcquire())
{
// 正常处理
var data = await FetchDataAsync();
cache.Set("data", data, TimeSpan.FromMinutes(5));
return Results.Ok(data);
}
// 被限流,返回缓存
if (cache.TryGetValue("data", out var cachedData))
{
return Results.Ok(new
{
data = cachedData,
fromCache = true,
message = "请求过于频繁,返回缓存数据"
});
}
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
});
8.2 限流与监控
关键指标:
- 限流命中率:被限流的请求占比
- 令牌桶剩余量:监控系统负载
- 队列长度:是否需要扩容
实现监控:
public class MonitoredTokenBucketRateLimiter
{
private readonly TokenBucketRateLimiter _limiter;
private long _totalRequests;
private long _rejectedRequests;
public bool TryAcquire()
{
Interlocked.Increment(ref _totalRequests);
if (_limiter.TryAcquire())
{
return true;
}
Interlocked.Increment(ref _rejectedRequests);
return false;
}
public double GetRejectionRate()
{
return (double)_rejectedRequests / _totalRequests;
}
}
8.3 测试限流逻辑
单元测试:
[Fact]
public void FixedWindowRateLimiter_ShouldRejectAfterLimit()
{
var limiter = new FixedWindowRateLimiter(limit: 5, window: TimeSpan.FromSeconds(1));
// 前 5 次应该通过
for (int i = 0; i < 5; i++)
{
Assert.True(limiter.TryAcquire());
}
// 第 6 次应该被拒绝
Assert.False(limiter.TryAcquire());
}
[Fact]
public async Task FixedWindowRateLimiter_ShouldResetAfterWindow()
{
var limiter = new FixedWindowRateLimiter(limit: 5, window: TimeSpan.FromMilliseconds(100));
for (int i = 0; i < 5; i++)
{
limiter.TryAcquire();
}
Assert.False(limiter.TryAcquire()); // 被拒绝
await Task.Delay(110); // 等待窗口重置
Assert.True(limiter.TryAcquire()); // 应该通过
}
9️⃣ 小结
本章我们深入学习了限流与并发控制的核心知识:
核心要点
-
限流的必要性:
- 保护系统资源
- 保证服务质量
- 防止级联故障
-
四种限流算法:
- 固定窗口:简单但有边界问题
- 滑动窗口:精确但内存占用高
- 令牌桶:推荐,允许突发流量
- 漏桶:平滑输出
-
工具选择:
- 限制并发数 →
SemaphoreSlim - 限制速率 → ASP.NET Core 限流中间件
- 分布式限流 → Redis + Lua 脚本
- 限制并发数 →
-
最佳实践:
- 多层防护(网关 + 应用层)
- 合理的降级策略
- 监控关键指标
- 充分测试
🔟 最后
在这个项目中,我准备了test-scripts.ps1的模拟测试脚本,大家可以根据当前项目Demo(RateLimiting)的README.md文档,按照步骤进行测试,观察实际效果。
纸上得来终觉浅,绝知此事要躬行!

浙公网安备 33010602011771号