10

1. 常用限流算法

1.1 计数器算法

统计一段时间内允许通过的请求数。比如 qps为100,即1s内允许通过的请求数100,每来一个请求计数器加1,超过100的请求拒绝、时间过1s后计数器清0,重新计数。这样限流比较暴力,如果前10ms 来了100个请求,那剩下的990ms只能眼睁睁看着请求被过滤掉,并不能平滑处理这些请求,容易出现常说的“突刺现象”。

计数器算法流程图如下
计数器算法流程图

1.2漏桶算法

请求到来时先放入漏桶中,漏桶再以匀速放行请求,如果进来请求超出了漏桶的容量时,则拒绝请求,这样做虽然能够避免“突刺现象”,但是过于平滑并不能应对短时的突发流量。
具体实现可采取将到来的请求放入队列中,再另起线程从队列中匀速拿出请求放行。

1.3令牌桶算法

假设有个桶,并且会以一定速率往桶中投放令牌,每次请求来时都要去桶中拿令牌,如果拿到则放行,拿不到则进行等待直至拿到令牌为止,比如以每秒100的速度往桶中投放令牌,令牌桶初始化一秒过后桶内有100个令牌,如果大量请求来时会立即消耗完100个令牌,其余请求进行等待,最终以匀速方式放行这些请求。此算法的好处在于既能应对短暂瞬时流量,又可以平滑处理请求。

** 令牌桶限流流程图**
图片2.png

2. 网关限流原理解析

2.1 spirng cloud gateway网关限流原理解析

** 核心限流类:org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter
** 核心方法如下:

 public Mono<Response> isAllowed(String routeId, String id) {
   if (!this.initialized.get()) {
      throw new IllegalStateException("RedisRateLimiter is not initialized");
   }
   Config routeConfig = loadConfiguration(routeId);
   //令牌桶平均投放速率
   int replenishRate = routeConfig.getReplenishRate();
   //桶容量
   int burstCapacity = routeConfig.getBurstCapacity();

   try {
//获取限流key
      List<String> keys = getKeys(id);

      //组装lua脚本执行参数,第一个参数投放令牌速率、第二个参数桶的容量、第三个参数当前时间戳,第四个参数需要获取的令牌个数
      List<String> scriptArgs = Arrays.asList(replenishRate + "",
            burstCapacity + "", Instant.now().getEpochSecond() + "", "1");

//通过lua脚本与redis交互获取令牌,返回数组,数组第一个元素代表是否获取成功(1成功0失败),第二个参数代表剩余令牌数
      Flux<List<Long>> flux = this.redisTemplate.execute(this.script, keys,
            scriptArgs);

      //如果获取令牌异常,默认设置获取结果【1、-1】,顾默认获取令牌成功、剩余令牌-1,不做限流控制
      return flux.onErrorResume(throwable -> Flux.just(Arrays.asList(1L, -1L)))
            .reduce(new ArrayList<Long>(), (longs, l) -> {
               longs.addAll(l);
               return longs;
            }).map(results -> {
               boolean allowed = results.get(0) == 1L;
               Long tokensLeft = results.get(1);
               Response response = new Response(allowed,
                     getHeaders(routeConfig, tokensLeft));

               if (log.isDebugEnabled()) {
                  log.debug("response: " + response);
               }
               return response;
            });
   }
   catch (Exception e) {
      log.error("Error determining if user allowed from redis", e);
   }
   return Mono.just(new Response(true, getHeaders(routeConfig, -1L)));
}

gateway限流lua脚本实现如下:
图片3.png

lua脚本分析及备注如下:

--令牌桶剩余令牌数key
local tokens_key = KEYS[1] 
--令牌桶最后填充时间key
local timestamp_key = KEYS[2]

--往令牌桶投放令牌速率 
local rate = tonumber(ARGV[1])
--令牌桶大小
local capacity = tonumber(ARGV[2])
--当前数据戳
local now = tonumber(ARGV[3])
--请求获取令牌数量
local requested = tonumber(ARGV[4])
--计算令牌桶填充满需要的时间
local fill_time = capacity/rate
--保证时间充足
local ttl = math.floor(fill_time*2)
--获取redis中剩余令牌数
local last_tokens = tonumber(redis.call("get", tokens_key))
if last_tokens == nil then
  last_tokens = capacity
end
--获取redis中最后一次更新令牌的时间
local last_refreshed = tonumber(redis.call("get", timestamp_key))
if last_refreshed == nil then
  last_refreshed = 0
end

local delta = math.max(0, now-last_refreshed)
--计算出需要更新redis里的令牌桶数量(通过 过去的时间间隔内需要投放的令牌数+桶剩余令牌)
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
local allowed = filled_tokens >= requested
local new_tokens = filled_tokens
local allowed_num = 0
--消耗令牌后,重新计算出需要更新redis缓存里的令牌数
if allowed then
  new_tokens = filled_tokens - requested
  allowed_num = 1
end
--互斥更新redis 里的剩余令牌数
redis.call("setex", tokens_key, ttl, new_tokens)
--互斥更新redis 里的最新更新令牌时间
redis.call("setex", timestamp_key, ttl, now)

return { allowed_num, new_tokens }

spring cloud gateway通过redis实现令牌桶算法的流程图如下
图片4.png

总结: 通过redis 实现令牌桶算法限流,支持集群限流、但限速有上限,毕竟和redis交互需要消耗较长时间,限流没加锁虽然可以提升网关吞吐量,但实际并不是满足"线程安全",缺乏对伪超速的判断处理,例如桶大小10,往桶投放令牌速率为100/1s,当桶内10令牌消耗完后,这时两个正常的请求q1 和q2同时进入网关,如果q1刚好拿到产生新的令牌放行,q2则需要再过10ms才能获取新的令牌,由于两个请求间隔很短<10ms,导致q2去桶中拿不到令牌而被拦截为超速请求,导致原因gateway未对消耗完桶后的请求进行入队等待。

测试
设置令牌桶大小为5,投放速率为10/s ,配置如下

server:
  port: 8081
spring:
  cloud:
    gateway:
      routes:
      - id: limit_route
        uri: http://localhost:19090
        predicates:
        - After=2017-01-20T17:42:47.789-07:00[America/Denver]
        filters:
        - name: RequestRateLimiter
          args:
            key-resolver: '#{@uriKeyResolver}'
            redis-rate-limiter.replenishRate: 10
            redis-rate-limiter.burstCapacity: 5
  application:
    name: gateway-limiter

现在用jmeter模拟10个并发请求,查看能够正常通过的请求数有多少?
图片5.png
运行结果:
图片6.png
通过打印结果发现,10个请求被拦截了5个请求。在实际应该中,10个请求或许都是正常请求,并没有超过10qps却被拦截。

2.2 spring-cloud-zuul网关限流zuul-ratelimit原理分析

zuul-ratelimt 支持memory、redis限流,通过计数器算法实现限流,即在窗口时间内消耗指定数量的令牌后限流,窗口时间刷新后重新指定消耗令牌数量为0。
核心代码如下:

public class RateLimitFilter extends ZuulFilter {

    public static final String LIMIT_HEADER = "X-RateLimit-Limit";
    public static final String REMAINING_HEADER = "X-RateLimit-Remaining";
    public static final String RESET_HEADER = "X-RateLimit-Reset";

    private static final UrlPathHelper URL_PATH_HELPER = new UrlPathHelper();

    private final RateLimiter rateLimiter;
    private final RateLimitProperties properties;
    private final RouteLocator routeLocator;
    private final RateLimitKeyGenerator rateLimitKeyGenerator;

    @Override
    public String filterType() {
        return "pre";
    }

    @Override
    public int filterOrder() {
        return -1;
    }

    @Override
    public boolean shouldFilter() {
        return properties.isEnabled() && policy(route()).isPresent();
    }

    public Object run() {
        final RequestContext ctx = RequestContext.getCurrentContext();
        final HttpServletResponse response = ctx.getResponse();
        final HttpServletRequest request = ctx.getRequest();
        final Route route = route();

        policy(route).ifPresent(policy -> 
          //生成限流key
            final String key = rateLimitKeyGenerator.key(request, route, policy);
           //执行核心限流方法,返回剩余可以用令牌数,如果rate.remaining<0则已超出流量限制
            final Rate rate = rateLimiter.consume(policy, key);
            response.setHeader(LIMIT_HEADER, policy.getLimit().toString());
            response.setHeader(REMAINING_HEADER, String.valueOf(Math.max(rate.getRemaining(), 0)));
            response.setHeader(RESET_HEADER, rate.getReset().toString());
            if (rate.getRemaining() < 0) {
                ctx.setResponseStatusCode(TOO_MANY_REQUESTS.value());
                ctx.put("rateLimitExceeded", "true");
                throw new ZuulRuntimeException(new ZuulException(TOO_MANY_REQUESTS.toString(),
                        TOO_MANY_REQUESTS.value(), null));
            }
        });
        return null;
    }
}

核心限流方法rateLimiter.consume(policy, key)代码如下:

public abstract class AbstractCacheRateLimiter implements RateLimiter {

    @Override
    public synchronized Rate consume(Policy policy, String key, Long requestTime) {
        final Long refreshInterval = policy.getRefreshInterval();
        final Long quota = policy.getQuota() != null ? SECONDS.toMillis(policy.getQuota()) : null;
        final Rate rate = new Rate(key, policy.getLimit(), quota, null, null);

        calcRemainingLimit(policy.getLimit(), refreshInterval, requestTime, key, rate);

        return rate;
    }

    protected abstract void calcRemainingLimit(Long limit, Long refreshInterval, Long requestTime, String key, Rate rate);

}


@Slf4j
@RequiredArgsConstructor
@SuppressWarnings("unchecked")
public class RedisRateLimiter extends AbstractCacheRateLimiter {

    private final RateLimiterErrorHandler rateLimiterErrorHandler;
    private final RedisTemplate redisTemplate;

    @Override
    protected void calcRemainingLimit(final Long limit, final Long refreshInterval,
                                      final Long requestTime, final String key, final Rate rate) {
        if (Objects.nonNull(limit)) {
            long usage = requestTime == null ? 1L : 0L;
            Long remaining = calcRemaining(limit, refreshInterval, usage, key, rate);
            rate.setRemaining(remaining);
        }
    }

    private Long calcRemaining(Long limit, Long refreshInterval, long usage,
                               String key, Rate rate) {
        rate.setReset(SECONDS.toMillis(refreshInterval));
        Long current = 0L;
        try {
            current = redisTemplate.opsForValue().increment(key, usage);
            // Redis returns the value of key after the increment, check for the first increment, and the expiration time is set
            if (current != null && current.equals(usage)) {
                handleExpiration(key, refreshInterval);
            }
        } catch (RuntimeException e) {
            String msg = "Failed retrieving rate for " + key + ", will return the current value";
            rateLimiterErrorHandler.handleError(msg, e);
        }
        return Math.max(-1, limit - current);
    }

    private void handleExpiration(String key, Long refreshInterval) {
        try {
            this.redisTemplate.expire(key, refreshInterval, SECONDS);
        } catch (RuntimeException e) {
            String msg = "Failed retrieving expiration for " + key + ", will reset now";
            rateLimiterErrorHandler.handleError(msg, e);
        }
    }
}

总结: 大家有没有注意到限流方法前面加了synchronized 锁,虽然保证了线程安全,但这里会存在一个问题,如果此限流方法执行时间2ms,即持锁时间过长(主要是和redis交互耗时),会导致整个网关的吞吐量不会超过500qps,所以在用redis做限流时建议做分key锁,每个限流key之间互不影响,即保证了限流的安全性,又提高了网关的吞吐量。用memory做限流不需要考虑这个问题,因为本地限流持锁时间足够短,即是执行限流方法是串行的,但也可以拥有很高的吞吐量,zuul-ratelimt限流算法采用计数器限流,顾都有一个通病,避免不了“突刺现象”。

2.3 Guava RateLimiter实现平滑限流

Guava RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现,代码实现时序图如下:
图片7.png

测试
平滑预热限流,qps为10,看下去拿10个令牌依次耗时情况

/**
 * 平滑预热限流(SmoothWarmingUp)
 */
public class SmoothWarmingUp {
    public static void main(String[] args) {

        RateLimiter limiter = RateLimiter.create(10, 1000, TimeUnit.MILLISECONDS);
        for(int i = 0; i < 10;i++) {
            //获取一个令牌
            System.out.println(limiter.acquire(1));
        }
    }
}

**运行结果:**返回线程等待的时间

图片8.png

平滑突发限流

/* 平滑突发限流(SmoothBursty)
         */
public class SmoothBurstyRateLimitTest {
    public static void main(String[] args) {
        //QPS = 5,每秒允许5个请求
        RateLimiter limiter = RateLimiter.create(5);
        //limiter.acquire() 返回获取token的耗时,以秒为单位
        System.out.println(limiter.acquire());
        System.out.println(limiter.acquire());
        System.out.println(limiter.acquire());
        System.out.println(limiter.acquire());
        System.out.println(limiter.acquire());
        System.out.println(limiter.acquire());
    }
}

运行结果:
图片9.png

**总结:**Guava限流思路主要是通过计算下一个可用令牌的等待时间,去休眠线程,休眠结束后默认成功获得令牌,平滑预热算法SmoothWarmingUp也是类似,只是刚开始计算获取令牌的速率要比设定限流的速率底,最后再慢慢趋于限流速率。SmoothWarmingUp限流不适用中低频限流,在常规的应用限流中,比如我们设定guava的限速为100qps,在同一个时间点来了q1、q2、q3三个正常请求,那么q1会被迫等待10ms,q2被迫等待20ms,q3被迫等待30ms,在除高并发的应用场景中是经常出现这种情况的,应用持续高并发情景并不多,只是在较短时间内来了多个正常请求,却被迫等待一定时间,降低了请求的响应速度,在这种场景下算法显得过于平滑,还是主要适用高并发应用场景 如秒杀场景等。SmoothBursty限流则不会,它有“桶”的概念,“桶”中令牌没拿完前是不会限速的,桶大小为限流速率大小,不支持自动调整桶大小。

3.网关限流功能对比

image.png

** 自写限流目标:**为了即保证限流算法线程安全,又能提高网关吞吐量,my-ratelimit网关限流采用分key锁,不同key之间限流互不影响。为满足多业务场景,my-ratelimit支持了自定义限流维度、多维度限流、多维度自由排列组合限流、支持自选限流算法及仓库类型。

4.自写令牌桶限流算法

4.1 基于memory令牌桶限流算法实现流程图如下:

图片10.png

**总结:**my-ratelimit令牌桶限流算法核心思想:每来一个请求都会先做投放令牌操作,投放数量根据当前时间距离上次投放时间的时间段占1s的比例乘以限流速率limit计算而得,可能投放数量为0,投放完后再去桶中取令牌,如果取到了令牌则请求放行,若没有令牌则线程进入AQS同步队列中,直到有令牌产生再依次去唤醒队列中的线程来获取令牌。在实际的业务场景中,高频的时间段其实并不多,大都是低频的请求,为了尽可能提高请求响应速度,满足低频“不限流”,高频平滑限流的指标,刚来的请求不会先入AQS同步队列中,而是先去拿令牌,当拿不到令牌时说明此时段流量比较大,再进入队列等待获取令牌达到平滑限流目的。另外在进来的请求前加了一个判断,则是如果等待队列大小已经到达了限流的速率limit大小了,则说明此时段请求已超速,顾直接拒绝请求。

4.2 测试

为了测试限流算法本身耗时情况,先用单线程来测试,设置每秒产生10w的令牌,桶大小为1,平滑拿完这些令牌需要多少时间。

** 测试代码:**

public static void singleManateeLimit(long permitsRatelimit) throws InterruptedException {
    RateLimitPermit rateLimitPermit =  RateLimitPermit.create(1,permitsRatelimit,1);
    int hastoken=0;
    long start =System.nanoTime();
    for(int i=0 ; i < permitsRatelimit*1 ; i++){
        if(rateLimitPermit.acquire()>=0){
            hastoken++;
        }
    }
   System.out.println("catfishLimit use time:"+(NANOSECONDS.toMillis(System.nanoTime()-start-SECONDS.toNanos(1) ) ) + " ms" );
    System.out.println("single thread hold Permit:"+hastoken);
}
public static void main(String[] args) throws Exception {
    singleManateeLimit(100000);
    //guavaLimit();
    //multCatfishLimit(2000,10000);
}

运行结果:

图片11.png

**说明:**10w令牌平滑拿完用了115ms,平均每次限流逻辑执行时间1微秒左右,几乎可以忽略不计。

接下来测试多线程情况,设置并发请求2000个,限流qps为10000,测试用时

public static void multManateeLimit(int threadCount ,long permitsRatelimit) throws InterruptedException {

    CountDownLatch countDownLatch=new CountDownLatch(threadCount);
    AtomicInteger hastoken= new AtomicInteger(0);

    CyclicBarrier cyclicBarrier= new CyclicBarrier(threadCount);
    RateLimitPermit rateLimitPermit = RateLimitPermit.create(1,permitsRatelimit,1);

    AtomicLong startTime= new AtomicLong(0);

    for (int i = 0; i < threadCount; i++) {
        Thread thread=new Thread(()->{
            try {
                cyclicBarrier.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (BrokenBarrierException e) {
                e.printStackTrace();
            }
            startTime.compareAndSet(0,System.nanoTime());
            for (int j = 0; j < 1; j++) {
                if( rateLimitPermit.acquire()>=0){
                    hastoken.incrementAndGet();
                }
            }

            countDownLatch.countDown();

        },"ratelimit-"+i);
        thread.start();
    }
    countDownLatch.await();
    System.out.println("manateeLimit use time:"+ (long)( NANOSECONDS.toMillis(  System.nanoTime()-startTime.get() ) -Math.min(hastoken.get()*1.0/permitsRatelimit*1000L, threadCount*1.0/permitsRatelimit * SECONDS.toMillis(1)) )+" ms");
    System.out.println("mult thread hold Permit:"+hastoken.get());

}

public static void main(String[] args) throws Exception {
    singleManateeLimit(100000);
    //guavaLimit();
    multManateeLimit(2000,10000);
}

运行结果:
图片12.png

**说明:**看到结果会发现,qps为1w时,2000个线程去拿令牌用时127ms,这是为什么呢,其实这里通过cyclicBarrier控制并发请求,请求数未到达2000时 进入wait,到达2000时才signalAll,这里换醒线程是有时间差的,很难通过程序控制多线程在同一个时间点同时执行,这里统计出的时间存在误差。

测qps为100时,2000个请求同时去拿令牌耗时,能拿到多少令牌

    public static void main(String[] args) throws Exception {
       // singleManateeLimit(100000);
        //guavaLimit();
        multManateeLimit(2000,100);
    }

运行结果:
图片13.png

**说明:**2000个线程却拿到了155个令牌,拿令牌操作用时12ms,总用时1550ms+12ms,为啥没有精确拿到100个令牌,原因还是2000个线程未在同一个时间点执行拿令牌操作,通过打印线程唤醒时间发现,2000个线程被唤醒的最大时间差为566ms。

图片14.png
图片15.png

**总结:**该限流算法特别支持少量限流器,高并发限流,因为算法获取不到令牌会循环往桶投放令牌,如果限流器多导致N个循环投放令牌操作,增加cpu压力

4.3 改进令牌桶限流算法

算法流程图如下:
微信截图_20191020192903.png

5.限流算法对比

image.png


super洪仔
26 声望2 粉丝