限流除了控制流量,还有一个目的就是控制用户行为,避免垃圾请求。比如在规定时间内被允许的请求次数,超过一定次数用户行为将受到限制。
简单限流
这个限流需求中存在一个滑动时间窗口(定宽〉,想想zset 数据结构的score值, 是不是可以通过score 来圈出这个时间窗口来。我们只需要保留这个时间窗口,窗口 之外的数据都可以砍掉。那这个zset 的value 填什么比较合适呢?它只需要保证唯一 性即可,用uuid 会比较浪费空间,那就改用毫秒时间戳吧。为节省内存,我们只需要保留时间窗口内的行为记录,同时如果用户是冷用户, 滑动时间窗口内的行为是空记录,那么这个zset 就可以从内存中移除,不再占用空间。 以下是相关代码:
public class SimpleRateLimiter {
private Jedis jedis;
public SimpleRateLimiter(Jedis jedis){
this.jedis = jedis;
}
public static void main(String[] args) {
Jedis jedis = new Jedis("192.168.230.131", 6379);
SimpleRateLimiter limiter = new SimpleRateLimiter(jedis);
for (int i=0;i<=20;i++){
System.out.println(limiter.isActionAllowted("taxue","reply",60,5));
}
}
public boolean isActionAllowted(String userid,String actionkey,int period, int maxCount){
String key = String.format("hist:%s:%s", userid, actionkey);
//时间戳
long nowTs = System.currentTimeMillis();
//创建redis连接
Pipeline pipelined = jedis.pipelined();
pipelined.multi();
//向队列中添加zset结构行为数据
pipelined.zadd(key, nowTs, "" + nowTs);
//删除时间窗口外的数据
pipelined.zremrangeByScore(key,0,nowTs - period*1000);
// 获取时间窗口内的总数
Response<Long> count = pipelined.zcard(key);
// 对key设置过期时间
pipelined.expire(key,period + 1);
pipelined.exec();
pipelined.close();
// 比较数据量是否超标
return count.get() <= maxCount;
}
}
核心部分是通过zset数据结构限定滑动窗口内的用户行为数据。因为这几个连续的Redis 操作都是针对同一个key的,使用pipeline可以显著提升Redis存取效率。但这种方案也有缺点,因为它要记录时间窗口内所有的行为记录, 如果数据量较大,会占用较多内存空间,显然不太合适。
## 漏斗限流
漏斗的容量是有限的,如果将漏嘴堵住,然后一直往里面灌水, 它就会变满,直至再也装不进去。如果将漏嘴放开,水就会往下流,流走一部分之后, 就又可以继续往里面灌水。如果漏嘴流水的速率大于灌水的速率,那么漏斗永远都 装不满。如果漏嘴流水速率小于灌水的速率,那么一旦漏斗满了,灌水就需要暂停 并等待漏斗腾出一部分空间。
public class FunnelRateLimiter {
static class Funnel {
// 漏斗容量
int capacity;
// 漏嘴流水速率
float leakingRate;
// 漏斗剩余空间
int leftQuota;
// 上一次漏水时间
long leakingTs;
public Funnel(int capacity, float leakingRate) {
this.capacity = capacity;
this.leakingRate = leakingRate;
this.leftQuota = capacity;
this.leakingTs = System.currentTimeMillis();
}
synchronized void makeSpace() {
// 当前时间
long nowTs = System.currentTimeMillis();
// 距离上一次漏水间隔时间
long deltaTs = nowTs - leakingTs;
// 间隔时间内流水量
int deltaQuota = (int) (deltaTs * leakingRate);
// 小于0说明时间过长,超出最大值了,重新初始化容量
if (deltaQuota < 0) {
this.leakingRate = capacity;
this.leakingTs = nowTs;
return;
}
if (deltaQuota < 1) {
return;
}
// 将剩余容量和流出的累加
this.leftQuota += deltaQuota;
// 记录本次流水时间
this.leakingTs = nowTs;
// 超出最大值,则用最大值
if (this.leftQuota > this.capacity) {
this.leftQuota = this.capacity;
}
}
synchronized boolean watering(int quota) {
// 计算流水和剩余容量
makeSpace();
// 剩余容量大于等于本次的用量,允许操作
if (this.leftQuota >= quota) {
this.leftQuota -= quota;
return true;
}
return false;
}
}
private static Map<String, Funnel> funnelMap = new ConcurrentHashMap<>();
public static boolean isActionAllowed(String userId, String actionKey, int capacity, float leakingRate) {
String key = String.format("%s:%s", userId, actionKey);
Funnel funnel = funnelMap.get(key);
if (funnel == null) {
funnel = new Funnel(capacity, leakingRate);
funnelMap.put(key, funnel);
}
return funnel.watering(1);
}
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
System.out.println(isActionAllowed("mn", "reply", 5, 1));
}
}
}
Funnel 对象的makespace 方法是漏斗算法的核心,其在每次灌水前都会被调用以触发漏水, 给漏斗腾出空间来。能腾出多少空间取决于过去了多久以及流水的速率。Funnel对象占据的空间大小不再和行为的频率成正比,它的空间占用是一个常量。如果不加锁,无法保证操作的原子性。这里我们选择了加锁,加锁意味着有加锁失败,就会导致重试或者放弃。重试会影响性能,放弃会影响用户体验。
redis-cell模块,可以很好的解决上面问题
redis4.0后提供了redis-cell模块,也使用了漏斗算法,并提供了原子的限流命令。
192.168.230.131:6379> cl.throttle taxue:reply 15 30 60 1
1) (integer) 0 # 0 表示允许, 1 表示拒绝
2) (integer) 16 #漏斗容量capacity
3) (integer) 15 #漏斗剩余空间left quota
4) (integer) -1 #如采被拒绝了,需要多长时间后再试(漏斗有空间了,单位秒)
5) (integer) 2 #多长时间后,漏斗完全空出来( left quota==capacity ,单位秒)
key taxue 15 capacity 这是漏斗容量 30 60 30 operations / 60 seconds 这是流水速率 1 need 1 quota(可选参鼓,默认值也是1)