限流系统稳定基石

为什么要限流

  秒杀时特别热卖的商品,会有很多人来抢(不排除黄牛),不进行限流,有可能把缓存和DB全部拖垮从而造成雪崩。
  微博上的热搜,那流量可想而知。
  比如业务方发布了一个活动,拼团支付一分钱,抽百万大奖,这是拼团支付流量很大,影响了其他业务的正常支付,这个时候需要对各个业务方做单独限流。
  业务方提出需求,用户在单位时间内访问某个页面的次数不能超过多少,这时也需要限流。
  限流应用场景很多,就不一一列举了,这些应用场景应该单独抽取出来,做成一个流控服务。

常见的限流有 : 单机限流,分布式限流,计数器限流。

单机限流

  单机限流的常见算法 漏桶算法、令牌桶算法、计数器算法

漏桶算法

  由图片可知,桶的漏出速率是一定的,请求达到桶的容量之后就会拒绝请求。漏桶算法有一定的缺点,就是当系统可以承受这么大的压力时,请求放过的量还是这么多,不能有效的利用系统资源。


漏桶算法

令牌桶算法

  如下图所示,请求过来以后先去桶里拿令牌,拿不到令牌之后则拒绝请求。令牌桶允许一定量的突发流量,请求空闲时预热一部分令牌,新请求无需等待


令牌桶算法

  guava 的RateLimiter默认就是令牌桶实现,提供了SmoothWarmingUp(预热限流),SmoothWarmingUp(突发限流)两种实现。
几个关键的成员变量

  // 当前桶里存放多少个令牌
  double storedPermits;
  // 最大可以存放多少令牌
  double maxPermits;
  // 获取一个令牌需要的时间,比如限流qps是5,那此值就是 1/5 = 200ms
  double stableIntervalMicros;
  // 记录最近一个可以获取令牌的时间。
  long nextFreeTicketMicros = 0L;
public double acquire(int permits) {
    // 拿 permits 个令牌要等待多长时间
    long microsToWait = reserve(permits);
    // 其实就是sleep
    stopwatch.sleepMicrosUninterruptibly(microsToWait);
    return 1.0 * microsToWait / SECONDS.toMicros(1L);
  }

接下来看一下reserve方法的实现

final long reserve(int permits) {
    checkPermits(permits);
    // mutex方法使用了double check,这在多线程编程里是很常见的
    synchronized (mutex()) {
      return reserveAndGetWaitLength(permits, stopwatch.readMicros());
    }
  }

reserveAndGetWaitLength 调用了reserveEarliestAvailable方法,最主要的实现就是在这个方法里了。没看源码之前总以为是有一个后台线程在不断的往桶里面放令牌,其实是每次拿令牌的时候才去更新桶里的令牌数量,这个实现很精髓。

final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
   // 这个方法的作用是,假如有很长时间都没有线程来拿令牌了,则更新令牌桶里的令牌数量
    resync(nowMicros);
    long returnValue = nextFreeTicketMicros;
    // 取要求拿几个令牌 和 现有存储令牌的 最小值
    double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
    double freshPermits = requiredPermits - storedPermitsToSpend;
    // 计算等待时间,storedPermitsToWaitTime 在SmoothBursty实现里返回0
    // 当拿的令牌数量小于桶中剩余令牌数量时,等待时间为0。
    long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
        + (long) (freshPermits * stableIntervalMicros);
    // 更新剩余令牌数量和最近一个可用令牌的时间
    this.nextFreeTicketMicros = nextFreeTicketMicros + waitMicros;
    this.storedPermits -= storedPermitsToSpend;
    return returnValue;
  }

返回的这个returnValue 大于0的话,当前线程就sleep多长时间。

private void resync(long nowMicros) {
    // 当前时间大于最后一个令牌可用时间,说明有一段时间没有线程来拿令牌了
    if (nowMicros > nextFreeTicketMicros) {
      // 更新存储令牌的数量,计算方式如下
      storedPermits = min(maxPermits,
          storedPermits + (nowMicros - nextFreeTicketMicros) / stableIntervalMicros);
      nextFreeTicketMicros = nowMicros;
    }
  }

acquire方法介绍完了,接下来在看一下 tryAcquire 方法的实现。

public boolean tryAcquire(int permits, long timeout, TimeUnit unit) {
    long timeoutMicros = max(unit.toMicros(timeout), 0);
    checkPermits(permits);
    long microsToWait;
    // acquire 和 tryAcquire 方法在此处都是加锁线程安全的。
    synchronized (mutex()) {
      long nowMicros = stopwatch.readMicros();
      // 看能否获取锁
      if (!canAcquire(nowMicros, timeoutMicros)) {
        return false;
      } else {
        // 接下来就是调用 reserveEarliestAvailable方法,上面已经介绍过了
        microsToWait = reserveAndGetWaitLength(permits, nowMicros);
      }
    }
    stopwatch.sleepMicrosUninterruptibly(microsToWait);
    return true;
  }
// timeoutMicros 参数就是最长等待多长时间
private boolean canAcquire(long nowMicros, long timeoutMicros) {
    // queryEarliestAvailable 返回的是 nextFreeTicketMicros
    // 最近一次可以获取令牌的时间 - 等待时间 < 当前时间
    return queryEarliestAvailable(nowMicros) - timeoutMicros <= nowMicros;
 }

guava的RateLimiter源码就大致解析到这里

计数器算法

这个实现比较简单,demo如下

// 设置1秒钟过期
LoadingCache<String, AtomicLong> cache = CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.SECONDS).build(new CacheLoader<String, AtomicLong>() {
            @Override
            public AtomicLong load(String key) throws Exception {
                // load 方法内部加了 synchorized 代码块修饰,在高并发下key失效,也只有一个线程调用此方法去加载初始值放到cache,其余线程从cache获取
                return new AtomicLong(0);
            }
        });
 if (cache.get("key").incrementAndGet() < 限流大小) {
      // pass
  } else {
      // reject
  }

单机限流还可以根据 线程池,每个限流方法配置不同的线程池,做到很好的线程隔离。也可以使用信号量,hystrix实现。

分布式限流

原因

  现在服务部署都是集群方式,使用比如dubbo等RPC框架,还有使用nginx做反向代理,有可能流量请求到物理机上不均匀,还有就是机器的性能不相同,如果使用单机限流的话,性能好的物理机资源会大大的浪费。

应用

  • 支付服务是核心服务,会有多个业务方来调,这时候要多各个业务方做限流,防止由于某个业务方支付量激增时影响其他业务方支付。
  • 限制 百分之多少的用户请求时直接降级。
  • 限制用户一天访问多少次,等等。

实现

  • 分布式限流可以基于redis,对限流的key做incr操作,并设置过期值,当incr的值大于限流值之后,直接reject调。
  • 可以单独部署一个令牌服务,每次请求时客户端去令牌服务端拿对应的令牌,令牌服务做成HA高可用,这样就不强依赖redis。(阿里开源的sentinel分布式限流就是这么做的)
  • 也可以使用nginx做限流,nginx+lua脚本实现。

  下面简单画了一下基于redis的集群限流图片


redis限流

高效率与高可用

  • 集群限流强依赖于第三方(比如redis),当网络放生抖动时,会使一个请求的时间大大加长,这对于业务方来说是不可忍受的。可以对请求redis的时间做熔断降级(异步写redis),当redis不可用时,集群限流降级为单机限流(集群限流值/机器总数)。
  • 客户端也可以一次拿多个令牌,这样就不会频繁请求redis了。
  • 读取redis的从库,写入主库,这样有可能存在主从延时导致多放请求进来。
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容

  • 在系统架构设计当中,限流是一个不得不说的话题,因为他太不起眼,但是也太重要了。这点有些像古代镇守边陲的将士,据守隘...
    Java大生阅读 792评论 0 0
  • 在系统架构设计当中,限流是一个不得不说的话题,因为他太不起眼,但是也太重要了。这点有些像古代镇守边陲的将士,据守隘...
    java菜阅读 362评论 0 4
  • 聊聊高并发系统限流特技-1来自开涛的博客 在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流。缓存的目的是...
    meng_philip123阅读 6,695评论 1 20
  • 在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流。缓存的目的是提升系统访问速度和增大系统能处理的容量,可...
    高级java架构师阅读 699评论 0 5
  • 关键词:性欲 题主:女 问:他170cm,25岁,经营货车生意。我跟他联系时,他都有女朋友了,但没结婚。 跟他联系...
    冷爱阅读 20,547评论 0 8