高并发利器之限流

限流实践

高并发系统三把利器:缓存、降级和限流
限流大家多少都有所了解,就是限制当前请求流量、数量,避免系统被蜂拥而至的流量瞬间击垮,刚好最近手上业务系统做了限流,顺便整理出来。


我们的业务场景

  • 云变量和云列表,用户在线使用的程序变量和数组,一些作品运行人数过多或者作者滥用(for循环中修改变量或数组),大量请求消息落到kafka,而我们消费机器能力有限,造成消息堵塞。直接的影响就是业务不能正常使用,频繁fullgc。
  • 无论什么系统,业务上的优化往往更能立竿见影,且成本较低。这里不做详述,比较偏业务,主要思路是尽量减少落入kafka中的无效消息,合并重复消息。

限流方式

  • 限制总并发数(比如数据库连接池、线程池)
  • 限制瞬时并发数(如nginx的limit_conn模块,用来限制瞬时并发连接数)
  • 限制时间窗口内的平均速率(如Guava的RateLimiter、nginx的limit_req模块,限制每秒的平均速率)
  • 限制远程接口调用速率
  • 限制MQ的消费速率。
  • 可以根据网络连接数、网络流量、CPU或内存负载等来限流

限流算法

Google大法了相关资料,总结常用的限流算法如下:

  • 计数器
    • 通过一个计数器来记录一定时间内某个接口的访问数量,超过阈值则不允许继续访问,或者后续的请求放入队列,计数器到下一个时间段清零,这里缺点是,假设设置每3秒100,第3秒99个请求数,然后到了下一个时间段,计数被清零,第4秒又来了99个请求,实际上3-4两秒时间超出了限制的100个请求,但这种情况较少。所有有了下面的时间窗口让它更平滑
  • 时间窗口
    • 创建时间窗口,100个格子,每个格子为100毫秒,每100毫秒滑动一格,判断当前执行次数和前一次执行次数差是否超出阈值,没有超过记录当前次数,超过证明超出了限流次数,窗口粒度越细则越平滑
  • 漏桶算法
    • 把请求固定丢入‘漏桶’(队列),线程以固定频率进行消费处理,这样对应用来说消费是固定的压力,超出漏桶容量的请求将被丢弃
  • 令牌桶限流
    • 基本思路为每秒生成固定数量令牌置入桶内,真正业务操作需要从桶中获取令牌才能执行,没有令牌抛弃请求,相对漏桶方式,更灵活,可以设置令牌生成的速度,获取令牌的速度和个数。允许一些突发的流量(如果当前机器可以支撑的流量(令牌数)较多则可以更多的获取令牌进行执行)

可选方案

  • Guava 提供的令牌桶实现RateLimiter,但只支持单机的限流
  • nginx 连接数限流模块ngx_http_limit_conn_module和漏桶算法实现的请求限流模块ngx_http_limit_req_module
  • redis+lua 脚本实现
  • ratelimitj开源框架(提供了基于redis、hazelcast、inmemory版本的实现方案)
  • 阿里巴巴开源的 Sentinel(限流、熔断降级、塑形、系统负载保护)

落地方案

  • 一般我们这里都是集群环境,现在最早已经使用了guava RateLimiter,这里系统也已经使用了redis,所以选择了redis进行了实现

  • redis+lua

    • redis可以调用lua脚本,lua代码将会被当成一个命令去执行,lua脚本执行完成redis才会继续执行其他命令,这样保证了并发环境下可以正确的进行执行限流判断操作。

    • lua脚本(基本为记录对应请求对应key计数是否超出限制数量)

      -- ratelimit 对象
      RateLimit = { acquire = false, key = nil, limit = nil, count = 0 }
      function RateLimit:new(o, acquire, key, limit, count)
          o = o or {}
          setmetatable(o, self)
          self.__index = self
          self.acquire = acquire or false
          self.key = key or nil
          self.limit = limit or 0
          self.count = count or 0;
          return o
      end
      
      -- 获取执行权限,超出流量控制则拒绝
      local key = "rate.limit:" .. KEYS[1]
      local limit = tonumber(KEYS[2])
      local expire_time = KEYS[3]
      -- 判断key是否存在
      local is_exists = redis.call("EXTSTS", key);
      -- 如果key存在则调用inscr操作key
      -- 如果值大于limit数量返回0,否则返回1
      if is_exists == 1 then
          local count = redis.call("INSCR", key)
          if redis.call("INSCR", key) > limit then
              return Rectangle:new(nil, false, key, limit, count)
          else
              return Rectangle:new(nil, true, key, limit, count)
          end
          -- 如果不存在key,则调用set命令设置key的值为1
          -- 设置key过期时间
      else
          redis.call("SET", key, 1)
          redis.call("EXPIRE", key, expire_time)
          return Rectangle:new(nil, true, key, limit, 1)
      end
      
    • 封装java接口(这里加了log,可以简单监控下当前限流情况,对异常请求可以根据业务需要做一些人工介入)

      package com.netease.edu.scratch.cloud.limit;
      
      import java.util.Map;
      import java.util.concurrent.TimeUnit;
      
      /**
       * 限流
       *
       * @author zhangchanglu
       * @since 2018/08/08 22:08.
       */
      public interface RateLimit {
          /**
           * 获取执行权限
           *
           * @param key    业务key
           * @param limit  限流大小
           * @param expire 限流重置时间
           * @return 流量情况
           */
          RateLimitVO acquire(String key, int limit, long expire, TimeUnit timeUnit);
      
          /**
           * 监控当前key流量情况
           *
           * @param key 业务key
           * @param limit 限制流量数
           * @return 流量情况
           */
          RateLimitVO log(String key, int limit);
          /**
           * 监控当前所有key流量情况
           * @param limit 限制流量数
           * @return 流量情况
           */
          Map logAll(int limit);
      
          /**
           * 获取存储redis key
           * @param key 业务key
           * @return 业务key
           */
          String getKey(String key);
      
      }
      
      
    • 主要实现方法(这里用的spring的redisTemplate,调用传递value没办法正常获取,所以所有参数都是用了key进行了传递

      /**
           * 获取执行权限
           *
           * @param key    业务key
           * @param limit  限流大小
           * @param expire 限流重置时间
           * @return 是否在允许执行
           */
          public RateLimitVO acquire(String key, int limit, long expire, TimeUnit timeUnit) {
              addLog(key);
              key = getKey(key);
              RedisScript redisScript = new DefaultRedisScript<>(scriptStr, Long.class);
              Long count = redisTemplate.execute(redisScript, Lists.newArrayList(key, String.valueOf(limit), String.valueOf(timeUnit.toSeconds(expire))));
              RateLimitVO rateLimitVO = rateLimitHelper.getRateLimitVO(key, limit, count);
              log.info("当前流量情况:" + rateLimitVO);
              return rateLimitVO;
          }
      
  • 这里我们用的是我们的rds,但跟dba沟通后,为了安全,限制了eval命令的调用,而redis调用lua脚本是依赖eval命令的,所以这里用使用代码进行了实现,思路一样的只是借助了分布式锁。

存在的问题

  • 我们业务上也是粗略进行限流,并没有精确进行限制流量,基本上避免大量无效和恶意请求即可
  • 这里的限流其实对于整个系统来讲,系统已经接受了大量的连接,依然会有压力存在,只是减少了kafka、数据库的压力 ,所以对限流来说应该越靠前越好,使用nginx来进行限流应该能更好的保证系统的稳定性,这也是我们后续要考虑做的。

你可能感兴趣的:(高并发利器之限流)