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环境,比较复杂,这里介绍安装包方式安装:
- 根据操作系统下载安装包;
- 将文件解压到redis能访问到的路径下;
- 进入 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 即可,如果不想 阻塞线程,也可以异步定时任务来重试。