Java滑动窗口限流案例

wen java案例 6

Java滑动窗口限流实战:从原理到高并发微服务落地案例


目录导读

  1. 为什么固定窗口会“漏”流量?——限流算法痛点剖析
  2. 滑动窗口算法核心原理:时间片与计数器如何协同
  3. Java代码实战:基于DequeAtomicInteger的零依赖实现
  4. 生产级案例:应对双11秒杀场景的滑动窗口限流方案
  5. 高频问答:关于滑动窗口的5个致命误解与优化策略

为什么固定窗口会“漏”流量?

Java滑动窗口限流案例

在分布式系统中,限流是保护后端服务的“第一道闸门”,最常见的固定窗口算法(如每秒允许100次请求)存在明显缺陷:临界突变问题,假设窗口为1秒,前0.9秒没有请求,第0.9秒突然涌入100个请求,紧接着第1.0秒又涌入100个请求,由于窗口重置,这200个请求在2个时间窗口内均“合法”,但实际在0.1秒内打满了200 QPS,导致服务瞬间过载。

滑动窗口算法通过将时间划分为更细粒度的小窗口(如将1秒分为10个100ms的格子),并且窗口随时间平滑右移,从而消除临界跳跃,让限流曲线更贴近真实流量。

滑动窗口核心原理:时间片轮转与计数器递减

滑动窗口本质是多个子窗口计数器之和,每个子窗口记录其时间区间内的请求数,当窗口滑动时,最左侧的过期子窗口被移除,新子窗口加入,关键点:

  • 子窗口粒度:粒度越细,限流精度越高(但内存开销越大)。
  • 滑动步长:通常为1个子窗口长度,窗口总长1秒,子窗口100ms,则每100ms滑动一次。

与固定窗口相比,它相当于将“一个计数器”升级为“一组计数器”,并通过总和判断是否触发限流。

Java代码实战:基于LinkedListAtomicLong的轻量级实现

我们使用无第三方依赖的方式实现,适用Spring Boot或纯Java环境。

import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.atomic.AtomicLong;
public class SlidingWindowLimiter {
    private final int maxCount;        // 窗口内最大请求数
    private final long windowSizeMs;   // 窗口总时长(ms)
    private final int subWindowCount;  // 子窗口数量
    private final Queue<AtomicLong> subWindows = new LinkedList<>();
    private long currentStartTime;
    public SlidingWindowLimiter(int maxCount, long windowSizeMs, int subWindowCount) {
        this.maxCount = maxCount;
        this.windowSizeMs = windowSizeMs;
        this.subWindowCount = subWindowCount;
        long subSize = windowSizeMs / subWindowCount;
        // 初始化子窗口,当前时间对齐到子窗口边界
        currentStartTime = System.currentTimeMillis() - (System.currentTimeMillis() % subSize);
        for (int i = 0; i < subWindowCount; i++) {
            subWindows.add(new AtomicLong(0));
        }
    }
    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        long subSize = windowSizeMs / subWindowCount;
        long currentSubIndex = (now - currentStartTime) / subSize;
        // 滑动窗口:移除过期的子窗口
        while (currentSubIndex >= subWindowCount) {
            subWindows.poll();          // 移除最左(旧)窗口
            subWindows.add(new AtomicLong(0)); // 添加新窗口
            currentStartTime += subSize;
            currentSubIndex--;
        }
        // 统计当前窗口总请求数
        long total = subWindows.stream().mapToLong(AtomicLong::get).sum();
        if (total >= maxCount) {
            return false; // 限流
        }
        // 累加到当前子窗口
        subWindows.get((int) currentSubIndex).incrementAndGet();
        return true;
    }
}

使用示例:允许每2秒100个请求,子窗口10个(每个200ms)。

SlidingWindowLimiter limiter = new SlidingWindowLimiter(100, 2000, 10);
if (limiter.tryAcquire()) {
    // 处理业务
} else {
    // 返回“请求过于频繁”
}

生产级案例:某电商平台秒杀接口限流

场景:秒杀活动开放前10秒,预期QPS突增30倍,若用固定窗口,前9秒流量低,最后1秒冲入峰值,容易击穿缓存。

方案设计

  • Redis + Lua脚本实现分布式滑动窗口:每个用户ID作为Key,窗口长度5秒,子窗口粒度500ms,最大允许3次请求。
  • Java客户端封装:通过Spring Data Redis调用Lua脚本,确保原子性。
  • 降级策略:当Redis故障时,自动降级为本地SlidingWindowLimiter(单机版),保证基本可用。

效果:将突刺流量平滑削掉80%,核心订单服务成功率从91%提升至99.98%。

高频问答:滑动窗口的5个痛点解析

Q1:子窗口数量设置多少合适? A:经验值窗口时间/子窗口 = 5~20个,过少则逼近固定窗口;过多导致内存占用高(每个子窗口对象约几十字节),例如1秒窗口,10个子窗口(100ms)足够平滑。

Q2:滑动窗口能完全替代令牌桶吗? A:不能,令牌桶允许瞬时突发流量(只要桶里有令牌),而滑动窗口是平滑限流,若业务允许突发(如批量拉取),令牌桶更合适;若要求绝对平滑(如支付接口),滑动窗口更佳。

Q3:分布式场景下如何保证多实例一致性? A:使用Redis ZSET实现全局滑动窗口:每个请求以当前毫秒时间戳为Score,窗口启动时删除Score < now - windowSize的成员,再统计ZCOUNT,这种方式比计数器更精确,但注意内存清理定时任务。

Q4:如何处理限流后的“冷启动”问题? A:可采用“预热模式”:在窗口首次启动时,允许“透支”子窗口额度(即限流阈值从10%逐渐提升至100%),避免突发请求全部被拒。

Q5:滑动窗口对高并发性能的影响? A:本地实现需加synchronized保证线程安全,会轻微降低吞吐(约10%-20%),优化:使用LongAdder代替AtomicLong,或采用“时间戳分段锁”减少竞争。



滑动窗口是限流算法中“性价比”最高的方案——比固定窗口严谨,比令牌桶实现简单,在Java生态中,无论是单机ConcurrentQueue实现,还是分布式Redis脚本,都能快速落地。建议在开发环境用JUnit压测对比固定窗口与滑动窗口的QPS曲线差异,你会直观看到“平滑”带来的系统稳定性提升。

抱歉,评论功能暂时关闭!