java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java滑动窗口限流

Java滑动窗口限流算法原理解析

作者:我是一颗柠檬

本文详细解析了限流的必要性、固定窗口与滑动窗口算法原理及应用场景,通过对比分析三种限流算法(固定窗口、滑动窗口、令牌桶),并提供了基于Redis的分布式滑动窗口限流实现方案,帮助你全面掌握限流技术

一、为什么需要限流?

1.1 高并发场景下的系统保护

我见过太多人觉得"我的系统扛得住"。结果流量一来,数据库CPU飙到100%,Redis连接打满,整个链路雪崩。

限流的核心目的就三个:

踩坑提醒:限流不是万能的!我见过有人设置了限流就不做降级,结果限流阈值配高了,系统照样崩。限流+降级+熔断,这三件套缺一不可。

1.2 固定窗口的边界突发问题

固定窗口限流是最简单的实现,比如"1秒内最多100个请求"。但它有个致命的边界问题:

时间轴:|-------第1秒-------|-------第2秒-------|
请求:   [95个]           [95个]
              ^                ^
           最后10ms         开头10ms

假设阈值是1秒100个请求:

这两个时间段加起来只有20ms,却通过了190个请求!系统直接被打爆。

这就是固定窗口的边界突发问题,也是滑动窗口要解决的核心痛点。

二、滑动窗口算法原理

2.1 核心思想:把大窗口切成小格子

滑动窗口的做法很直观:把1秒的大窗口切成10个100ms的小格子,每个格子独立计数。

当前时间:1.35秒
窗口范围:[0.35s ~ 1.35s](共1秒)
格子划分:
| 0.35-0.45 | 0.45-0.55 | 0.55-0.65 | 0.65-0.75 | 0.75-0.85 | 0.85-0.95 | 0.95-1.05 | 1.05-1.15 | 1.15-1.25 | 1.25-1.35 |
|    8      |    12     |    5      |    20     |    15     |    10     |    25     |    18     |    7      |    10     |
总请求数 = 8+12+5+20+15+10+25+18+7+10 = 130
如果阈值是100,当前请求就会被拒绝。

2.2 滑动过程详解

时间每往前走一格,窗口就"滑动"一下:

T1时刻窗口:[0.0 ~ 1.0]  包含格子 G1,G2,G3,G4,G5,G6,G7,G8,G9,G10
T2时刻(过了100ms):
- 丢弃 G1(已经滑出窗口)
- 新增 G11(进入窗口)
- 新窗口:[0.1 ~ 1.1] 包含 G2~G11

用代码思维理解就是:只统计当前时间往前推1秒内的所有格子请求数之和

2.3 关键参数

参数说明建议值
窗口大小统计的时间范围1秒
格子数量窗口切分的粒度10个
格子时长每个格子代表的时间100ms
阈值窗口内允许的最大请求数根据压测结果设定

格子数量越多,精度越高,但内存占用和计算量也越大。后面会详细讲怎么选。

三、三种限流算法对比

3.1 滑动窗口 vs 固定窗口 vs 令牌桶

维度固定窗口滑动窗口令牌桶
精度低,有边界突发问题高,平滑过渡高,支持突发流量
内存占用极低,只需一个计数器中等,需维护多个格子低,只需记录令牌数
实现复杂度简单,几行代码搞定中等,需维护窗口状态中等,需定时生成令牌
突发流量处理差,边界会双倍突发一般,窗口内平滑好,允许瞬间突发
适用场景简单统计、日志限流API接口限流、Sentinel网络流量整形、带宽控制
分布式实现容易较复杂(Redis ZSet)较复杂(需同步令牌)

3.2 怎么选?

说实话,大部分互联网公司的核心接口限流,用的都是滑动窗口或者令牌桶。固定窗口基本只出现在日志限流这种非关键场景。

四、Java实现滑动窗口限流

4.1 基于本地内存的实现(环形数组)

这个实现适合单机限流,性能最好,无外部依赖。

import java.util.Arrays;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
/**
 * 基于环形数组的滑动窗口限流器
 * 
 * 核心设计:
 * 1. 用环形数组存储每个格子的请求计数
 * 2. 通过时间戳计算当前请求落在哪个格子
 * 3. 滑动时清空过期格子的计数
 */
public class SlidingWindowRateLimiter {
    /** 窗口大小(毫秒) */
    private final long windowSizeMs;
    /** 格子数量 */
    private final int gridCount;
    /** 每个格子的时长(毫秒) */
    private final long gridDurationMs;
    /** 限流阈值 */
    private final int threshold;
    /** 环形数组,存储每个格子的请求数 */
    private final AtomicInteger[] grids;
    /** 每个格子最后更新的时间戳 */
    private final long[] gridTimestamps;
    /** 保护数组更新的锁 */
    private final ReentrantLock lock = new ReentrantLock();
    public SlidingWindowRateLimiter(long windowSizeMs, int gridCount, int threshold) {
        this.windowSizeMs = windowSizeMs;
        this.gridCount = gridCount;
        this.gridDurationMs = windowSizeMs / gridCount;
        this.threshold = threshold;
        this.grids = new AtomicInteger[gridCount];
        this.gridTimestamps = new long[gridCount];
        for (int i = 0; i < gridCount; i++) {
            grids[i] = new AtomicInteger(0);
            gridTimestamps[i] = -1;
        }
    }
    /**
     * 尝试获取一个请求配额
     * @return true-允许通过,false-被限流
     */
    public boolean tryAcquire() {
        long now = System.currentTimeMillis();
        // 计算当前时间落在哪个格子
        int currentGridIndex = (int) ((now / gridDurationMs) % gridCount);
        lock.lock();
        try {
            // 检查当前格子是否过期(不是本次窗口的)
            long gridStartTime = (now / gridDurationMs) * gridDurationMs;
            if (gridTimestamps[currentGridIndex] != gridStartTime) {
                // 格子过期,重置计数
                grids[currentGridIndex].set(0);
                gridTimestamps[currentGridIndex] = gridStartTime;
            }
            // 计算当前窗口内的总请求数
            int total = calculateCurrentWindowCount(now);
            if (total < threshold) {
                grids[currentGridIndex].incrementAndGet();
                return true;
            }
            return false;
        } finally {
            lock.unlock();
        }
    }
    /**
     * 计算当前滑动窗口内的请求总数
     */
    private int calculateCurrentWindowCount(long now) {
        int total = 0;
        long windowStart = now - windowSizeMs;
        for (int i = 0; i < gridCount; i++) {
            if (gridTimestamps[i] > windowStart) {
                total += grids[i].get();
            }
        }
        return total;
    }
    /**
     * 获取当前窗口的实时QPS(用于监控)
     */
    public int getCurrentQps() {
        lock.lock();
        try {
            return calculateCurrentWindowCount(System.currentTimeMillis());
        } finally {
            lock.unlock();
        }
    }
    @Override
    public String toString() {
        return "SlidingWindowRateLimiter{" +
                "windowSizeMs=" + windowSizeMs +
                ", gridCount=" + gridCount +
                ", threshold=" + threshold +
                ", currentQps=" + getCurrentQps() +
                '}';
    }
    // ==================== 测试代码 ====================
    public static void main(String[] args) throws InterruptedException {
        // 1秒窗口,10个格子,阈值100
        SlidingWindowRateLimiter limiter = new SlidingWindowRateLimiter(1000, 10, 100);
        System.out.println("=== 测试1:正常请求 ===");
        int pass = 0, reject = 0;
        for (int i = 0; i < 120; i++) {
            if (limiter.tryAcquire()) {
                pass++;
            } else {
                reject++;
            }
        }
        System.out.println("通过: " + pass + ", 拒绝: " + reject);
        // 预期:通过100,拒绝20
        System.out.println("\n=== 测试2:等待窗口滑动后再次请求 ===");
        Thread.sleep(200); // 等待200ms
        pass = 0; reject = 0;
        for (int i = 0; i < 50; i++) {
            if (limiter.tryAcquire()) {
                pass++;
            } else {
                reject++;
            }
        }
        System.out.println("通过: " + pass + ", 拒绝: " + reject);
        System.out.println("\n=== 测试3:模拟突发流量 ===");
        SlidingWindowRateLimiter limiter2 = new SlidingWindowRateLimiter(1000, 10, 10);
        // 快速发送20个请求
        pass = 0; reject = 0;
        for (int i = 0; i < 20; i++) {
            if (limiter2.tryAcquire()) {
                pass++;
            } else {
                reject++;
            }
        }
        System.out.println("通过: " + pass + ", 拒绝: " + reject);
        // 预期:通过10,拒绝10
        System.out.println("\n=== 运行结果 ===");
        System.out.println("本地内存版滑动窗口限流器测试完成!");
    }
}

运行结果:

=== 测试1:正常请求 ===
通过: 100, 拒绝: 20
=== 测试2:等待窗口滑动后再次请求 ===
通过: 50, 拒绝: 0
=== 测试3:模拟突发流量 ===
通过: 10, 拒绝: 10
=== 运行结果 ===
本地内存版滑动窗口限流器测试完成!

4.2 基于Redis ZSet的分布式实现

分布式场景下,多个实例需要共享限流状态,Redis ZSet是经典方案。

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.Response;
/**
 * 基于Redis ZSet的分布式滑动窗口限流器
 * 
 * 核心原理:
 * 1. 用ZSet的score存储请求时间戳
 * 2. 每次请求时,先删除窗口外的旧记录(ZREMRANGEBYSCORE)
 * 3. 统计当前窗口内的记录数(ZCARD)
 * 4. 如果未超限,添加当前请求记录(ZADD)
 * 
 * 踩坑提醒:这个实现需要Redis 2.6+支持lua脚本才能保证原子性!
 * 下面提供的是Pipeline版本,生产环境建议用lua脚本版本。
 */
public class RedisSlidingWindowRateLimiter {
    private final JedisPool jedisPool;
    private final String keyPrefix;
    private final long windowSizeMs;
    private final int threshold;
    public RedisSlidingWindowRateLimiter(String redisHost, int redisPort, 
                                          String keyPrefix, long windowSizeMs, int threshold) {
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(100);
        config.setMaxIdle(20);
        this.jedisPool = new JedisPool(config, redisHost, redisPort);
        this.keyPrefix = keyPrefix;
        this.windowSizeMs = windowSizeMs;
        this.threshold = threshold;
    }
    /**
     * 尝试获取配额(Pipeline版本,非原子性,适合容忍少量误差的场景)
     */
    public boolean tryAcquire(String resource) {
        String key = keyPrefix + ":" + resource;
        long now = System.currentTimeMillis();
        long windowStart = now - windowSizeMs;
        try (Jedis jedis = jedisPool.getResource()) {
            Pipeline pipeline = jedis.pipelined();
            // 1. 删除窗口外的旧记录
            pipeline.zremrangeByScore(key, 0, windowStart);
            // 2. 统计当前窗口内的记录数
            Response<Long> countResponse = pipeline.zcard(key);
            // 3. 执行前两条命令
            pipeline.sync();
            long currentCount = countResponse.get();
            if (currentCount < threshold) {
                // 4. 添加当前请求记录
                jedis.zadd(key, now, now + "_" + Thread.currentThread().getId());
                // 5. 设置过期时间,防止key残留
                jedis.pexpire(key, windowSizeMs);
                return true;
            }
            return false;
        }
    }
    /**
     * 原子性版本:使用Lua脚本
     * 生产环境强烈推荐用这个!
     */
    public boolean tryAcquireAtomic(String resource) {
        String key = keyPrefix + ":" + resource;
        long now = System.currentTimeMillis();
        long windowStart = now - windowSizeMs;
        // Lua脚本保证整个操作的原子性
        String luaScript = 
            "local key = KEYS[1] " +
            "local now = tonumber(ARGV[1]) " +
            "local windowStart = tonumber(ARGV[2]) " +
            "local threshold = tonumber(ARGV[3]) " +
            "local expire = tonumber(ARGV[4]) " +
            "-- 删除窗口外的记录 " +
            "redis.call('ZREMRANGEBYSCORE', key, 0, windowStart) " +
            "-- 获取当前窗口内记录数 " +
            "local count = redis.call('ZCARD', key) " +
            "if count < threshold then " +
            "    redis.call('ZADD', key, now, ARGV[5]) " +
            "    redis.call('PEXPIRE', key, expire) " +
            "    return 1 " +
            "else " +
            "    return 0 " +
            "end";
        try (Jedis jedis = jedisPool.getResource()) {
            Object result = jedis.eval(luaScript, 
                Arrays.asList(key), 
                Arrays.asList(
                    String.valueOf(now),
                    String.valueOf(windowStart),
                    String.valueOf(threshold),
                    String.valueOf(windowSizeMs),
                    now + "_" + Thread.currentThread().getId() + "_" + Math.random()
                )
            );
            return Long.valueOf(1).equals(result);
        }
    }
    /**
     * 获取当前QPS(用于监控)
     */
    public long getCurrentQps(String resource) {
        String key = keyPrefix + ":" + resource;
        long windowStart = System.currentTimeMillis() - windowSizeMs;
        try (Jedis jedis = jedisPool.getResource()) {
            jedis.zremrangeByScore(key, 0, windowStart);
            return jedis.zcard(key);
        }
    }
    public void close() {
        if (jedisPool != null && !jedisPool.isClosed()) {
            jedisPool.close();
        }
    }
    // ==================== 测试代码 ====================
    public static void main(String[] args) {
        // 注意:需要本地启动Redis,端口6379
        RedisSlidingWindowRateLimiter limiter = new RedisSlidingWindowRateLimiter(
            "localhost", 6379, "rate_limit", 1000, 10
        );
        System.out.println("=== Redis分布式滑动窗口限流测试 ===");
        String resource = "api:/order/create";
        int pass = 0, reject = 0;
        for (int i = 0; i < 15; i++) {
            if (limiter.tryAcquireAtomic(resource)) {
                pass++;
                System.out.println("请求 " + (i+1) + " -> 通过");
            } else {
                reject++;
                System.out.println("请求 " + (i+1) + " -> 被拒绝");
            }
        }
        System.out.println("\n总计:通过=" + pass + ", 拒绝=" + reject);
        System.out.println("当前QPS:" + limiter.getCurrentQps(resource));
        limiter.close();
    }
}

踩坑提醒:Redis ZSet实现有个坑——如果请求量极大,ZSet会不断膨胀,即使设置了过期时间,在过期前内存占用也会很高。建议配合ZREMRANGEBYSCORE及时清理,或者考虑用Redis Cell模块。

五、Sentinel中的滑动窗口实现

5.1 LeapArray设计

Sentinel的滑动窗口实现叫LeapArray,设计非常精巧。

核心结构:

// 简化的LeapArray核心结构
public abstract class LeapArray<T> {
    // 窗口时长(默认1秒)
    protected int windowLengthInMs;
    // 采样数量(默认2个,即每个窗口500ms)
    protected int sampleCount;
    // 窗口数组(环形)
    protected final AtomicReferenceArray<WindowWrap<T>> array;
    // 计算当前时间对应的窗口索引
    private int calculateTimeIdx(long timeMillis) {
        long timeId = timeMillis / windowLengthInMs;
        return (int) (timeId % array.length());
    }
    // 计算窗口开始时间
    protected long calculateWindowStart(long timeMillis) {
        return timeMillis - timeMillis % windowLengthInMs;
    }
}

Sentinel默认把1秒分成2个500ms的窗口,而不是更细的粒度。这是精度和性能的平衡:

参数默认值说明
sampleCount21秒分2个窗口
windowLengthInMs500每个窗口500ms
intervalInMs1000统计周期1秒

5.2 Sentinel的实际应用

Sentinel用滑动窗口统计什么?

  1. QPS统计:每秒请求数,用于限流判断
  2. RT统计:平均响应时间,用于熔断判断
  3. 异常比例:异常请求占比,用于熔断判断
  4. 并发线程数:当前处理的线程数,用于线程隔离
// Sentinel中滑动窗口的使用示例
Entry entry = null;
try {
    entry = SphU.entry("orderCreate");
    // 业务逻辑
} catch (BlockException e) {
    // 被限流或熔断
    return "系统繁忙,请稍后再试";
} finally {
    if (entry != null) {
        entry.exit();
    }
}

Sentinel的StatisticSlot会在entry.exit()时更新滑动窗口的统计数据,后续的FlowSlot(限流)和DegradeSlot(熔断)基于这些统计数据做决策。

六、问题与解答

Q1: 滑动窗口的格子数怎么选?

格子数越多,精度越高,但内存和CPU开销也越大。

经验法则:

Sentinel默认2个格子,因为它还要兼顾RT、异常数等多种统计,不能搞太细。

Q2: 滑动窗口和令牌桶哪个更好?

没有绝对的好坏,看场景:

电商下单接口用滑动窗口,视频播放带宽用令牌桶。

Q3: Redis ZSet实现有什么缺点?

三个主要缺点:

  1. 内存开销大:每个请求都要在ZSet里存一条记录,QPS高的时候内存爆炸
  2. 清理开销:每次请求都要ZREMRANGEBYSCORE清理过期数据,高并发时Redis压力大
  3. 精度问题:Pipeline版本非原子性,极端情况下可能超卖

优化方案:

七、面试高频考点

考点1:滑动窗口限流原理是什么?

答案: 滑动窗口将时间窗口划分为多个小格子,每个格子独立计数。每次请求时,只统计当前时间往前推一个窗口周期内的所有格子请求数之和,如果总和超过阈值则拒绝请求。相比固定窗口,它解决了边界突发问题,因为窗口是连续滑动的,不会出现两个相邻窗口边界处请求翻倍的情况。

考点2:三种限流算法(固定窗口、滑动窗口、令牌桶)有什么区别?

答案:

考点3:Sentinel中的LeapArray是怎么实现的?

答案: Sentinel使用LeapArray实现滑动窗口统计。核心是一个环形数组,每个元素是一个时间窗口。默认1秒分为2个500ms的窗口。通过calculateTimeIdx计算当前时间对应的窗口索引,通过CAS操作保证线程安全。LeapArray统计的数据包括QPS、RT、异常数、并发线程数等,供限流和熔断规则使用。

考点4:如何用Redis实现分布式滑动窗口限流?

答案: 使用Redis ZSet,以请求时间戳作为score,请求唯一标识作为member。每次请求时:

  1. ZREMRANGEBYSCORE删除窗口外的过期记录
  2. ZCARD统计当前窗口内的记录数
  3. 如果未超限,用ZADD添加当前请求记录
  4. PEXPIRE设置key过期时间

生产环境必须用Lua脚本保证原子性,避免竞态条件。

考点5:限流算法选择要考虑哪些因素?

答案:

  1. 精度要求:固定窗口<滑动窗口≈令牌桶
  2. 突发容忍:令牌桶>滑动窗口>固定窗口
  3. 实现复杂度:固定窗口<令牌桶<滑动窗口
  4. 分布式支持:固定窗口最容易,滑动窗口和令牌桶都需要外部存储
  5. 内存占用:固定窗口最低,滑动窗口与格子数成正比

八、模拟面试官提问

场景题1:设计一个接口限流系统

面试官: 假设你要给公司的API网关设计一套限流系统,支持按用户、按接口、按IP多维度限流,你会怎么设计?

参考答案:

  1. 分层限流:网关层做粗粒度限流(IP、全局QPS),业务层做细粒度限流(用户ID、接口级别)
  2. 存储选型:本地内存做第一层过滤(Guava RateLimiter或自研滑动窗口),Redis做分布式共享状态
  3. 维度设计:限流key设计为rate_limit:{维度}:{标识}:{接口},如rate_limit:ip:192.168.1.1:/api/order
  4. 动态配置:接入配置中心(Nacos/Apollo),支持限流阈值实时调整
  5. 降级策略:Redis不可用时,降级为本地限流,保证服务可用性

场景题2:秒杀场景限流

面试官: 秒杀活动,预计每秒10万人点击,但只有100个库存,怎么设计限流?

参考答案:

  1. 多层过滤
    • CDN层:静态资源缓存,过滤大部分无效请求
    • Nginx层:IP限流,单IP每秒最多10次
    • 网关层:用户ID限流,单用户每秒最多1次
    • 业务层:库存预扣,用Redis原子减库存,减成功才放行
  2. 滑动窗口设置:用户维度1秒1个请求,全局维度根据库存和持续时间计算
  3. 排队机制:超过限流的请求进入MQ排队,而不是直接拒绝,提升用户体验
  4. 热点隔离:对秒杀商品ID做哈希分散,避免单点热点

场景题3:分布式限流的一致性

面试官: 10个服务实例,每个实例本地限流100QPS,理论上全局是1000QPS。但如果负载不均,某个实例承担了50%流量,怎么办?

参考答案:

  1. 问题本质:本地限流无法感知全局流量分布
  2. 方案一:Redis集中式限流,所有实例共享计数器,但引入网络开销和Redis单点压力
  3. 方案二:令牌桶+本地缓存,定期从中心节点同步令牌,平衡一致性和性能
  4. 方案三(推荐):自适应限流,每个实例根据CPU、内存、RT等指标动态调整本地阈值,配合全局熔断
  5. Sentinel做法:支持集群流控,通过Token Server集中管理,但有一定延迟

场景题4:滑动窗口内存优化

面试官: 滑动窗口需要维护每个格子的计数,如果服务有10万个限流key,每个key 10个格子,内存占用会很大,怎么优化?

参考答案:

  1. 惰性创建:不是预先分配所有key的窗口,而是请求到来时才创建对应的滑动窗口
  2. 过期清理:用WeakReference或定时任务清理长时间无请求的key的窗口数据
  3. 内存压缩:如果格子数固定且较少,可以用long[]数组代替对象数组
  4. 分层存储:热key放在本地内存,冷key降级到Redis或Caffeine缓存
  5. Sentinel方案:LeapArray的窗口数组是共享的,不同资源复用同一套时间窗口结构

场景题5:限流与熔断结合

面试官: 限流和熔断有什么区别?实际项目中怎么配合用?

参考答案:

  1. 区别
    • 限流:主动防御,防止流量过大压垮系统,基于请求速率
    • 熔断:被动保护,当错误率过高时切断请求,基于错误统计
  2. 配合方式
    • 流量正常时:限流控制并发,保证系统稳定运行
    • 流量突增时:限流拒绝超额请求,返回降级结果
    • 下游故障时:熔断触发,直接返回降级结果,不再调用下游
    • 下游恢复时:熔断半开,逐步放行请求探测
  3. Sentinel实践FlowSlot做限流,DegradeSlot做熔断,SystemSlot做系统保护,三者按优先级依次执行

九、互动话题

你在生产环境用过哪种限流方案?有没有遇到过固定窗口的边界突发问题?欢迎在评论区分享你的踩坑经历!

十、参考资料

  1. Sentinel官方文档 - 流量控制
  2. Redis官方文档 - ZSet命令
  3. Rate Limiting系列文章 - Cloudflare博客
  4. 阿里巴巴Sentinel源码 - GitHub
  5. Redis Cell模块文档

到此这篇关于Java滑动窗口限流算法原理解析的文章就介绍到这了,更多相关Java滑动窗口限流内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文