滑动窗口计数器的核心逻辑是实时维护一个随请求滑动的时间窗口,每次请求时仅统计窗口内(如过去1秒)的有效请求时间戳,并动态清理过期数据;它依赖ConcurrentQueue线程安全地存储带时间戳的请求记录,通过while循环持续出队过期项后再判断队列长度是否超限,避免误判与性能陷阱。

滑动窗口计数器的核心逻辑是什么
滑动窗口计数器不是简单地把时间切分成固定桶(如每秒一个桶),而是让窗口随请求实时滑动,比如“过去 1 秒内最多 100 次请求”。关键在于:每次请求到来时,只统计那些时间戳 ≥ DateTime.UtcNow.AddSeconds(-1) 的记录,丢弃过期数据。它比固定窗口更平滑,但需要维护带时间戳的请求历史——不能只用一个整数累加。
用 ConcurrentQueue 实现线程安全的滑动窗口
这是最轻量、无外部依赖的做法,适合中低并发(QPS ≤ 数千)。ConcurrentQueue 天然线程安全,避免锁开销;每次请求入队,再循环出队过期时间戳,最后检查队列长度是否超限。
- 必须用
DateTime.UtcNow(而非DateTime.Now),避免时区和夏令时干扰 - 出队操作不能只做一次——要 while 循环直到队首时间戳有效,否则会误判(例如多个请求在临界点涌入)
- 队列长度本身不直接代表当前请求数,必须先清理过期项再取
Count(ConcurrentQueue的Count是 O(n),高并发下慎用;改用原子计数器更稳)
public class SlidingWindowCounter
{
private readonly ConcurrentQueue _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 或 Redis
SortedSet 看似能快速范围查询,但它不支持按时间范围批量删除(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(sp => new SlidingWindowCounter(1, 100)); - 在中间件或 ActionFilter 中调用
TryAcquire(),失败时写入context.Response.StatusCode = 429并设置Retry-After: 1头 - 验证时用
curl -X GET http://localhost:5000/api/test &快速并发 200 次,观察响应头和状态码分布;注意不要用 Postman 自带的“发送多次”功能——它默认串行,测不出并发效果
真正难的是边界场景:窗口跨越秒级边界时的计数抖动、多实例部署时的共享状态缺失、以及和熔断/降级策略的协同。单机滑动窗口只是起点,不是终点。










