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