资讯动态

滑动窗口限流器实战:Java实现与高并发避坑指南

发布时间:2026/10/14 14:22:58 来源:尧图企业网站定制
限流这个词做后端的朋友应该都不陌生。我最初接触限流是因为一次线上事故某个活动接口在开售瞬间被超高并发流量打穿数据库连接池直接耗尽整个服务雪崩。复盘的时候发现当时网关虽然配了一个固定的每秒请求限制但那个限制在临界点附近等于形同虚设。后面我花了一个晚上手写了一个单机滑动窗口限流器替换掉原来的固定窗口方案才彻底解决尖峰流量穿透的问题。今天就把这个限流器的设计思路、Java实现细节和落地时踩过的一堆坑完整记录下来给需要自己做限流的同学一个参考。这篇内容适合正在做服务端开发的同学尤其是接口保护、秒杀系统、API网关这类对瞬时流量敏感的场景。文章不会只给一个能跑的最小实现而是会把“为什么用滑动窗口”“怎么选桶的数量”“并发竞争怎么处理”这些关键问题讲透。1. 限流是必须要做但选对算法更关键1.1 什么样的场景需要单机限流限流的本质是“在容量不足时保护系统”而不是“精确统计请求数量”。单机限流解决的是单个服务实例的自我保护问题。比如说你的服务部署了10个节点前面有负载均衡分发流量但某一天某个热点商品出了问题流量偏偏就集中打到了其中的一个实例上。这时候网关的全局限流拦不住它因为全局限流关注的是总量而不是单台机器的负载。单机限流器在这种场景下是最后一道防线保证任何一个实例都不会因为瞬时超载而被打垮。常见的触发场景包括外部回调流量异常、爬虫没有遵守规则地高频抓取、代码缺陷导致循环调用、还有秒杀开始的那一刻流量突然暴涨。这些场景有一个共同点你无法提前准确预测流量峰值必须靠本地的限流组件兜底。1.2 为什么滑动窗口比固定窗口更靠谱先看固定窗口算法。它的逻辑很简单把时间切成固定大小的片段比如每1秒一个窗口每个窗口最多放行N个请求窗口切换时计数清零。这个方案实现简单、性能极好但有一个臭名昭著的临界突刺问题。具体来说假设你限制每秒100个请求。在第1秒的最后100毫秒来了100个请求全部放行。然后紧跟着第2秒开始的最初100毫秒又来了100个请求固定窗口因为计数清零了所以也全部放行。结果就是在短短200毫秒的时间窗内实际通过了200个请求是设定阈值的两倍。如果恶意请求方精确踩在窗口切换点上打流量固定窗口限流完全丧失意义。滑动窗口算法解决这个问题的思路很直接窗口不是从自然秒的边界开始而是从每个请求到达的时间往前回溯。例如限制1秒100个请求那么任意一个请求到达时它往回看1秒内的请求数量如果已经达到100就拒绝这个请求。这样窗口边界是跟着时间连续滑动的临界点的突刺被摊平了。1.3 滑动窗口、令牌桶、漏桶的适用边界后端常见的限流算法有固定窗口、滑动窗口、令牌桶和漏桶。我用一张表整理一下它们在单机场景下的表现。算法平滑度实现复杂度内存开销典型用途固定窗口差临界突刺严重极低极低简单粗粒度限制不太推荐滑动窗口分桶较好取决于桶数中低大多数业务接口限流滑动日志精确版最好低高随时间增长对精确度要求极高的低频场景令牌桶好允许一定突刺中低允许突发、希望平滑速率的场景漏桶好恒定速率中低下游无法接受突发速率的场景令牌桶允许一定程度的突发流量比如桶里积攒了N个令牌高峰期可以一次性消耗适合很多对毛刺不敏感的业务。漏桶则强制恒定速率实现上往往是队列加调度器本质上会引入排队延迟。滑动窗口则是追求“短时间内不能超过某个阈值”的精确控制尤其适合活动秒杀这种对集中流量非常敏感的场景。我最后选了滑动窗口就是因为活动场景最怕的不是速率不稳而是某一个瞬间突然涌入超出负载能力的流量。2. 单机滑动窗口限流器的设计与实现2.1 核心数据结构窗口是怎么“滑”的滑动窗口的朴素实现是在内存里记录每个请求的时间戳每次新请求进来时把窗口之前的旧时间戳全部移除然后统计剩余数量。这个做法叫滑动日志准确是准确但有两个问题一是时间戳列表会随着并发量增大占用大量内存二是移除旧时间戳是O(N)操作高并发下会成为瓶颈。所以工程上更通用的做法是分桶。把整个窗口切成K个小格子每个格子记录这个小时间段内的请求数量。比如窗口是1000毫秒切成10个格子每个格子代表100毫秒。窗口滑动的时候实际上就是把已经过期的小格子的计数丢弃再在当前时间对应的小格子里累加。要理解滑动窗口的核心必须先理解“桶序号”这个概念。假设桶的大小是100毫秒那么从1970年开始到现在按100毫秒一个单位切分每个请求都落在某个整数编号的桶里。现在时间为T我们只需要关心编号从T/100 - K 1到T/100这K个桶凡是编号更早的桶它们的数据已经滑出窗口直接忽略。2.2 第一版滑动日志实现原理演示先写一个最直观的版本。用双端队列保存每个请求的时间戳每次请求进来时把队头已经超出窗口的时间戳清掉。public class SlidingLogRateLimiter { private final DequeLong requests new LinkedList(); private final long windowSizeMillis; private final int maxRequests; public SlidingLogRateLimiter(long windowSizeMillis, int maxRequests) { this.windowSizeMillis windowSizeMillis; this.maxRequests maxRequests; } public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); long windowStart now - windowSizeMillis; while (!requests.isEmpty() requests.peekFirst() windowStart) { requests.pollFirst(); } if (requests.size() maxRequests) { return false; } requests.addLast(now); return true; } }这个版本逻辑清晰适合理解滑动窗口的含义。但它的缺陷也很明显每一个放行的请求都会在内存里留下一个时间戳对象在QPS上万的时候这个队列的写入、淘汰、加锁开销非常可观。而且它统计的是精确数量如果窗口是1000毫秒、限制1000个请求就意味着要维护一个长度最高1000的队列内存和GC压力全都上来了。生产环境我一般不建议直接用这个写法除非限制的QPS很低比如每秒几十的级别。2.3 第二版分桶环形数组实现生产首选分桶方案用固定长度的数组替代动态增长的队列内存占用恒定统计速度也稳定。核心思想是用一个长度为K的数组保存K个桶的计数另外用一个数组保存每个桶对应的起始时间戳。当请求进来时先计算出当前时间属于哪个桶然后判断窗口内所有未过期桶的总数是否超限。public class SlidingWindowRateLimiter { private final int bucketCount; private final long windowSizeMillis; private final int maxRequests; private final long bucketSizeMillis; private final long[] buckets; private final long[] bucketStartTimes; public SlidingWindowRateLimiter(int bucketCount, long windowSizeMillis, int maxRequests) { this.bucketCount bucketCount; this.windowSizeMillis windowSizeMillis; this.maxRequests maxRequests; this.bucketSizeMillis windowSizeMillis / bucketCount; this.buckets new long[bucketCount]; this.bucketStartTimes new long[bucketCount]; Arrays.fill(bucketStartTimes, -1); } public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); long nowBucket now / bucketSizeMillis; long windowLowerBound nowBucket - bucketCount 1; int currentIndex (int) (nowBucket % bucketCount); long total 0; for (int i 0; i bucketCount; i) { if (bucketStartTimes[i] windowLowerBound) { total buckets[i]; } } if (bucketStartTimes[currentIndex] ! nowBucket) { bucketStartTimes[currentIndex] nowBucket; buckets[currentIndex] 0; } if (total maxRequests) { return false; } buckets[currentIndex]; return true; } }解释几个关键细节。关于环形数组的复用安全性因为数组下标是nowBucket % bucketCount当前桶序号和同一个下标上一次存储的桶序号之间必然相差K的整数倍。在正常单调递增的时间流中上一个存储的桶序号一定是nowBucket - K而这个值一定小于windowLowerBound nowBucket - K 1也就是说它已经滑出了窗口可以安全清零。这个性质决定了代码里那步if (bucketStartTimes[currentIndex] ! nowBucket)的重置是安全的不会误删还在窗口内的计数。关于为什么用synchronized这个判断和更新的过程是“读多个桶、求和、判断、更新当前桶”整个过程必须作为一个原子操作。如果多个线程同时读到total未满然后同时自增最终实际放行的数量就会超过阈值。用synchronized虽然粗暴但在限流这个场景下完全够用因为限流本身就是在请求链路上加一道快速拦截单次加锁的纳秒级开销和网络请求动辄几毫秒的耗时相比不值一提。关于为什么用long[]而不是AtomicLong[]既然已经用了锁数组元素就是单线程访问的根本不需要原子类。原子类还会引入额外的CAS开销和对象头内存占用。写代码要区分“并发环境”和“锁内环境”锁内变量用普通数组反而更清爽。2.4 平滑限流的延伸叠加小窗口滑动窗口能解决边界突刺但它管的是“窗口内总量”。有些场景还需要另一个维度在更细粒度的时间片上也要限制流量。比如某活动接口要求“单机每秒最多1000次且任意100毫秒内不超过200次”。这种需求靠单一窗口实现不了因为1秒窗口允许的1000个请求可能集中在窗口开头的100毫秒内。解决方案是用多个不同粒度的限流器叠加形成一个“二级窗口”结构。一个窗口设1000毫秒、阈值1000另一个窗口设100毫秒、阈值200两个限流器都放行才放行任何一个拒绝就拒绝。public boolean tryAcquire() { return secondWindow.tryAcquire() hundredMsWindow.tryAcquire(); }这个结构的本质是给限流增加一个“短时抖动约束”。我实测下来光是控制总量的滑动窗口还不足以应对那种“全部流量集中在极短时间”的极端请求叠加一个粒度更细的窗口后尖峰明显被削平了。如果你遇到类似的需求不需要自己去改算法组合多窗口就够了。3. 并发安全、性能优化与参数调优3.1 并发安全三件套锁、原子操作、时间片限流器核心的并发安全点有三个当前桶计数的累加、窗口总请求数的统计、过期桶的重置。任何一步出现竞态都会导致限流失效或者误杀。第二版代码用一把大锁把三个操作串行化最简单也最正确。但如果你对性能有极致追求可以分析一下这把锁里最耗时的是什么。遍历所有桶求和是O(K)当K10~20时其实很快耗时更明显的是System.currentTimeMillis()的系统调用。高并发下频繁调用它会有不小的开销可以用缓存时间戳的类来优化但代价是时间精度下降限流的窗口边界会有一两毫秒的误差。工程上我的建议是先用简单的锁版本压测确认真的成为瓶颈了再优化。限流器通常不会是服务的热点。如果确实要优化可以考虑用ReentrantReadWriteLock把“统计窗口总数”这个读操作并发化但要注意写锁需要保证互斥实现复杂度会高出不少。3.2 为什么不能用 HashMap 存桶有人会想为什么不用HashMapLong, Long来存桶键是时间戳值是该时间片的计数。第一个问题是哈希碰撞虽然桶数组不大但HashMap的扩容、链表、红黑树都要时间。第二个问题是桶的淘汰你需要额外记录窗口起点每次前进时找到对应的key删除HashMap的元素增删会带来GC压力。第三个问题是遍历时不安全高并发下边遍历边修改会抛ConcurrentModificationException你依然得加锁。固定数组为什么好长度恒定分配的是一整块连续内存下标通过模运算直接定位没有任何哈希寻址的成本过期桶不需要删除只需在访问到它时重置遍历求和时数据都在CPU缓存行里K个long加起来就是几纳秒的事。这些都是HashMap给不了的。3.3 关键参数怎么定窗口长度、桶数、阈值窗口长度取决于业务容忍度。比如接口要求“1秒内不超过1000次”窗口长度就是1000毫秒。要求“10秒内不超过5000次”窗口长度就是10000毫秒。桶数的选择直接影响平滑度和性能。窗口大小推荐桶数单桶时长适合场景1000ms10~2050~100ms常规接口限流1000ms10010ms对尖峰极敏感的秒杀场景10000ms50~100100~200ms长窗口低频限流60000ms601000ms分钟级配额控制桶数太少滑动窗口就退化成固定窗口临界突刺问题回来了。桶数太多单次请求需要遍历求和的次数变多虽然K100时也就几百纳秒但没必要。经验法则桶数取10到20之间是比较均衡的。对极敏感的场景可以适当加粗但不建议超过100除非你有特殊理由。阈值怎么定先对单机做压测找到服务的真实容量上限然后取这个上限的70%~80%作为限流阈值。留出20%的余量是因为线上流量和压测流量模式不一样GC暂停、慢SQL、下游抖动都可能导致同样的请求量产生更高的负载。直接拍脑袋取一个数字是最容易出事的。4. 写一个可直接落地的滑动窗口限流器4.1 基础配置从配置中心动态读取生产环境里限流参数不太可能一次定死。活动大促时要临时提高阈值平时要收紧。比较好的做法是把窗口大小、桶数、阈值放到配置中心里限流器在每次执行时动态读取。注意不要每次都从配置中心拉取远程内容那样会有网络开销和故障点应该是配置中心变更时推送到本地缓存限流器直接读本地值。一个简单的动态配置载体public class RateLimiterConfig { private volatile int maxRequests; private volatile int bucketCount; private volatile long windowSizeMillis; public RateLimiterConfig(int maxRequests, int bucketCount, long windowSizeMillis) { this.maxRequests maxRequests; this.bucketCount bucketCount; this.windowSizeMillis windowSizeMillis; } // getter / setter 省略 }用volatile保证线程可见性。限流器每次调用tryAcquire时优先读取配置引用这样参数调整在下次请求时立即生效不需要重启服务。4.2 完整限流器实现支持动态配置和实时统计把前面的分桶版本升级一下让它支持动态配置同时暴露出窗口内的实时请求数方便监控和排查。public class DynamicSlidingWindowRateLimiter { private final int bucketCount; // 固定不建议运行时变化 private volatile RateLimiterConfig config; // 动态阈值和窗口长度 private final long[] buckets; private final long[] bucketStartTimes; private final long bucketSizeMillis; public DynamicSlidingWindowRateLimiter(RateLimiterConfig config) { this.config config; this.bucketCount config.getBucketCount(); this.bucketSizeMillis config.getWindowSizeMillis() / config.getBucketCount(); this.buckets new long[bucketCount]; this.bucketStartTimes new long[bucketCount]; Arrays.fill(bucketStartTimes, -1); } public synchronized boolean tryAcquire() { RateLimiterConfig cfg this.config; long now System.currentTimeMillis(); long nowBucket now / bucketSizeMillis; long windowLowerBound nowBucket - bucketCount 1; int currentIndex (int) (nowBucket % bucketCount); long total 0; for (int i 0; i bucketCount; i) { if (bucketStartTimes[i] windowLowerBound) { total buckets[i]; } } if (bucketStartTimes[currentIndex] ! nowBucket) { bucketStartTimes[currentIndex] nowBucket; buckets[currentIndex] 0; } if (total cfg.getMaxRequests()) { return false; } buckets[currentIndex]; return true; } public synchronized long currentRequestsInWindow() { long now System.currentTimeMillis(); long nowBucket now / bucketSizeMillis; long windowLowerBound nowBucket - bucketCount 1; long total 0; for (int i 0; i bucketCount; i) { if (bucketStartTimes[i] windowLowerBound) { total buckets[i]; } } return total; } }注意一个细节bucketSizeMillis用的是创建时的窗口长度除以桶数所以窗口长度可以动态调整但是调整时桶的大小不会跟着变。如果你需要动态修改窗口长度最好连桶数一起重建限流器。我的经验是窗口长度这种参数很少需要频繁调整真正频繁调整的是阈值maxRequests日常大促改这一个参数就够了。4.3 接入Spring Boot拦截器三分钟搞定单机限流器在业务代码里常见的接入方式是用Spring的HandlerInterceptor在请求进入Controller之前拦截。写一个限流拦截器public class RateLimitInterceptor implements HandlerInterceptor { private final DynamicSlidingWindowRateLimiter rateLimiter; public RateLimitInterceptor(DynamicSlidingWindowRateLimiter rateLimiter) { this.rateLimiter rateLimiter; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (rateLimiter.tryAcquire()) { return true; } response.setStatus(429); response.getWriter().write({\code\:429,\message\:\Too Many Requests\}); return false; } }注册到WebMvcConfigurerConfiguration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new RateLimitInterceptor(new DynamicSlidingWindowRateLimiter( new RateLimiterConfig(500, 10, 1000)))) .addPathPatterns(/api/**) .excludePathPatterns(/actuator/**); } }如果不想网关所有接口共用一个阈值可以按接口维度创建多个限流器实例放在一个Map里拦截器根据请求路径取对应的实例。注意这个Map本身要用ConcurrentHashMap保证并发安全并且实例的创建要控制好生命周期别在请求路径上每次new一个。4.4 压测与效果对比我用单机4核8G的配置用JMeter模拟并发请求做了一个对比测试。场景是限制每秒500个请求用100线程持续灌入流量。固定窗口限流器的表现在窗口切换的瞬间瞬时放行量可以冲到800甚至1000因为临界前后两个窗口的配额被同时耗尽。滑动窗口限流器10个桶的表现瞬时放行量最大值稳定在530以内虽然因为线程并发和统计时机存在少量超调但相比固定窗口有了质的改善。这里有个值得说的点滑动窗口限流允许非常小的超调。因为请求瞬间多个线程同时通过检查并自增在synchronized锁内顺序执行时确实能保证严格不超过阈值。但这个测试里的超调我后来排查发现是JMeter统计口径和限流器内部状态之间存在时间差实际上限流器内部放行量是严格的。这个时间差告诉我们压测观察到的“瞬时QPS”和限流器统计的“窗口请求量”不是同一个概念。5. 常见问题与工程避坑记录5.1 时间边界条件与并发竞态高并发下最经典的坑是多个线程在同一个毫秒到达落到同一个桶里。这个没问提因为synchronized保证了它们串行执行桶计数会正确累加。容易出问题的是系统时间毫秒级波动导致桶序号反复横跳。比如某个请求的now / bucketSizeMillis计算出100累加到桶100下一个请求因为时间戳回退计算出99那么它在求total时看到的窗口下界可能就不连续。这种波动很小的时候不必担心如果波动较大会导致同一个窗口的请求被错误地分到两个桶里。5.2 系统时钟回拨的噩梦单机限流器本质上依赖系统时间一旦系统时钟发生回拨窗口就会出现逻辑错乱。比如说当前桶序号是100下一个请求因为时钟拨回去变成了95。95对应的数组下标和100不同它可能清零了一个本该属于窗口内的时间片导致限流被绕过也可能把窗口起点拖后导致短时间内大量请求被放行。线上常见的是NTP校时导致的轻微回拨比如几十毫秒。这种级别基本不会触发明显异常因为限流器窗口至少是几百毫秒。真正要防御的是人为改时间或者极端情况下的校时偏差可能达到数秒。一个简单的防御是在代码里记录上一次的桶序号发现当前桶序号变小且小于windowLowerBound时直接拒绝请求或重置整个窗口。public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); long nowBucket now / bucketSizeMillis; if (nowBucket lastBucket) { // 时间回拨超过一个桶直接按拒绝处理等待下一个正常时间片 return false; } // 后续逻辑 }5.3 长窗口下的遍历开销与优化当窗口长度拉长到10秒、桶数设为100时每个请求进来都要遍历100个桶求和。虽然单次只有几百纳秒但如果你的限流器被调到请求链路上非常前置的位置每一微秒都值得优化。优化方案是维护一个total变量窗口每次滑过过期桶时从total里减掉过期桶的计数这样统计总数的时间从O(K)变成O(1)。代价是逻辑变复杂因为你需要精确追踪窗口滑过了几个桶。public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); long nowBucket now / bucketSizeMillis; long windowLowerBound nowBucket - bucketCount 1; int currentIndex (int) (nowBucket % bucketCount); // 推进窗口把滑出边界的桶从 total 中扣掉 while (headBucket windowLowerBound) { total - buckets[headIndex]; buckets[headIndex] 0; headIndex (headIndex 1) % bucketCount; headBucket; } if (bucketStartTimes[currentIndex] ! nowBucket) { bucketStartTimes[currentIndex] nowBucket; buckets[currentIndex] 0; } if (total config.getMaxRequests()) { return false; } if (bucketStartTimes[currentIndex] ! nowBucket) { bucketStartTimes[currentIndex] nowBucket; } buckets[currentIndex]; total; return true; }这种优化版我在项目里也用过确实性能更好但代码里有好几个关联变量headBucket、headIndex、total稍不留神就会写错。对于大多数业务场景K20以内的遍历足够快了不要为了省这点时间把正确性搞复杂。只有你确认限流器在热路径上且桶数很多时再上这个优化。5.4 单机限流的边界与多实例问题最后必须说清楚单机限流的边界。单机限流器只能保证“一个进程内”的请求不超过阈值它管不了多实例部署下的全局总量。假设你部署了4个实例每个限500那么全局总量最高可以到2000。如果你的系统设计依赖全局2000这个上限单机限流是给不了这个保证的。这时候需要引入分布式限流或者把阈值按实例数做预算分配但要注意线上流量不一定均匀分布保守一点的方案是给每台实例留出冗余量。我实际项目里的做法是分布式限流管总量单机限流管自我保护。两层都配缺一不可。至于要不要把单机限流做成可插拔的注解看团队规范。有的团队喜欢RateLimit注解直接标在Controller方法上由AOP切面统一处理。这种方案可读性好但要注意AOP切面的执行时机在拦截器之后如果你的拦截器已经做了限流AOP里的限流器可以只针对个别方法做更细粒度的控制两者不冲突。在实际操作中还有一个小点限流后的响应设计。直接返回429状态码是最标准的做法但很多调用方对这个状态码不熟悉会误认为请求失败重试反而加重系统负担。比较好的做法是返回一个明确的业务错误码比如{code:42901,message:系统繁忙请稍后再试}同时在响应头里带上Retry-After告诉调用方多久之后再试。我在生产上跑这个滑动窗口限流器已经很久了。最大的体会是限流器的代码本身不难难的是对边界条件的理解和参数的调优。网上很多文章只贴一个实现就完事但真正放到高并发环境里时钟回拨、桶复用安全、并发原子性、动态配置、压测调参这些才是决定它能不能稳定运行的关键。希望这篇文章能把那层窗户纸捅破让你在需要限流的时候不必再重复踩我踩过的坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑