【.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文档,按照步骤进行测试,观察实际效果。

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

posted @ 2026-07-04 00:24  呆萌哈士奇  阅读(11)  评论(0)    收藏  举报