侧边栏壁纸
  • 累计撰写 93 篇文章
  • 累计创建 85 个标签
  • 累计收到 9 条评论

分布式限流 - 几种解决方案

bearjun
2021-02-20 / 0 评论 / 0 点赞 / 550 阅读 / 6,367 字 / 正在检测是否收录...
温馨提示:
本文最后更新于 2021-02-20,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

分布式限流

分布式限流的几种维度

QPS和连接数控制

56679-5sn7bhbxhe4.png
针对上图中的连接数和QPS(query per second)限流来说,我们可以设定IP维度的限流,也可以设置基于单个服务器的限流。在真实环境中通常会设置多个维度的限流规则,比如设定同一个IP每秒访问频率小于10,连接数小于5,再设定每台机器QPS最高1000,连接数最大保持200。更进一步,我们可以把某个服务器组或整个机房的服务器当做一个整体,设置更high-level的限流规则,这些所有限流规则都会共同作用于流量控制。
在稍后的小节里,我们的实践Demo部分将主要围绕在QPS和连接数控制的限流规则。

传输速率

对于“传输速率”大家都不会陌生,比如资源的下载速度。有的网站在这方面的限流逻辑做的更细致,比如普通注册用户下载速度为100k/s,购买会员后是10M/s,这背后就是基于用户组或者用户标签的限流逻辑。
在稍后的小节我们会给大家展示如何在Nginx中限制传输速度。

黑白名单

黑白名单是各个大型企业应用里很常见的限流和放行手段,而且黑白名单往往是动态变化的。举个例子,如果某个IP在一段时间的访问次数过于频繁,被系统识别为机器人用户或流量攻击,那么这个IP就会被加入到黑名单,从而限制其对系统资源的访问,这就是我们俗称的“封IP”。
我们平时见到的爬虫程序,比如说爬知乎上的美女图片,或者爬券商系统的股票分时信息,这类爬虫程序都必须实现更换IP的功能,以防被加入黑名单。有时我们还会发现公司的网络无法访问12306这类大型公共网站,这也是因为某些公司的出网IP是同一个地址,因此在访问量过高的情况下,这个IP地址就被对方系统识别,进而被添加到了黑名单。使用家庭宽带的同学们应该知道,大部分网络运营商都会将用户分配到不同出网IP段,或者时不时动态更换用户的IP地址。
白名单就更好理解了,相当于御赐金牌在身,可以自由穿梭在各种限流规则里,畅行无阻。比如某些电商公司会将超大卖家的账号加入白名单,因为这类卖家往往有自己的一套运维系统,需要对接公司的IT系统做大量的商品发布、补货等等操作。

分布式限流的主流方案

Guava乱入

说起Guava大家一定不陌生,它是Google出品的一款工具包(com.google.guava),我们经常用它做一些集合操作比如Lists.newArrayList(),它最早源于2007年的"Google Collections Library"项目。Guava不甘于将自己平凡的一生都耗费在Collections上面,于是乎它开始了转型,慢慢扩展了自己在Java领域的影响力,从反射工具、函数式编程、安全验证、数学运算等等方面,都提供了响应的工具包。
在限流这个领域中,Guava也贡献了一份绵薄之力,在其多线程模块下提供了以RateLimiter为首的几个限流支持类。我们前面提到了,Guava是一个客户端组件,也就是说它的作用范围仅限于“当前”这台服务器,不能对集群以内的其他服务器施加流量控制。
51489-wdkga4mq5ae.png
打个比方,目前我有2台服务器[Server 1,Server 2],这两台服务器都部署了一个登陆服务,假如我希望对这两台机器的流量进行控制,比如将两台机器的访问量总和控制在每秒20以内,如果用Guava来做,只能独立控制每台机器的访问量<=10。
这里不过多介绍:[https://juejin.cn/post/6844903783432978439][1]

Nginx限流

官方文档地址:[http://nginx.org/en/docs/][2]

官网中提供了限流的两个模块:一种是漏桶算法,一种是令牌桶算法.
ngx_http_limit_conn_module 用来限制同一时间连接数,即并发限制。
ngx_http_limit_req_module 用来限制单位时间内的请求数目,以及速度限制。

limit_req 和limit_conn 配置

# 放在http{} 内
limit_req_zone $binary_remote_addr/$server_name zone=name:numer m rate=number r/s;

limit_conn_zone $binary_remote_addr/$server_name zone=name:name:numer m;
  • 第一个参数:$binary_remote_addr 表示通过remote_addr这个标识来做限制,“binary_”的目的是缩写内存占用量,是限制同一客户端ip地址;server_name通过服务来做限制。
  • 第二个参数:zone=iplimit:10m表示生成一个大小为10M,名字为iplimit的内存区域,用来存储访问的频次信息。
  • 第三个参数:rate=1r/s表示允许相同标识的客户端的访问频次,这里限制的是每秒1次,还可以有比如30r/m的。
# 放在server{}内
limit_req zone=name burst=1 nodelay;
limit_conn perip 1;
  • 第一个参数:zone=iplimit设置使用哪个配置区域来做限制,与上面limit_req_zone 里的name对应。
  • 第二个参数:burst=1,重点说明一下这个配置,burst爆发的意思,这个配置的意思是设置一个大小为5的缓冲区当有大量请求(爆发)过来时,超过了访问频次限制的请求可以先放到这个缓冲区内。
  • 第三个参数:nodelay,如果设置,超过访问频次而且缓冲区也满了的时候就会直接返回503,如果没有设置,则所有请求会等待排队。

完整的配置如下:

http {
  # 基于ip地址的限制
  limit_req_zone $binary_remote_addr zone=iplimit:10m rate=1r/s;

  # 基于服务器级别的配置
  limit_req_zone $server_name zone=serverlimit:10m rate=1r/s;

  # 基于IP地址连接数的配置
  limit_conn_zone $binary_remote_addr zone=perip:20m;

  # 基于服务器连接数的配置
  limit_conn_zone $server_name zone=perserver:20m;
  server {
      server_name www.bearjun.com;
      location / {
         proxy_pass http://127.0.0.1/;
         # 基于ip地址的限制
         limit_req zone=iplimit burst=1 nodelay;
         # 基于服务器级别的限制,一般的,server级别的限流速率最大
         limit_req zone=serverlimit burst=100 nodelay;
         # 每个server保持100个链接
         limit_conn perserver 100;
         # 每个ip地址保持1个链接
         limit_conn perip 1;
      }  
  }
}

中间件限流(redis + lua)

Redis,作为缓存组件,如果不采用持久化方案的话,Redis的大部分操作都是纯内存操作,性能十分优异。而且线程安全,只用单线程承接网络请求(其他模块仍然多线程),天然具有线程安全的特性,而且对原子性操作的支持非常到位。限流服务不仅需要承接超高QPS,还需要保证限流逻辑的执行层面具备线程安全的特性。利用Redis的这些天然特性做限流,既能保证线程安全,也能保持良好的性能。

LUA教程:[https://www.runoob.com/lua/lua-tutorial.html][3]
话不多少,直接上代码,

  • maven相关依赖
<!--guava-->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>18.0</version>
</dependency>
<!--spring-data-redis-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--aop-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!--lombok-->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>

lua脚本,放在resources下的rateLimiter.lua

-- 获取方法签名的特征
local methodKey = KEYS[1]
-- 添加redis日志,打印到redis的控制台
redis.log(redis.LOG_DEBUG, 'key is ', methodKey)

-- 调用脚本传入的限流大小
local limit = tonumber(ARGV[1])

-- 获取当前流量的大小
local count = tonumber(redis.call('get', methodKey) or "0")

--判断是否超过阀值
if count + 1 > limit then
    -- 拒绝访问
    return false
else
    -- 没有超过阀值
    -- 设置当前的访问量加一
    redis.call("INCRBY", methodKey, 1)
    -- 设置过期时间
    redis.call("EXPIRE", methodKey, 1)
    -- 放行
    return true
end
  • 编写redis的配置文件
@Configuration
public class RedisConfigration {

    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory){
        return new StringRedisTemplate(factory);
    }

    @Bean
    public DefaultRedisScript loadRedisScript(){
        DefaultRedisScript redisScript = new DefaultRedisScript();
        redisScript.setLocation(new ClassPathResource("rateLimiter.lua"));
        redisScript.setResultType(java.lang.Boolean.class);
        return redisScript;
    }
}
  • 自定义注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Limiter {

    int limit();

    String methodKey() default "";
}
  • 定义一个切面
@Aspect
@Component
@Slf4j
public class AccessLimiterAspect {

    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private RedisScript<Boolean> redisScript;

    @Pointcut("@annotation(com.bearjun.springcloud.common.Limiter)")
    public void cut(){
    }

    @Before("cut()")
    public void before(JoinPoint joinpoint){
        // 获取类签名
        MethodSignature signature = (MethodSignature) joinpoint.getSignature();
        // 获取签名方法
        Method method = signature.getMethod();
        // 获取方法上的Limiter注解
        Limiter annotation = method.getAnnotation(Limiter.class);
        // 如果不存在直接返回
        if (annotation == null){
            return;
        }
        // 获取limit
        Integer limit = annotation.limit();
        // 获取methodKey
        String key = annotation.methodKey();
        // 如果没有定义key
        if (StringUtils.isBlank(key)){
            // 获取方法参数的类型数组
            Class[] type = method.getParameterTypes();
            // 获取方法名称
            key = method.getName();
            // 如果方法不为空,证明存在值,拼接key
            if (type != null){
                // 获取所有的参数的类型,并用,隔开
                String paramTypes = Arrays.stream(type).map(Class::getName).collect(Collectors.joining(","));
                // 拼接方法名称#参数名称1,参数名称2...
                key += "#" + paramTypes;
            }
        }
        /**
         * 执行lua脚本
         * redisScript: lua脚本
         * Lists.newArrayList(key):lua脚本中key列表
         * limit.toString():lua脚本value的列表
         */
        Boolean accessFlag = stringRedisTemplate.execute(redisScript, Lists.newArrayList(key), limit.toString());
        // 判断是否限流了
        if (!accessFlag){
            log.error("your access is blocked,key={}", key);
            throw new RuntimeException("your access is blocked");
        }
    }
}
  • 测试
@RequestMapping("/test-a")
@Limiter(limit = 1)
public String tryAcquire2(){
   return "success";
}

73988-29z6bzutqz4.png

redis + lua 主要运用redis和lua脚本的天然聚合,无缝衔接,高效的,可预热的执行lua脚本的能力。
[1]: https://juejin.cn/post/6844903783432978439
[2]: http://nginx.org/en/docs/
[3]: https://www.runoob.com/lua/lua-tutorial.html

0

评论区