当前位置: 首页 > news >正文

深入浅出限流算法(二):更平滑的滑动窗口

好的,接续上一篇关于固定窗口计数器的讨论,我们现在来看看它的改进版——滑动窗口算法,它旨在解决固定窗口那个恼人的“临界突变”问题。

在上一篇文章中,我们探讨了最简单的固定窗口计数器限流算法,并指出了它最大的缺陷——临界突变问题,即在窗口边界可能产生远超预期的瞬时流量。为了克服这个问题,工程师们设计出了更精妙的滑动窗口 (Sliding Window) 算法。

滑动窗口的核心思想

想象一下,你不是每分钟换一个全新的计数器,而是有一个总时长固定(比如 1 分钟)但由多个小格子组成(比如 6 个 10 秒的格子)的观察窗口。

  1. 细分窗口: 时间被划分成连续的小格子(子窗口),每个格子记录自己时间段内的请求数。
  2. 滑动观察: 你的观察窗口始终覆盖着最近的若干个格子(总时长固定,例如始终覆盖最近 6 个 10 秒的格子,构成一个 1 分钟的滑动窗口)。
  3. 计算总和: 当新请求到来时,你计算当前观察窗口内所有格子计数器的总和。
  4. 检查限制: 如果总和小于等于阈值(例如 QPS 限制),则允许请求,并将当前请求计入其所属的那个小格子。如果超过阈值,则拒绝请求。
  5. 时间推移: 随着时间的流逝,观察窗口整体向前“滑动”:最老的一个格子(及其计数)滑出观察窗口,不再参与计算;最新的一个时间格子进入观察窗口。

通过这种方式,统计总是基于一个持续滑动的、固定长度的时间区间,避免了固定窗口在边界处的计数器突然清零,从而使得流量限制更加平滑。

Java 实现

下面是一个基于数组的滑动窗口限流器 Java 实现:

import java.time.LocalTime; // 这个 import 在代码中实际未使用,可以移除
import java.util.concurrent.atomic.AtomicInteger;public class RateLimiterSlidingWindow {private int qps = 2;               // 总的 QPS 限制 (每秒允许请求数)private long windowSize = 1000;    // 主窗口大小 (毫秒),例如 1000ms = 1秒private int windowCount = 10;      // 子窗口(格子)的数量private WindowInfo[] windowArray; // 存储子窗口数据的数组// 内部类,用于存储每个子窗口的信息private static class WindowInfo {private Long time;          // 此子窗口最后一次被重置或更新的时间戳private AtomicInteger number; // 此子窗口内的请求计数器 (线程安全)// 构造函数public WindowInfo(long time, AtomicInteger number) {this.time = time;this.number = number;}// Getters and Setters (可以根据需要添加)public Long getTime() { return time; }public void setTime(Long time) { this.time = time; }public AtomicInteger getNumber() { return number; }}// 构造函数:初始化限流器public RateLimiterSlidingWindow(int qps, long windowSize, int windowCount) {this.qps = qps;this.windowSize = windowSize;this.windowCount = windowCount;this.windowArray = new WindowInfo[this.windowCount];long currentTimeMillis = System.currentTimeMillis();// 初始化所有子窗口,初始时间设为当前,计数设为0for (int i = 0; i < windowArray.length; i++) {windowArray[i] = new WindowInfo(currentTimeMillis, new AtomicInteger(0));}}// 尝试获取一个请求许可 (核心方法)// 使用 synchronized 保证对 windowArray 状态修改的线程安全public synchronized boolean tryAcquire() {long currentTimeMillis = System.currentTimeMillis();// 1. 计算当前时间戳应该落在哪个子窗口 (格子)long subWindowSize = windowSize / windowCount; // 每个子窗口的时间跨度// 通过取模和除法,将当前时间映射到数组索引int currentIndex = (int)((currentTimeMillis % windowSize) / subWindowSize);// 2. 遍历所有子窗口:重置过期窗口 & 计算当前滑动窗口总数int sum = 0; // 当前滑动窗口内的总请求数for (int i = 0; i < windowArray.length; i++) {WindowInfo windowInfo = windowArray[i];// 检查子窗口是否已过期 (时间戳距离现在超过整个主窗口大小)if ((currentTimeMillis - windowInfo.getTime()) > windowSize) {// 如果过期,重置计数器为 0,并更新其时间戳为当前时间// 这相当于让这个格子“重新进入”滑动窗口的循环利用windowInfo.getNumber().set(0);windowInfo.setTime(currentTimeMillis);}// 如果当前遍历到的格子就是本次请求所属的格子if (currentIndex == i) {// 先尝试将当前格子的计数加 1 (后面如果超限会撤销)windowInfo.getNumber().incrementAndGet();}// 累加所有未过期格子的计数sum = sum + windowInfo.getNumber().get();}// 3. 检查总数是否超过限制if (sum > qps) {// 如果总数超限,说明刚才对 currentIndex 的增加是无效的// 需要撤销 (回滚) 这个增加操作windowArray[currentIndex].getNumber().decrementAndGet();return false; // 拒绝请求} else {// 如果未超限,刚才的增加有效,请求被允许return true;}}// main 方法用于测试 (可以参考上一篇的测试方式)public static void main(String[] args) throws InterruptedException {// QPS=2, 窗口1秒, 分成10个格子 (每个100ms)RateLimiterSlidingWindow limiter = new RateLimiterSlidingWindow(2, 1000, 10);for (int i = 0; i < 10; i++) {// 模拟请求间隔,观察效果Thread.sleep(100); // 每 100ms 发一个请求// Thread.sleep(400); // 每 400ms 发一个请求// Thread.sleep(600); // 每 600ms 发一个请求long currentTimeMillis = System.currentTimeMillis();if (limiter.tryAcquire()) {System.out.println(currentTimeMillis + " 请求成功 -> ✅");} else {System.out.println(currentTimeMillis + " 请求失败 -> ❌");}}}
}

代码解读:

  • ​WindowInfo​ 内部类: 存储每个子窗口(格子)的时间戳和原子计数器。

  • ​windowArray​: 一个 WindowInfo​ 对象的数组,数组大小即格子的数量 (windowCount​)。

  • 构造函数: 初始化 qps​、windowSize​、windowCount​,并创建和初始化 windowArray​。所有格子的初始时间戳设为当前时间,计数为 0。

  • ​tryAcquire()​ 核心逻辑:

    • 计算 currentIndex​: 确定当前请求应该落在哪一个格子里。通过 (当前时间 % 主窗口大小) / 子窗口大小​ 的方式,巧妙地将无限的时间轴映射到有限的数组索引上,实现了循环使用数组格子的效果。

    • 遍历 windowArray​:

      • 重置过期格子: 对每个格子,检查其记录的时间戳 windowInfo.getTime()​ 是否已经“太旧”(距离当前时间超过了一个 windowSize​)。如果是,说明这个格子代表的时间段已经完全滑出了当前的统计窗口,需要将其计数清零,并更新时间戳为当前时间,为后续循环利用做准备。
      • 增加当前格子计数: 如果遍历到的格子 i​ 正好是当前请求所属的格子 currentIndex​,就先尝试将其计数原子地加 1。
      • 累加总数: 将所有(未过期的)格子的计数值累加到 sum​ 中。
    • 检查并处理结果:

      • 如果 sum​ 超过了 qps​ 限制,说明刚才对 currentIndex​ 的增加导致了超限,需要调用 decrementAndGet()​ 将其撤销,并返回 false​ (拒绝)。
      • 如果 sum​ 未超过 qps​,说明请求被允许,刚才的增加有效,返回 true​。
  • 线程安全: 同样使用了 synchronized​ 关键字来保证对共享数组 windowArray​ 的访问和修改是线程安全的。AtomicInteger​ 保证了单个格子计数的原子性。

优点

  1. 缓解临界突变:通过细化时间窗口并进行滑动统计,显著减少了固定窗口在边界处可能出现的流量突刺问题,使得流量控制更加平滑。
  2. 实现相对简单:相比更复杂的算法(如令牌桶),滑动窗口的概念和基于数组的实现仍然比较直观。

缺点与考量

  1. 并未完全消除突变:滑动窗口虽然平滑了流量,但仍然可能存在一定的突发性。例如,如果子窗口(格子)划分得不够细,在子窗口的边界处仍然可能出现小范围的流量聚集。精度取决于格子的数量,格子越多越平滑,但内存和计算开销也越大。
  2. ​synchronized​ 性能瓶颈:在高并发场景下,synchronized​ 可能会成为性能瓶颈,因为它锁定了整个方法,限制了并发度。更优化的实现可能会采用更细粒度的锁或者无锁数据结构(但这会显著增加实现的复杂度)。
  3. 内存占用:相比固定窗口,需要存储多个子窗口的信息,内存占用有所增加。

总结

滑动窗口算法是对固定窗口计数器算法的有效改进,通过将时间窗口细分为多个格子并进行滑动统计,显著缓解了临界突变问题,提供了更平滑、更准确的流量控制。虽然它并非完美(仍有一定突发可能,且简单实现存在并发瓶颈),但它在精度和实现复杂度之间取得了较好的平衡,是许多限流场景下的常用选择。

http://www.xdnf.cn/news/195103.html

相关文章:

  • Python 如何操作数据库,让你使用 DeepSeek 开发数据库应用更加快 (Orm Bee)
  • MySQL复合查询
  • HTML 从标签到动态效果的基础
  • react-navigation-draw抽屉导航
  • ArkTS基础实验 (二)
  • 数字人Live_Talking的搭建和使用
  • OpenResty深度解析:从卓伊凡的”隐形主流”论看其深度原理与应用生态-卓伊凡
  • 深入理解java线程池
  • stm32 阻塞式延时 与 非阻塞式延时
  • “数字驱动·智建未来——2025河北省建筑电气与智能化技术交流大会”
  • 【ACL系列论文写作指北14-科研心态与抗压管理】-走得远,比走得快更重要
  • 不同参数大小的DeepSeekR1模型对Java中new FileInputStream(“test.txt“).seek(100);语法错误的检查
  • 学习笔记:Qlib 量化投资平台框架 — MAIN COMPONENTS (Part I)
  • XrayR启动失败
  • 架构进阶:详解108页系统架构设计与详细设计知识讲座【附全文阅读】
  • 品融电商:全域电商代运营的领航者,驱动品牌长效增长
  • 第四章:Messaging and Memory
  • C语言中的指针详解
  • RSS‘25|CMU提出统一空中操作框架:以末端执行器为中心,无人机实现高精度遥操作
  • Cursor + Figma-Context-MCP ,让 Cursor 获取 Figma 设计图信息,实现 AI 生成页面的高度还原
  • 力扣面试150题--K 个一组翻转链表
  • 机器人--激光雷达
  • ESG跨境电商怎么样?esg跨境电商有哪些功用?
  • 阅读MySQL实战45讲第11天
  • uniapp打包apk如何实现版本更新
  • Spring MVC异常处理利器:深入理解HandlerExceptionResolver
  • SpringBoot实现接口防刷的5种高效方案详解
  • C#/.NET/.NET Core技术前沿周刊 | 第 36 期(2025年4.21-4.27)
  • AudioSet 音频中文类别
  • 蚂蚁seo蜘蛛池:提升网站收录的秘密武器