redis限流

149 阅读2分钟

限流除了控制流量,还有一个目的就是控制用户行为,避免垃圾请求。比如在规定时间内被允许的请求次数,超过一定次数用户行为将受到限制。

简单限流

这个限流需求中存在一个滑动时间窗口(定宽〉,想想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) 00 表示允许, 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)