012-redis应用-05-限流【简单限流、漏斗限流】

一、概述

  限流主要目的控制流量、用于控制用户行为,避免垃圾请求

1.1、简单限流

  限流需求中存在一个滑动时间窗口,适用 zset 数据结构的 score 值,可以通过 score 来圈出这个时间窗口。而且我们只需要保留这个时间窗口,窗口之外的数据都 可以删除。

   zset 的 value 只需要保证唯一性即可,用 uuid 会比较浪费空间,可以使用毫秒时间戳。

    

  如图所示,用一个 zset 结构记录用户的行为历史,每一个行为都会作为 zset 中的一个 key 保存下来。同一个用户同一种行为用一个 zset 记录。

  为节省内存,我们只需要保留时间窗口内的行为记录,同时如果用户是冷用户,滑动时 间窗口内的行为是空记录,那么这个 zset 就可以从内存中移除,不再占用空间。

  通过统计滑动窗口内的行为数量与阈值 max_count 进行比较就可以得出当前的行为是否 允许。用代码表示如下: 

public class SimpleRateLimiter {
    private Jedis jedis;

    public SimpleRateLimiter(Jedis jedis) {
        this.jedis = jedis;
    }

    public boolean isActionAllowed(String userId, String actionKey, int period, int maxCount) {
        String key = String.format("hist:%s:%s", userId, actionKey);
        long nowTs = System.currentTimeMillis();
        Pipeline pipe = jedis.pipelined();
        pipe.multi();
        pipe.zadd(key, nowTs, "" + nowTs);
        pipe.zremrangeByScore(key, 0, nowTs - period * 1000);
        Response<Long> count = pipe.zcard(key);
        pipe.expire(key, period + 1);
        pipe.exec();
        pipe.close();
        return count.get() <= maxCount;
    }

    public static void main(String[] args) {
        Jedis jedis = new Jedis();
        SimpleRateLimiter limiter = new SimpleRateLimiter(jedis);
        for (int i = 0; i < 20; i++) {
            System.out.println(limiter.isActionAllowed("laoqian", "reply", 60, 5));
        }
    }
}

 

  它的整体思路就是:每一个行为到来时,都维护一次时间窗口。将时间窗口外的记录全部清理掉,只保留窗口内的记录。zset 集合中只有 score 值非常重要,value 值没有特别的意义,只需要保证它是唯一的就可 以了。

  因为这几个连续的 Redis 操作都是针对同一个 key 的,使用 pipeline 可以显著提升 Redis 存取效率。但这种方案也有缺点,因为它要记录时间窗口内所有的行为记录,如果这 个量很大,比如限定 60s 内操作不得超过 100w 次这样的参数,它是不适合做这样的限流 的,因为会消耗大量的存储空间。 

1.2、漏斗限流

  漏斗(funnel )限流是最常用的限流方法之一 

  漏洞的容量是有限的,如果将漏嘴堵住,然后一直往里面灌水,它就会变满,直至再也装不进去。如果将漏嘴放开,水就会往下流,流走一部分之后,就又可以继续往里面灌水。如果漏嘴流水的速率大于灌水的速率,那么漏斗永远都装不满。如果漏嘴流水速率小于灌水的速率,那么一旦漏斗满了,灌水就需要暂停并等待漏斗腾空。

  所以,漏斗的剩余空间就代表着当前行为可以持续进行的数量,漏嘴的流水速率代表着系统允许该行为的最大频率。

1.2.1、java版本【自行实现】

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();
        }

        void makeSpace() {
            long nowTs = System.currentTimeMillis();
            long deltaTs = nowTs - leakingTs;
            int deltaQuota = (int) (deltaTs * leakingRate);
            if (deltaQuota < 0) { // 间隔时间太长,整数数字过大溢出
                this.leftQuota = capacity;
                this.leakingTs = nowTs;
                return;
            }
            if (deltaQuota < 1) { // 腾出空间太小,最小单位是 1
                return;
            }
            this.leftQuota += deltaQuota;
            this.leakingTs = nowTs;
            if (this.leftQuota > this.capacity) {
                this.leftQuota = this.capacity;
            }
        }

        boolean watering(int quota) {
            makeSpace();
            if (this.leftQuota >= quota) {
                this.leftQuota -= quota;
                return true;
            }
            return false;
        }
    }

    private Map<String, Funnel> funnels = new HashMap<>();

    public boolean isActionAllowed(String userId, String actionKey, int capacity, float leakingRate) {
        String key = String.format("%s:%s", userId, actionKey);
        Funnel funnel = funnels.get(key);
        if (funnel == null) {
            funnel = new Funnel(capacity, leakingRate);
            funnels.put(key, funnel);
        }
        return funnel.watering(1); // 需要 1 个 quota
    }
}

 

  Funnel 对象的 make_space 方法是漏斗算法的核心,其在每次灌水前都会被调用以触发 漏水,给漏斗腾出空间来。能腾出多少空间取决于过去了多久以及流水的速率。Funnel 对象 占据的空间大小不再和行为的频率成正比,它的空间占用是一个常量。 

1.2.2、Redis-Cell实现

  Redis 4.0 提供了一个限流 Redis 模块,它叫 redis-cell。该模块也使用了漏斗算法,并 提供了原子的限流指令。 该模块只有 1 条指令 cl.throttle 

1.2.2.1、安装方式

官方提供了安装包和源码编译两种方式,源码编译要安装rust环境,比较复杂,这里介绍安装包方式安装:

  1. 根据操作系统下载安装包;
  2. 将文件解压到redis能访问到的路径下;
  3. 进入 redis-cli,执行命令临时启动: module load /path/to/libredis_cell.so;永久:redis-server --loadmodule /aaa/bbb/libredis_cell.so

1.2.2.2、参数说明

cl.throttle test 100 400 60 3

test: redis key

100: 官方叫max_burst,其值为令牌桶的容量 - 1, 首次执行时令牌桶会默认填满,漏斗容量

400: 与下一个参数一起,表示在指定时间窗口内允许访问的次数

60: 指定的时间窗口,单位:秒

3: 表示本次要申请的令牌数,不写则默认为 1

以上命令表示从一个初始值为100的令牌桶中取3个令牌,该令牌桶的速率限制为400次/60秒

127.0.0.1:6379> cl.throttle test 100 400 60 3
1) (integer) 0
2) (integer) 101
3) (integer) 98
4) (integer) -1
5) (integer) 0

1: 是否成功,0:成功,1:拒绝

2: 令牌桶的容量,大小为初始值+1

3: 当前令牌桶中可用的令牌

4: 若请求被拒绝,这个值表示多久后才令牌桶中会重新添加令牌,单位:秒,可以作为重试时间

5: 表示多久后令牌桶中的令牌会存满

1.2.2.3、测试

以一个速率稍慢一点的令牌桶来演示一下,连续快速执行以下命令,每次从桶中取出3个令牌,当桶中令牌不足时,请求被拒绝。

127.0.0.1:6379> CL.THROTTLE test2 10 5 60 3
1) (integer) 0
2) (integer) 11
3) (integer) 8
4) (integer) -1
5) (integer) 36
127.0.0.1:6379> CL.THROTTLE test2 10 5 60 3
1) (integer) 0
2) (integer) 11
3) (integer) 5
4) (integer) -1
5) (integer) 71
127.0.0.1:6379> CL.THROTTLE test2 10 5 60 3
1) (integer) 0
2) (integer) 11
3) (integer) 2
4) (integer) -1
5) (integer) 107
127.0.0.1:6379> CL.THROTTLE test2 10 5 60 3
1) (integer) 1
2) (integer) 11
3) (integer) 2
4) (integer) 10
5) (integer) 106

  在执行限流指令时,如果被拒绝了,就需要丢弃或重试。cl.throttle 指令考虑的非常周 到,连重试时间都帮你算好了,直接取返回结果数组的第四个值进行 sleep 即可,如果不想 阻塞线程,也可以异步定时任务来重试。 

 

posted @ 2020-03-31 08:40  bjlhx15  阅读(811)  评论(1编辑  收藏  举报
Copyright ©2011~2020 JD-李宏旭