【.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 连接)

不限流的连锁反应:

  1. 10000 个请求打到订单服务
  2. 订单服务调用库存服务(只能处理 500 QPS)
  3. 库存服务被打爆,响应变慢
  4. 订单服务大量线程等待库存服务响应,线程池耗尽
  5. API 网关大量线程等待订单服务响应,也被耗尽
  6. 整个系统崩溃

限流后的保护:

  1. 库存服务限流 500 QPS,超出直接拒绝
  2. 订单服务快速失败,不会被拖死
  3. 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();
    }
}

这样就实现了”双重保护”:

  1. 中间件限制请求速率(防止瞬时大量请求)
  2. 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(限流失效!)

问题根源:每个实例的限流器是独立的,它们之间不共享状态。

后果:

  1. 限流阈值失控:预期 1000 QPS,实际可能是 N × 1000 QPS(N = 实例数)
  2. 扩容时更危险:新增实例反而让保护失效
  3. 无法实现全局限流策略(如”所有用户共享 10000 QPS”)

5.2 分布式限流的核心思想

要实现分布式限流,核心是:让所有实例共享同一个限流状态。

方案 1:中心化存储(推荐)
		┌─────────────┐
 用户 ──►│ 负载均衡器  │
		└─────────────┘
			 │
	┌────────┼────────┐
	▼        ▼        ▼
┌────────┐ ┌────────┐ ┌────────┐
│ 实例 1 │ │ 实例 2 │ │ 实例 3 │
└───┬────┘ └───┬────┘ └───┬────┘
	└─────┬────┴────┬─────┘
		  ▼         ▼
	  ┌───────────────┐
	  │     Redis     │  ← 共享限流状态
	  │  (计数器/令牌) │
	  └───────────────┘

 效果:全局 1000 QPS,真正的分布式限流

方案 2:Gossip 协议(复杂)
- 实例间互相通信同步状态
- 实现复杂,延迟高
- 不推荐(除非不能用 Redis)

为什么选 Redis?

  1. 原子操作:Lua 脚本保证限流逻辑的原子性
  2. 高性能:内存操作,延迟通常 < 1ms
  3. 自动过期:通过 TTL 自动清理过期数据
  4. 高可用:Redis Cluster / Sentinel 保证可用性

5.3 Redis + 令牌桶:分布式限流的最佳实践

为什么用 Lua 脚本?

Redis 的限流操作需要多个步骤:

  1. 读取当前令牌数
  2. 计算补充令牌
  3. 判断是否足够
  4. 扣减令牌并更新时间

如果用普通命令,这些步骤不是原子的:

//  错误示例:非原子操作
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(提高突发容忍度)

生产环境建议:

  1. 灰度调整:不要一次性大幅修改,每次调整 20-30%
  2. 分时段调整:高峰期(白天)和低峰期(夜间)可以使用不同阈值
  3. A/B 测试:部分实例使用新阈值,观察效果后全量推广
  4. 保留应急开关:能快速关闭限流(用于紧急流量需求)

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 吗?

策略:

  1. 立即拒绝:返回 429,告知重试时间
  2. 排队等待:设置 QueueLimit,请求进入队列
  3. 降级处理:返回缓存数据或默认值
  4. 重定向:引导用户到其他服务

示例:返回缓存数据

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 限流与监控

关键指标:

  1. 限流命中率:被限流的请求占比
  2. 令牌桶剩余量:监控系统负载
  3. 队列长度:是否需要扩容

实现监控:

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️⃣ 小结

本章我们深入学习了限流与并发控制的核心知识:

核心要点

  1. 限流的必要性:

    • 保护系统资源
    • 保证服务质量
    • 防止级联故障
  2. 四种限流算法:

    • 固定窗口:简单但有边界问题
    • 滑动窗口:精确但内存占用高
    • 令牌桶:推荐,允许突发流量
    • 漏桶:平滑输出
  3. 工具选择:

    • 限制并发数 → SemaphoreSlim
    • 限制速率 → ASP.NET Core 限流中间件
    • 分布式限流 → Redis + Lua 脚本
  4. 最佳实践:

    • 多层防护(网关 + 应用层)
    • 合理的降级策略
    • 监控关键指标
    • 充分测试

最后

在这个项目中,我准备了test-scripts.ps1的模拟测试脚本,大家可以根据当前项目Demo(RateLimiting)的README.md文档,按照步骤进行测试,观察实际效果。

纸上得来终觉浅,绝知此事要躬行!

原文链接:【.NET并发编程 – 18】限流与并发控制:保护系统的第一道防线

© 版权声明
THE END
喜欢就支持一下吧
点赞13 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容