限流算法实现

由于 API 接口无法控制调用方的行为,因此当遇到瞬时请求量激增时,会导致接口占用过多服务器资源,使得其他请求响应速度降低或是超时,更有甚者可能导致服务器宕机。
限流(Ratelimiting)指对应用服务的请求进行限制,例如某一接口的请求限制为 100 个每秒,对超过限制的请求则进行快速失败或丢弃。

拦截器 vs 过滤器在限流中的使用选择

过滤器(Filter)更合理的理由:

  1. 执行时机更早

    • Filter 在请求进入Spring MVC框架之前执行
    • 能够更早地拦截和拒绝过多请求,节省框架初始化开销
  2. 更底层的控制

    • 基于Servlet规范,不依赖Spring MVC框架
    • 可以拦截所有类型的请求(包括静态资源)
  3. 性能优势

    • 避免不必要的Spring上下文初始化
    • 减少请求处理链路长度
  4. 适用场景匹配

    • 适合做通用的基础设施功能(如限流、安全检查)
    • 对所有HTTP请求进行统一处理

拦截器(Interceptor)的局限性:

  1. 执行时机较晚

    • DispatcherServlet之后执行
    • 已经完成了部分Spring MVC框架的初始化工作
  2. 依赖Spring MVC

    • 只能处理经过Spring MVC的请求
    • 无法处理静态资源等非Controller请求

结论

对于固定窗口限流 这种基础设施级别的功能,过滤器更为合理,因为:

  • 能够在最早阶段拒绝过多请求
  • 不依赖具体的应用框架
  • 性能开销更小
  • 控制粒度更粗,适合全局限流策略

使用redis作为中间件

xml 复制代码
<!-- Spring Boot Starter Data Redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

固定窗口计数器算法

固定窗口其实就是时间窗口,其原理是将时间划分为固定大小的窗口,在每个窗口内限制请求的数量或速率,即固定窗口计数器算法规定了系统单位时间处理的请求数量。

java 复制代码
/**
 * 限流器接口
 */
public interface RateLimiter {
    boolean tryAcquire(String key, long limit, long windowMs);
}
java 复制代码
import com.example.demo.infrastructure.RateLimiter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import java.util.Collections;

/**
 * 基于 Redis 的固定窗口限流器
 */
@Slf4j
@Component("redisRateLimiter")
public class RedisFixedWindowRateLimiter implements RateLimiter {

    private final StringRedisTemplate stringRedisTemplate;
    private final boolean failOpen;
    private final String keyPrefix;

    private static final String LUA_SCRIPT =
            "local key = KEYS[1]\n" +
                    "local limit = tonumber(ARGV[1])\n" +
                    "local window_ms = tonumber(ARGV[2])\n" +
                    "local current = redis.call('INCR', key)\n" +
                    "if current == 1 then\n" +
                    "    redis.call('PEXPIRE', key, window_ms)\n" +
                    "end\n" +
                    "if current <= limit then\n" +
                    "    return 1\n" +
                    "else\n" +
                    "    return 0\n" +
                    "end";

    private final DefaultRedisScript<Long> redisScript;

    public RedisFixedWindowRateLimiter(
            StringRedisTemplate stringRedisTemplate,
            @Value("${rate.limit.fail-open:false}") boolean failOpen,
            @Value("${rate.limit.redis.key-prefix:myapp:rl}") String keyPrefix) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.failOpen = failOpen;
        this.keyPrefix = keyPrefix;
        this.redisScript = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
    }

    @Override
    public boolean tryAcquire(String key, long limit, long windowMs) {
        if (key == null || key.isEmpty() || limit <= 0 || windowMs <= 0) {
            log.warn("Invalid rate limit params: key={}, limit={}, windowMs={}", key, limit, windowMs);
            return false;
        }

        // 防止窗口过小导致 TTL=0
        if (windowMs < 10) {
            log.warn("Window too small: {}ms, using 10ms", windowMs);
            windowMs = 10;
        }

        String redisKey = keyPrefix + ":" + key + ":" + (System.currentTimeMillis() / windowMs);

        try {
            Long result = stringRedisTemplate.execute(
                    redisScript,
                    Collections.singletonList(redisKey),
                    String.valueOf(limit),
                    String.valueOf(windowMs)
            );
            boolean allowed = result != null && result == 1L;
            if (!allowed) {
                log.warn("Rate limit exceeded | key={}", key);
            }
            return allowed;
        } catch (Exception e) {
            log.error("Redis rate limiter error | key={}", key, e);
            return failOpen;
        }
    }
}

滑动窗口计数算法

滑动窗口限流算法(Sliding Window Rate Limiting Algorithm) 是一种用于控制系统单位时间内请求频率的流量控制机制。它将时间划分为连续、动态移动的时间窗口,通过统计当前窗口内已发生的请求数量(或估算值),并与预设阈值比较,从而决定是否允许新请求通过

图示 复制代码
         上一窗口                  当前窗口
|----------|----------|----------|----------|
          ↑                     ↑
   1672531200000         1672531201000      now→1672531201500

prev_count = 80        current_count = 60
               ←─────── offset = 500ms ───────┘
weight = 0.5 → 只算 prev_count 的一半 → 40
             total = 60 + 40 = 100 ≤ limit(100) → 放行
java 复制代码
/**
 * 限流器接口
 */
public interface RateLimiter {
    boolean tryAcquire(String key, long limit, long windowMs);
}
java 复制代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import java.util.Arrays;

/**
 * 基于 Redis 的滑动窗口计数器限流器、
 * <p>
 * 算法:Sliding Window Counter(非日志型,避免高内存消耗)
 * 特点:
 * - 使用两个 Redis Key:当前窗口 + 上一窗口
 * - Lua 脚本原子计算估算请求数
 * - 支持毫秒级窗口
 * - 自动清理过期窗口
 */
@Slf4j
@Component("slidingWindowRateLimiter")
public class RedisSlidingWindowRateLimiter implements RateLimiter {

    private final StringRedisTemplate stringRedisTemplate;

    private final boolean failOpen;

    private final String keyPrefix;

    // Lua 脚本:实现滑动窗口计数逻辑
    private static final String LUA_SCRIPT =
            "-- KEYS[1]: current window key\n" +
                    "-- KEYS[2]: previous window key\n" +
                    "-- ARGV[1]: limit\n" +
                    "-- ARGV[2]: window_ms (毫秒)\n" +
                    "-- ARGV[3]: current_time_ms\n" +
                    "\n" +
                    "local current_key = KEYS[1]\n" +
                    "local prev_key = KEYS[2]\n" +
                    "local limit = tonumber(ARGV[1])\n" +
                    "local window_ms = tonumber(ARGV[2])\n" +
                    "local now = tonumber(ARGV[3])\n" +
                    "\n" +
                    "-- 获取当前窗口和上一窗口的计数\n" +
                    "local current_count = redis.call('GET', current_key)\n" +
                    "local prev_count = redis.call('GET', prev_key)\n" +
                    "\n" +
                    "current_count = current_count and tonumber(current_count) or 0\n" +
                    "prev_count = prev_count and tonumber(prev_count) or 0\n" +
                    "\n" +
                    "-- 计算窗口偏移量(当前时间在窗口中的位置)\n" +
                    "local window_start = now - (now % window_ms)\n" +
                    "local offset = now - window_start  -- [0, window_ms)\n" +
                    "\n" +
                    "-- 估算总请求数:当前窗口 + 上一窗口 * (1 - offset/window_ms)\n" +
                    "local weight = (window_ms - offset) / window_ms\n" +
                    "local estimated = current_count + (prev_count * weight)\n" +
                    "\n" +
                    "-- 如果超过限制,拒绝\n" +
                    "if estimated >= limit then\n" +
                    "    return 0\n" +
                    "end\n" +
                    "\n" +
                    "-- 允许请求:增加当前窗口计数,并设置 TTL\n" +
                    "redis.call('INCR', current_key)\n" +
                    "redis.call('PEXPIRE', current_key, window_ms * 2)  -- 保留两个窗口周期\n" +
                    "\n" +
                    "return 1";

    private final DefaultRedisScript<Long> redisScript;

    public RedisSlidingWindowRateLimiter(
            StringRedisTemplate stringRedisTemplate,
            @Value("${rate.limit.fail-open:false}") boolean failOpen,
            @Value("${rate.limit.redis.key-prefix:myapp:rl}") String keyPrefix) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.failOpen = failOpen;
        this.keyPrefix = keyPrefix;
        this.redisScript = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
    }

    @Override
    public boolean tryAcquire(String key, long limit, long windowMs) {
        if (key == null || key.isEmpty() || limit <= 0 || windowMs <= 0) {
            log.warn("Invalid rate limit params: key={}, limit={}, windowMs={}", key, limit, windowMs);
            return false;
        }

        if (windowMs < 10) {
            log.warn("Window too small: {}ms, using 10ms", windowMs);
            windowMs = 10;
        }

        long now = System.currentTimeMillis();
        // 当前窗口编号
        long windowSlot = now / windowMs;

        String currentKey = buildKey(key, windowSlot);
        String prevKey = buildKey(key, windowSlot - 1);

        try {
            Long result = stringRedisTemplate.execute(
                    redisScript,
                    Arrays.asList(currentKey, prevKey),
                    String.valueOf(limit),
                    String.valueOf(windowMs),
                    String.valueOf(now)
            );

            boolean allowed = result != null && result == 1L;
            if (!allowed) {
                log.warn("Sliding window rate limit exceeded | key={}", key);
            }
            return allowed;

        } catch (Exception e) {
            log.error("Redis sliding window rate limiter error | key={}", key, e);
            return failOpen;
        }
    }

    private String buildKey(String key, long slot) {
        return keyPrefix + ":sw:" + key + ":" + slot;
    }
}

漏桶算法

漏桶算法(Leaky Bucket Algorithm) 是一种流量整形与限流机制,它将请求比作"水滴"注入一个固定容量的"桶"中,桶以恒定速率"漏水"(即处理请求);当桶满时,新请求被丢弃或拒绝,从而平滑突发流量、确保系统以稳定速率处理请求。

java 复制代码
/**
 * 限流器接口
 */
public interface RateLimiter {
    boolean tryAcquire(String key, long limit, long windowMs);
}

limit → 桶容量(capacity)
rate = 1000 / windowMs → 每秒处理速率(req/s)
例如:tryAcquire("api", 5, 1000) 表示 桶容量=5,每秒匀速处理1个请求。
java 复制代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import java.util.Collections;

/**
 * 基于 Redis 的漏桶限流算法(Leaky Bucket)实现
 * <p>
 * 特点:
 * - 恒定输出速率,平滑突发流量
 * - 使用惰性漏水策略(Lazy Leaking)
 * - Lua 脚本保证原子性
 * - 支持毫秒级精度
 */
@Slf4j
@Component("leakyBucketRateLimiter")
public class RedisLeakyBucketRateLimiter implements RateLimiter {

    private final StringRedisTemplate stringRedisTemplate;

    private final boolean failOpen;

    private final String keyPrefix;

    // Lua 脚本:实现漏桶惰性漏水逻辑
    private static final String LUA_SCRIPT =
            "-- KEYS[1]: bucket key\n" +
                    "-- ARGV[1]: capacity (桶容量)\n" +
                    "-- ARGV[2]: rate_per_sec (每秒处理请求数)\n" +
                    "-- ARGV[3]: current_time_ms\n" +
                    "\n" +
                    "local key = KEYS[1]\n" +
                    "local capacity = tonumber(ARGV[1])\n" +
                    "local rate = tonumber(ARGV[2])\n" +
                    "local now = tonumber(ARGV[3])\n" +
                    "\n" +
                    "-- 获取当前桶状态: \"water:last_leak_time\"\n" +
                    "local bucket = redis.call('GET', key)\n" +
                    "local water = 0\n" +
                    "local last_leak_time = now\n" +
                    "\n" +
                    "if bucket then\n" +
                    "    local parts = {}\n" +
                    "    for part in string.gmatch(bucket, \"([^:]+)\") do\n" +
                    "        table.insert(parts, part)\n" +
                    "    end\n" +
                    "    water = tonumber(parts[1]) or 0\n" +
                    "    last_leak_time = tonumber(parts[2]) or now\n" +
                    "end\n" +
                    "\n" +
                    "-- 惰性漏水:计算应漏出的水量\n" +
                    "local delta = now - last_leak_time\n" +
                    "if delta > 0 and rate > 0 then\n" +
                    "    local leaked = math.floor((delta * rate) / 1000)\n" +
                    "    water = math.max(0, water - leaked)\n" +
                    "    last_leak_time = now\n" +
                    "end\n" +
                    "\n" +
                    "-- 尝试加入新请求\n" +
                    "if water + 1 <= capacity then\n" +
                    "    water = water + 1\n" +
                    "    -- 更新桶状态,并设置合理 TTL(防止永久残留)\n" +
                    "    redis.call('SET', key, water .. ':' .. last_leak_time, 'PX', 86400000) -- 24h\n" +
                    "    return 1\n" +
                    "else\n" +
                    "    return 0\n" +
                    "end";

    private final DefaultRedisScript<Long> redisScript;

    public RedisLeakyBucketRateLimiter(
            StringRedisTemplate stringRedisTemplate,
            @Value("${rate.limit.fail-open:false}") boolean failOpen,
            @Value("${rate.limit.redis.key-prefix:myapp:rl}") String keyPrefix) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.failOpen = failOpen;
        this.keyPrefix = keyPrefix;
        this.redisScript = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
    }

    /**
     * 语义映射:
     * - limit → 桶容量(capacity)
     * - windowMs → 处理一个请求所需毫秒数 → rate = 1000 / windowMs (req/s)
     */
    @Override
    public boolean tryAcquire(String key, long limit, long windowMs) {
        if (key == null || key.isEmpty() || limit <= 0 || windowMs <= 0) {
            log.warn("Invalid leaky bucket params: key={}, capacity={}, processTimeMs={}", key, limit, windowMs);
            return false;
        }
        // 桶容量
        long capacity = limit;
        // 每秒处理请求数
        double ratePerSec = 1000.0 / windowMs;

        // 防止 rate 过大导致精度问题
        if (ratePerSec > 10000) {
            log.warn("Rate too high: {} req/s, capped at 10000", ratePerSec);
            ratePerSec = 10000;
        }

        String redisKey = keyPrefix + ":lb:" + key;
        long now = System.currentTimeMillis();

        try {
            Long result = stringRedisTemplate.execute(
                    redisScript,
                    Collections.singletonList(redisKey),
                    String.valueOf(capacity),
                    String.valueOf(Math.round(ratePerSec)), // 取整避免 Lua 浮点问题
                    String.valueOf(now)
            );

            boolean allowed = result != null && result == 1L;
            if (!allowed) {
                log.warn("Leaky bucket overflow | key={}", key);
            }
            return allowed;

        } catch (Exception e) {
            log.error("Redis leaky bucket error | key={}", key, e);
            return failOpen;
        }
    }
}

令牌桶算法

令牌桶算法(Token Bucket Algorithm) 是一种流量控制机制,它以恒定速率向一个固定容量的"桶"中添加令牌,每个请求需消耗一个令牌才能被处理;当桶中有足够令牌时,请求可立即通过(支持突发流量),若桶空则请求被限流。该算法既能限制平均请求速率,又能允许一定程度的突发流量,兼具灵活性与稳定性。

java 复制代码
/**
 * 限流器接口
 */
public interface RateLimiter {
    boolean tryAcquire(String key, long limit, long windowMs);
}
limit → 桶容量(capacity)
windowMs → 生成 limit 个令牌所需时间(毫秒)
⇒ 令牌生成速率 rate = limit / (windowMs / 1000) = (limit * 1000) / windowMs (单位:tokens/sec)
💡 示例:tryAcquire("api", 10, 1000) 表示:
桶容量=10,每秒生成10个令牌(即 10 QPS),允许瞬间10个请求通过
java 复制代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import java.util.Collections;

/**
 * 基于 Redis 的令牌桶限流算法(Token Bucket)实现
 * <p>
 * 特点:
 * - 支持突发流量(桶内有令牌即可立即通过)
 * - 恒定速率补充令牌
 * - 惰性计算令牌(Lazy Token Refill)
 * - Lua 脚本保证原子性
 * - 符合阿里生产规范
 */
@Slf4j
@Component("tokenBucketRateLimiter")
public class RedisTokenBucketRateLimiter implements RateLimiter {

    private final StringRedisTemplate stringRedisTemplate;
    private final boolean failOpen;
    private final String keyPrefix;

    // Lua 脚本:实现令牌桶惰性补充逻辑
    private static final String LUA_SCRIPT =
            "-- KEYS[1]: token bucket key\n" +
                    "-- ARGV[1]: capacity (桶容量)\n" +
                    "-- ARGV[2]: rate_per_sec (每秒生成令牌数)\n" +
                    "-- ARGV[3]: current_time_ms\n" +
                    "\n" +
                    "local key = KEYS[1]\n" +
                    "local capacity = tonumber(ARGV[1])\n" +
                    "local rate = tonumber(ARGV[2])\n" +
                    "local now = tonumber(ARGV[3])\n" +
                    "\n" +
                    "-- 获取当前桶状态: \"tokens:last_refill_time\"\n" +
                    "local bucket = redis.call('GET', key)\n" +
                    "local tokens = capacity\n" +
                    "local last_refill = now\n" +
                    "\n" +
                    "if bucket then\n" +
                    "    local parts = {}\n" +
                    "    for part in string.gmatch(bucket, \"([^:]+)\") do\n" +
                    "        table.insert(parts, part)\n" +
                    "    end\n" +
                    "    tokens = tonumber(parts[1]) or capacity\n" +
                    "    last_refill = tonumber(parts[2]) or now\n" +
                    "end\n" +
                    "\n" +
                    "-- 惰性补充令牌\n" +
                    "local delta = now - last_refill\n" +
                    "if delta > 0 and rate > 0 then\n" +
                    "    local new_tokens = math.floor((delta * rate) / 1000)\n" +
                    "    tokens = math.min(capacity, tokens + new_tokens)\n" +
                    "    last_refill = now\n" +
                    "end\n" +
                    "\n" +
                    "-- 尝试获取1个令牌\n" +
                    "if tokens >= 1 then\n" +
                    "    tokens = tokens - 1\n" +
                    "    redis.call('SET', key, tokens .. ':' .. last_refill, 'PX', 86400000) -- TTL 24h\n" +
                    "    return 1\n" +
                    "else\n" +
                    "    return 0\n" +
                    "end";

    private final DefaultRedisScript<Long> redisScript;

    public RedisTokenBucketRateLimiter(
            StringRedisTemplate stringRedisTemplate,
            @Value("${rate.limit.fail-open:false}") boolean failOpen,
            @Value("${rate.limit.redis.key-prefix:myapp:rl}") String keyPrefix) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.failOpen = failOpen;
        this.keyPrefix = keyPrefix;
        this.redisScript = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
    }

    /**
     * 语义映射:
     * - limit → 桶容量(capacity)
     * - windowMs → 生成 limit 个令牌所需时间(毫秒)
     *   ⇒ rate = (limit * 1000) / windowMs (tokens/sec)
     */
    @Override
    public boolean tryAcquire(String key, long limit, long windowMs) {
        if (key == null || key.isEmpty() || limit <= 0 || windowMs <= 0) {
            log.warn("Invalid token bucket params: key={}, capacity={}, refillWindowMs={}", key, limit, windowMs);
            return false;
        }

        long capacity = limit;
        double ratePerSec = (limit * 1000.0) / windowMs;

        // 防止速率过高导致精度问题或 Redis 压力
        if (ratePerSec > 100000) {
            log.warn("Token rate too high: {} tokens/s, capped at 100000", ratePerSec);
            ratePerSec = 100000;
        }

        String redisKey = keyPrefix + ":tb:" + key;
        long now = System.currentTimeMillis();

        try {
            Long result = stringRedisTemplate.execute(
                    redisScript,
                    Collections.singletonList(redisKey),
                    String.valueOf(capacity),
                    String.valueOf(Math.round(ratePerSec)), // 取整避免 Lua 浮点误差
                    String.valueOf(now)
            );

            boolean allowed = result != null && result == 1L;
            if (!allowed) {
                log.warn("Token bucket exhausted | key={}", key);
            }
            return allowed;

        } catch (Exception e) {
            log.error("Redis token bucket error | key={}", key, e);
            return failOpen;
        }
    }
}
算法 核心思想 是否支持突发流量 输出是否匀速 内存/计算开销 精度 典型适用场景
固定窗口(Fixed Window) 将时间划分为固定长度窗口,统计窗口内请求数 ✅ 是(窗口切换时可能双倍突发) ❌ 否 ⭐ 极低(仅计数器) ⭐ 低(存在边界突刺) - 对精度要求不高的粗略限流- 内部系统简单防护
滑动窗口(Sliding Window) 动态移动的时间窗口,精确统计最近 N 毫秒内请求量 ✅ 是 ❌ 否 ⭐⭐ 中(需维护多个窗口或日志) ⭐⭐⭐ 高 - API 网关 QPS 控制- 开放平台接口限流- 防刷、防爬场景
漏桶(Leaky Bucket) 请求入桶,以恒定速率"漏水"处理;桶满则丢弃 ❌ 否 ✅ 是 ⭐⭐ 中(需记录水量和时间) ⭐⭐ 中 - 流量整形(Traffic Shaping)- 保护下游系统免受突发冲击- 消息队列匀速消费
令牌桶(Token Bucket) 以恒定速率生成令牌,请求消耗令牌;桶空则拒绝 ✅ 是(只要桶中有令牌) ❌ 否 ⭐⭐ 中(需记录令牌数和时间) ⭐⭐⭐ 高 - 通用限流(最常用)- 微服务接口保护- 支持突发 + 平均速率控制
相关推荐
Vane13 分钟前
从零开发一个AI插件,经历了什么?
人工智能·后端
9523625 分钟前
SpringBoot统一功能处理
java·spring boot·后端
rleS IONS1 小时前
SpringBoot中自定义Starter
java·spring boot·后端
DevilSeagull1 小时前
MySQL(2) 客户端工具和建库
开发语言·数据库·后端·mysql·服务
TeDi TIVE2 小时前
springboot和springframework版本依赖关系
java·spring boot·后端
雨辰AI2 小时前
SpringBoot3 + 人大金仓 V9 微服务监控实战|Prometheus+Grafana+SkyWalking 全链路监控
数据库·后端·微服务·grafana·prometheus·skywalking
Nicander3 小时前
理解 mybatis 源码:vibe-coding一个mini-mybatis
后端·mybatis
小呆呆6663 小时前
Codex 穷鬼大救星
前端·人工智能·后端
FelixBitSoul4 小时前
缓存淘汰策略全解:从原理到手写实现(Java / Go / Python)
后端·面试
AI人工智能+电脑小能手4 小时前
【大白话说Java面试题】【Java基础篇】第29题:静态代理和动态代理的区别是什么
java·开发语言·后端·面试·代理模式