限流算法实现

由于 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) 以恒定速率生成令牌,请求消耗令牌;桶空则拒绝 ✅ 是(只要桶中有令牌) ❌ 否 ⭐⭐ 中(需记录令牌数和时间) ⭐⭐⭐ 高 - 通用限流(最常用)- 微服务接口保护- 支持突发 + 平均速率控制
相关推荐
吴祖贤2 小时前
4.6 Docker Model Runner Chat
后端
用户221765927922 小时前
python有哪些方案可以处理多线程请求接口时结果的顺序问题?
后端
间彧2 小时前
💻 Windows服务器K8s学习与SpringBoot部署实战指南
后端
FreeCode2 小时前
LangChain1.0智能体开发:MCP
后端·langchain·agent
前端小张同学2 小时前
基础需求就用AI写代码,你会焦虑吗?
java·前端·后端
zyb_1234563 小时前
手把手带你入门 TypeORM —— 面向新手的实战指南
后端
爱吃程序猿的喵3 小时前
Spring Boot 常用注解全面解析:提升开发效率的利器
java·spring boot·后端
zyb_1234563 小时前
NestJS 集成 RabbitMQ(CloudAMQP)实战指南
后端
吴祖贤3 小时前
3.3 Spring AI Advisors API
后端