在 Spring Boot 中使用 Redis 实现速率限制

1. 概述

速率限制帮助你阻止客户端在某个时间段内过于频繁地调用 API,防止系统过载或被滥用。本文介绍如何使用 Redis 作为集中式计数器,结合 Lua 脚本确保当多个应用实例同时访问时的原子性。读完这篇文章,你将能够编写自己的 RateLimiter,实现固定窗口和滑动窗口算法,通过拦截器集成到 Spring Boot,并编写测试来验证其行为。

2. 为什么选择 Redis 作为速率限制

当应用以多个实例运行(水平扩展)时,存储在每个实例内存中的计数器(例如 AtomicInteger)无法保持同步。Redis 解决了这个问题,因为:

  • 是集中式数据存储,所有实例读写同一个位置。
  • 支持 TTL(EXPIRE)来自动清理旧的 key,无需 cron job。
  • 支持在服务器上原子地运行 Lua 脚本,避免在增加计数器和检查阈值时出现竞态条件。

注意: 如果系统只有单个实例且无需共享状态,使用内存中的限制器(例如 Guava RateLimiter)会更快、更简单。只有在需要分布式速率限制时才应使用 Redis。

3. 配置依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring.data.redis.host=localhost
spring.data.redis.port=6379

Spring Boot 会根据这些属性自动配置 RedisConnectionFactory 和 StringRedisTemplate。

4. 固定窗口算法

固定窗口将时间分成固定区间(例如每 60 秒),计数该区间内的请求数。如果超过阈值,则拒绝。

4.1. 原子 Lua 脚本

使用 Lua 脚本将 INCR 和 EXPIRE 组合成一个原子操作,避免另一个请求在两步之间插入的情况。

public class FixedWindowRateLimiter {

    private static final String SCRIPT = """
        local current = redis.call('INCR', KEYS[1])
        if current == 1 then
            redis.call('EXPIRE', KEYS[1], ARGV[1])
        end
        return current
        """;

    private final StringRedisTemplate redisTemplate;
    private final RedisScript<Long> redisScript;

    public FixedWindowRateLimiter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.redisScript = RedisScript.of(SCRIPT, Long.class);
    }

    public boolean isAllowed(String clientId, int limit, int windowSeconds) {
        String key = "rate:fixed:" + clientId;
        Long current = redisTemplate.execute(
                redisScript,
                List.of(key),
                String.valueOf(windowSeconds));
        return current != null && current <= limit;
    }
}

解释:

  • INCR KEYS[1] 增加 key 的计数器,返回新值。
  • 如果 current == 1(首次创建 key),设置 TTL 为 windowSeconds 秒。
  • 整个脚本在 Redis 服务器上原子地运行,不会被其他请求打断,即使在多线程或多实例环境下。
  • isAllowed 在当前请求数未超过 limit 时返回 true。

4.2. 在 Spring Boot 拦截器中使用

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    private final FixedWindowRateLimiter rateLimiter;

    public RateLimitInterceptor(FixedWindowRateLimiter rateLimiter) {
        this.rateLimiter = rateLimiter;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                              HttpServletResponse response,
                              Object handler) throws IOException {
        String clientId = request.getRemoteAddr();
        boolean allowed = rateLimiter.isAllowed(clientId, 100, 60);
        if (!allowed) {
            response.setStatus(429);
            response.getWriter().write("Too many requests");
            return false;
        }
        return true;
    }
}

拦截器使用客户端 IP 作为 clientId,限制每 60 秒 100 个请求。如果超过阈值,返回 HTTP 429 并阻止请求继续进入控制器。

注意: 固定窗口有“边界突发”的缺点:如果客户端在第 59 秒末发送 100 个请求,然后在第 60 秒初发送 100 个请求,实际上 1 秒内有 200 个请求,尽管每个窗口都是合法的。如果需要更精确,使用滑动窗口。

5. 滑动窗口日志算法

滑动窗口日志在 Sorted Set 中存储每个请求的时间戳,每次检查时移除太旧的时间戳。比固定窗口更精确,但占用内存更多,因为存储了每个请求。

public class SlidingWindowRateLimiter {

    private static final String SCRIPT = """
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local windowMs = tonumber(ARGV[2])
        local limit = tonumber(ARGV[3])

        redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)
        local count = redis.call('ZCARD', key)
        if count < limit then
            redis.call('ZADD', key, now, now)
            redis.call('PEXPIRE', key, windowMs)
            return 1
        end
        return 0
        """;

    private final StringRedisTemplate redisTemplate;
    private final RedisScript<Long> redisScript;

    public SlidingWindowRateLimiter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.redisScript = RedisScript.of(SCRIPT, Long.class);
    }

    public boolean isAllowed(String clientId, int limit, int windowMs) {
        String key = "rate:sliding:" + clientId;
        long now = System.currentTimeMillis();
        Long result = redisTemplate.execute(
                redisScript,
                List.of(key),
                String.valueOf(now),
                String.valueOf(windowMs),
                String.valueOf(limit));
        return result != null && result == 1;
    }
}

Lua 脚本中每一步的解释:

  • ZREMRANGEBYSCORE 删除分数(时间戳)小于 now - windowMs 的条目,即移除已超出时间窗口的请求。
  • ZCARD 计数 Sorted Set 中剩余条目的数量——这正是当前窗口内的请求数。
  • 如果 count < limit,添加新条目,分数为 now,设置 key 的 TTL 为 windowMs(以便在没有流量时自动清理),返回 1(允许)。
  • 如果已满,返回 0(拒绝),不添加新条目。

结果:窗口随着实时时间连续滑动,没有固定窗口在边界处的突发现象。

6. 两种算法的比较

标准 固定窗口 滑动窗口日志
精确度 低,边界有突发 高
内存占用 O(1) 每个 key O(limit) 每个 key
复杂度 简单 更复杂
适用场景 粗粒度限制,高流量 敏感 API,需要精确度

如果不确定选择哪种算法,从固定窗口开始——简单、性能好、足够用于大多数 API。

7. 使用 Testcontainers 编写测试

为了测试不依赖预装的 Redis,使用 Testcontainers 在 Docker 中启动 Redis。

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@Testcontainers
class FixedWindowRateLimiterTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379);

    FixedWindowRateLimiter rateLimiter;

    @BeforeEach
    void setUp() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(
                redis.getHost(), redis.getMappedPort(6379));
        LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
        factory.afterPropertiesSet();

        StringRedisTemplate template = new StringRedisTemplate(factory);
        template.afterPropertiesSet();

        rateLimiter = new FixedWindowRateLimiter(template);
    }

    @Test
    void shouldBlockRequestsExceedingLimit() {
        String clientId = "client-A";
        int limit = 3;

        boolean r1 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r2 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r3 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r4 = rateLimiter.isAllowed(clientId, limit, 60);

        assertThat(r1).isTrue();
        assertThat(r2).isTrue();
        assertThat(r3).isTrue();
        assertThat(r4).isFalse();
    }
}

这个测试连续发送 4 个请求,limit = 3。前三个请求被接受(isTrue),第四个请求因为超过了同一窗口内的阈值而被拒绝(isFalse)。Testcontainers 自动启动和销毁 Redis 容器,确保测试与运行 CI 的环境无关。

8. 常见错误

  • 没有为 key 设置 TTL:如果忘记 EXPIRE,key 会永久存在于 Redis 中,导致内存泄漏,计数器永远不会重置。
  • 用两条独立命令增加计数器和检查阈值(先 GET 再 INCR):当多个请求同时到达时会产生竞态条件,导致实际超过阈值。总是应该将其合并为一个 Lua 脚本或使用原子的 INCR。
  • 使用不区分客户端的 key:如果忘记在 key 中嵌入 clientId,所有客户端会共用一个计数器,导致误伤其他用户。
  • 超大流量下的滑动窗口日志:如果 limit 很大且有大量并发客户端,Sorted Set 会快速膨胀,考虑使用滑动窗口计数器(结合两个固定窗口)来节省更多内存。

9. 结论

Redis 结合 Lua 脚本是构建分布式速率限制器的常见选择:简单、原子性强、易于随应用实例数量扩展。固定窗口因其简洁性适合大多数场景,而滑动窗口日志适合需要在时间边界处精确控制的情况。在投入生产前,务必充分测试阈值边界处的行为,因为这是逻辑错误最容易出现的地方。

redisrate-limitingspring-bootlua-scriptjava