在构建高并发的C#后端服务时,接口限流是保护系统稳定性的基础手段。令牌桶算法和漏桶算法分别从“允许突发”和“强制匀速”两个角度解决流量过载问题。理解它们的核心差异并用代码落地,是每位.NET开发者都应掌握的能力。

一、令牌桶算法原理与C#实现
令牌桶算法的核心思想是系统以固定速率向桶中放入令牌,桶有容量上限。每个请求到来时必须从桶中取出一个令牌才能继续执行,若桶空则拒绝或排队。该模型的特点是允许在短时间内消耗桶中积攒的令牌,从而支撑一定的突发流量。
在C#中实现令牌桶,重点在于令牌补充的线程安全控制。通常使用System.Threading.Interlocked或lock来保证多请求并发下的计数准确。下面的示例采用后台定时器补充令牌,并使用SemaphoreSlim做简单互斥。
using System;
using System.Threading;
using System.Threading.Tasks;
public class TokenBucket
{
private readonly int _capacity;
private readonly int _refillRate; // 每秒补充令牌数
private int _tokens;
private readonly object _lock = new object();
private DateTime _lastRefillTime;
public TokenBucket(int capacity, int refillRatePerSecond)
{
_capacity = capacity;
_refillRate = refillRatePerSecond;
_tokens = capacity;
_lastRefillTime = DateTime.UtcNow;
}
// 尝试获取令牌
public bool TryConsume()
{
lock (_lock)
{
Refill();
if (_tokens > 0)
{
_tokens--;
return true;
}
return false;
}
}
private void Refill()
{
var now = DateTime.UtcNow;
var elapsed = (now - _lastRefillTime).TotalSeconds;
if (elapsed <= 0) return;
int added = (int)(elapsed * _refillRate);
if (added > 0)
{
_tokens = Math.Min(_capacity, _tokens + added);
_lastRefillTime = now;
}
}
}
上面的代码在每次调用TryConsume时先计算时间差并补充令牌,再判断是否足够扣除。这种惰性补充方式避免了独立的定时器线程,但在高并发下lock可能成为瓶颈。若追求更高性能,可改用Interlocked结合时间窗口估算。
令牌桶的优势在于对用户体验较友好:平时积攒的令牌可应对促销活动时的瞬时高峰。缺点是若桶容量设置过大,可能让后端在突发后陷入过载,因此容量需结合压测确定。
二、漏桶算法原理与C#实现
漏桶算法将请求视为流入桶中的水,桶以恒定速率漏水(处理请求)。如果水流入过快导致桶溢出,则丢弃新请求。它强制输出速率恒定,能够绝对平滑流量,但无法应对任何突发。
在C#中可以用队列加消费线程模拟漏桶:请求入队,后台线程以固定间隔出队处理。以下示例用ConcurrentQueue与Timer实现简易漏桶。
using System;
using System.Collections.Concurrent;
using System.Threading;
public class LeakyBucket
{
private readonly ConcurrentQueue<Action> _queue = new ConcurrentQueue<Action>();
private readonly int _maxSize;
private readonly Timer _timer;
public LeakyBucket(int maxSize, int processPerSecond)
{
_maxSize = maxSize;
// 每 1000/processPerSecond 毫秒处理一个
int interval = 1000 / processPerSecond;
_timer = new Timer(Process, null, 0, interval);
}
public bool TryEnqueue(Action task)
{
if (_queue.Count >= _maxSize) return false;
_queue.Enqueue(task);
return true;
}
private void Process(object state)
{
if (_queue.TryDequeue(out var task))
{
task?.Invoke();
}
}
}
该实现中,TryEnqueue检查队列长度,超过_maxSize直接拒绝,保证内存可控。Timer按固定频率从队列取任务执行,形成匀速处理。由于处理在后台线程,调用方通常仅做入队动作,实际业务被异步执行。
漏桶对下游保护极强,适合调用第三方付费接口或数据库写入等必须严格限速的场景。其缺点是正常流量也会被强制排队,延迟升高,且队列实现增加了系统复杂度。
三、在ASP.NET Core中间件中应用
将限流逻辑放在中间件中,可以对所有接口统一拦截。下面以令牌桶为例展示中间件写法,漏桶只需替换成对应类即可。
using Microsoft.AspNetCore.Http;
using System.Threading.Tasks;
public class RateLimitMiddleware
{
private readonly RequestDelegate _next;
private static readonly TokenBucket _bucket = new TokenBucket(100, 10);
public RateLimitMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
if (!_bucket.TryConsume())
{
context.Response.StatusCode = 429;
await context.Response.WriteAsync("请求过于频繁");
return;
}
await _next(context);
}
}
在Startup.cs的Configure方法中用app.UseMiddleware<RateLimitMiddleware>()注册即可。注意静态实例在单例场景下是共享的,若需按IP限流,应改用IMemoryCache存储每个键的桶对象。
实际部署时,多实例部署会让进程内限流失效,此时应引入Redis等分布式存储,用Lua脚本保证原子性地增减令牌或队列长度,从而在全集群维度生效。
四、两种算法选型对比
从流量特征看,令牌桶适合大多数Web API,因为它兼顾平滑与突发;漏桶适合严重依赖下游承载力的链路。下表列出关键维度差异:
| 维度 | 令牌桶 | 漏桶 |
|---|---|---|
| 突发支持 | 支持 | 不支持 |
| 输出平滑度 | 较好 | 极致平滑 |
| 实现复杂度 | 低 | 中 |
| 适用场景 | 通用API保护 | 严格限速下游 |
无论选择哪种,都应配合监控统计限流次数,并给客户端返回明确的429状态码与Retry-After头。这样既能保护系统,也能让调用方合理重试。
最后提醒,限流值不是拍脑袋决定,应通过全链路压测找到系统拐点,再预留安全余量配置到桶容量与速率参数中,才能在生产环境真正发挥作用。