c# 如何用 C# 实现一个滑动窗口计数器来做限流

来源:这里教程网 时间:2026-02-21 17:41:41 作者:

滑动窗口计数器的核心逻辑是什么

滑动窗口计数器不是简单地把时间切分成固定桶(如每秒一个桶),而是让窗口随请求实时滑动,比如“过去 1 秒内最多 100 次请求”。关键在于:每次请求到来时,只统计那些时间戳 ≥

DateTime.UtcNow.AddSeconds(-1)
的记录,丢弃过期数据。它比固定窗口更平滑,但需要维护带时间戳的请求历史——不能只用一个整数累加。

ConcurrentQueue<datetime></datetime>
实现线程安全的滑动窗口

这是最轻量、无外部依赖的做法,适合中低并发(QPS ≤ 数千)。

ConcurrentQueue<datetime></datetime>
天然线程安全,避免锁开销;每次请求入队,再循环出队过期时间戳,最后检查队列长度是否超限。

必须用
DateTime.UtcNow
(而非
DateTime.Now
),避免时区和夏令时干扰
出队操作不能只做一次——要 while 循环直到队首时间戳有效,否则会误判(例如多个请求在临界点涌入) 队列长度本身不直接代表当前请求数,必须先清理过期项再取
Count
ConcurrentQueue
Count
是 O(n),高并发下慎用;改用原子计数器更稳)
public class SlidingWindowCounter
{
    private readonly ConcurrentQueue<DateTime> _timestamps = new();
    private readonly int _windowSeconds;
    private readonly int _maxRequests;
    private readonly object _cleanupLock = new(); // 避免 Count 被频繁调用时反复遍历
    public SlidingWindowCounter(int windowSeconds, int maxRequests)
    {
        _windowSeconds = windowSeconds;
        _maxRequests = maxRequests;
    }
    public bool TryAcquire()
    {
        var now = DateTime.UtcNow;
        var windowStart = now.AddSeconds(-_windowSeconds);
        // 清理过期时间戳:必须 while 循环,不能只 pop 一次
        while (_timestamps.TryPeek(out var ts) && ts < windowStart)
        {
            _timestamps.TryDequeue(out _);
        }
        // 使用锁保护 Count 访问(或改用 Interlocked 原子计数器)
        lock (_cleanupLock)
        {
            if (_timestamps.Count >= _maxRequests) return false;
            _timestamps.Enqueue(now);
        }
        return true;
    }
}

为什么不用
SortedSet<datetime></datetime>
或 Redis

SortedSet<datetime></datetime>
看似能快速范围查询,但它不支持按时间范围批量删除(
RemoveWhere
是 O(n) 且非线程安全),实际性能不如队列 + 首尾扫描;而 Redis 的
ZREMRANGEBYSCORE
+
ZCARD
虽标准,但引入网络延迟和序列化开销,在单机高吞吐场景下反而成瓶颈。纯内存方案只要控制好队列大小(比如加个最大容量限制防内存泄漏),对大多数 Web API 限流已足够可靠。

没做最大容量限制?极端情况下(长时间无请求后突发洪峰),队列可能积压数万条时间戳,导致清理变慢甚至 GC 压力上升 没考虑系统时钟回拨?如果 NTP 同步导致
UtcNow
突然变小,会误删大量未过期记录——生产环境建议搭配单调时钟(如
Stopwatch.GetTimestamp()
换算)
这个类不是完全无锁:
lock
块只保护
Count
Enqueue
,但清理逻辑本身是无锁的;若追求极致性能,可改用
Interlocked
维护计数器,把时间戳存进
ConcurrentBag
并定期批量清理(需额外调度)

如何在 ASP.NET Core 中集成并验证效果

别直接在 Controller 里 new 实例——每个请求新建一个计数器就完全失效了。必须注册为 Singleton,并通过依赖注入使用。同时,限流结果要明确返回 HTTP 429,不能静默失败。

注册服务:
services.AddSingleton<slidingwindowcounter>(sp => new SlidingWindowCounter(1, 100));</slidingwindowcounter>
在中间件或 ActionFilter 中调用
TryAcquire()
,失败时写入
context.Response.StatusCode = 429
并设置
Retry-After: 1
验证时用
curl -X GET http://localhost:5000/api/test &
快速并发 200 次,观察响应头和状态码分布;注意不要用 Postman 自带的“发送多次”功能——它默认串行,测不出并发效果

真正难的是边界场景:窗口跨越秒级边界时的计数抖动、多实例部署时的共享状态缺失、以及和熔断/降级策略的协同。单机滑动窗口只是起点,不是终点。

相关推荐