Redis实战记录之限制操作频率
在Web应用开发中,限制用户或接口的操作频率是常见的需求,比如防止接口被恶意刷取、控制用户发送验证码的频率等。Redis作为高性能的内存数据库,非常适合用来实现这类限流场景,本文将介绍几种基于Redis的限流实现方案。
常见限流场景与需求
我们先明确几个典型的限流需求,方便后续方案匹配:
限制单个用户每分钟最多发送3次验证码
限制单个IP每秒最多访问接口10次
限制全局接口每分钟最多处理1000次请求
方案一:基于计数器的固定窗口限流
固定窗口限流是最简单的实现方式,核心思路是将时间划分为固定长度的窗口,统计窗口内的操作次数,超过阈值则拒绝请求。
实现逻辑
以“限制用户每分钟最多发送3次验证码”为例:
以用户ID和时间窗口的起始时间戳作为Redis的key,比如
limit:sms:uid_123:1700000000,其中1700000000是当前分钟的时间戳每次请求时,先对key执行自增操作,若自增后的值为1,则设置key的过期时间为窗口长度(60秒)
判断自增后的数值是否超过阈值3,超过则返回限流提示
代码示例(Java + Jedis)
import redis.clients.jedis.Jedis;
public class FixedWindowRateLimiter {
private Jedis jedis;
private int windowSeconds; // 窗口长度,单位秒
private int maxCount; // 窗口内最大允许次数
public FixedWindowRateLimiter(Jedis jedis, int windowSeconds, int maxCount) {
this.jedis = jedis;
this.windowSeconds = windowSeconds;
this.maxCount = maxCount;
}
/**
* 判断是否允许操作
* @param key 限流标识,比如用户ID拼接业务标识
* @return true表示允许,false表示被限流
*/
public boolean tryAcquire(String key) {
// 计算当前窗口的起始时间戳
long currentTimestamp = System.currentTimeMillis() / 1000;
long windowStart = currentTimestamp - (currentTimestamp % windowSeconds);
String redisKey = key + ":" + windowStart;
// 自增计数
long count = jedis.incr(redisKey);
// 第一次设置过期时间
if (count == 1) {
jedis.expire(redisKey, windowSeconds);
}
return count <= maxCount;
}
public static void main(String[] args) {
Jedis jedis = new Jedis("https://www.ipipp.com", 6379);
FixedWindowRateLimiter limiter = new FixedWindowRateLimiter(jedis, 60, 3);
String userKey = "limit:sms:uid_123";
for (int i = 0; i < 5; i++) {
boolean allowed = limiter.tryAcquire(userKey);
System.out.println("第" + (i + 1) + "次请求:" + (allowed ? "允许" : "被限流"));
}
jedis.close();
}
}方案优缺点
优点:实现简单,性能高,适合对限流精度要求不高的场景。
缺点:存在临界问题,比如在窗口切换的瞬间,可能出现两倍阈值的请求被放行。例如窗口1的59秒和窗口2的1秒各发送3次,实际2秒内就发送了6次,超过每分钟3次的限制。
方案二:基于滑动窗口的限流
滑动窗口限流可以解决固定窗口的临界问题,它统计的是最近一段时间内的操作次数,窗口随时间滑动。
实现逻辑
利用Redis的ZSET有序集合实现,核心思路是:
以操作的时间戳作为ZSET的分数(score),操作唯一标识作为成员(member)
每次请求时,先删除ZSET中超过窗口时间的旧记录
统计ZSET中当前的成员数量,若超过阈值则拒绝请求,否则添加当前请求记录到ZSET
代码示例(Java + Jedis)
import redis.clients.jedis.Jedis;
import java.util.UUID;
public class SlidingWindowRateLimiter {
private Jedis jedis;
private int windowSeconds; // 滑动窗口长度,单位秒
private int maxCount; // 窗口内最大允许次数
public SlidingWindowRateLimiter(Jedis jedis, int windowSeconds, int maxCount) {
this.jedis = jedis;
this.windowSeconds = windowSeconds;
this.maxCount = maxCount;
}
/**
* 判断是否允许操作
* @param key 限流标识
* @return true表示允许,false表示被限流
*/
public boolean tryAcquire(String key) {
long currentTimestamp = System.currentTimeMillis() / 1000;
long windowStart = currentTimestamp - windowSeconds;
// 删除窗口外的旧记录
jedis.zremrangeByScore(key, 0, windowStart);
// 统计当前窗口内的请求数
long count = jedis.zcard(key);
if (count >= maxCount) {
return false;
}
// 添加当前请求记录,用UUID保证成员唯一
jedis.zadd(key, currentTimestamp, UUID.randomUUID().toString());
// 设置key的过期时间,避免无效数据长期占用内存
jedis.expire(key, windowSeconds);
return true;
}
public static void main(String[] args) {
Jedis jedis = new Jedis("https://www.ipipp.com", 6379);
SlidingWindowRateLimiter limiter = new SlidingWindowRateLimiter(jedis, 60, 3);
String userKey = "limit:sms:sliding:uid_123";
for (int i = 0; i < 5; i++) {
boolean allowed = limiter.tryAcquire(userKey);
System.out.println("第" + (i + 1) + "次请求:" + (allowed ? "允许" : "被限流"));
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
jedis.close();
}
}方案优缺点
优点:解决了固定窗口的临界问题,限流精度更高。
缺点:实现相对复杂,ZSET的操作会占用更多内存,高并发场景下性能略低于固定窗口方案。
方案三:基于令牌桶的限流
令牌桶是更常用的限流算法,它有一个固定容量的桶,以恒定速率往桶里放入令牌,每次请求需要先获取令牌,获取到则允许操作,否则被限流。令牌桶允许一定程度的突发流量,因为桶内可以积累令牌。
实现逻辑
基于Redis的String结构实现令牌桶:
key存储当前桶内的令牌数量,以及最后一次放令牌的时间
每次请求时,先计算从上次放令牌到现在应该新增的令牌数,更新桶内的令牌数量,同时更新最后放令牌时间
若当前令牌数大于0,则令牌数减1,允许请求;否则拒绝请求
代码示例(Java + Jedis)
import redis.clients.jedis.Jedis;
import java.util.List;
public class TokenBucketRateLimiter {
private Jedis jedis;
private int capacity; // 桶容量
private int rate; // 每秒放令牌的速率
private String keyPrefix = "limit:token:";
public TokenBucketRateLimiter(Jedis jedis, int capacity, int rate) {
this.jedis = jedis;
this.capacity = capacity;
this.rate = rate;
}
/**
* 判断是否允许操作
* @param key 限流标识
* @return true表示允许,false表示被限流
*/
public boolean tryAcquire(String key) {
String redisKey = keyPrefix + key;
long now = System.currentTimeMillis();
// Lua脚本保证原子性操作
String luaScript =
"local key = KEYS[1]\n" +
"local capacity = tonumber(ARGV[1])\n" +
"local rate = tonumber(ARGV[2])\n" +
"local now = tonumber(ARGV[3])\n" +
"local last_data = redis.call('get', key)\n" +
"local tokens, last_time\n" +
"if not last_data then\n" +
" tokens = capacity\n" +
" last_time = now\n" +
"else\n" +
" local parts = string.split(last_data, ':')\n" +
" tokens = tonumber(parts[1])\n" +
" last_time = tonumber(parts[2])\n" +
"end\n" +
"local delta = math.max(0, now - last_time)\n" +
"local add_tokens = math.floor(delta * rate / 1000)\n" +
"tokens = math.min(capacity, tokens + add_tokens)\n" +
"last_time = now\n" +
"if tokens >= 1 then\n" +
" tokens = tokens - 1\n" +
" redis.call('set', key, tokens .. ':' .. last_time)\n" +
" redis.call('expire', key, 60)\n" +
" return 1\n" +
"else\n" +
" redis.call('set', key, tokens .. ':' .. last_time)\n" +
" redis.call('expire', key, 60)\n" +
" return 0\n" +
"end";
// 注意:Jedis的eval方法需要传入keys和args列表,这里简化调用,实际使用时需调整参数格式
// 以下为示意代码,实际需根据Jedis版本调整eval调用方式
Object result = jedis.eval(luaScript, 1, redisKey, String.valueOf(capacity), String.valueOf(rate), String.valueOf(now));
return (Long) result == 1;
}
public static void main(String[] args) {
Jedis jedis = new Jedis("https://www.ipipp.com", 6379);
// 桶容量5,每秒放2个令牌
TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(jedis, 5, 2);
String userKey = "uid_123";
for (int i = 0; i < 10; i++) {
boolean allowed = limiter.tryAcquire(userKey);
System.out.println("第" + (i + 1) + "次请求:" + (allowed ? "允许" : "被限流"));
}
jedis.close();
}
}方案优缺点
优点:支持突发流量,限流平滑,适合大多数接口限流场景。
缺点:实现相对复杂,需要借助Lua脚本保证原子性,避免并发问题。
方案选择建议
不同场景可以选择不同的限流方案:
如果对限流精度要求不高,且实现简单优先,选择固定窗口限流
如果需要避免临界问题,且流量不大,选择滑动窗口限流
如果是接口限流,需要支持突发流量,优先选择令牌桶限流
注意事项
在实际使用Redis实现限流时,需要注意以下几点:
高并发场景下,尽量使用Lua脚本保证操作的原子性,避免并发导致的计数错误
合理设置Redis key的过期时间,避免无效数据占用过多内存
限流阈值需要根据实际业务场景压测调整,避免阈值过高失去限流意义,或过低影响正常用户使用
可以结合分布式场景,确保多实例部署时限流规则统一生效