31

限流,永远都不是一件简单的事!

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzA5Mjg2MDQ5NQ%3D%3D&%3Bmid=2452509852&%3Bidx=1&%3Bsn=be206273c2cb9ff93d5427606c8337de
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

背景

随着微服务的流行,服务之间的稳定性变得越发重要,往往我们会花很多经历在维护服务的稳定性上,限流和熔断降级是我们最常用的两个手段。前段时间在群里有些小伙伴对限流的使用些疑问,再加上最近公司大促也做了限流相关的事,所以在这里总结一下写写自己对限流的一些看法。

刚才说了限流是我们保证服务稳定性的手段之一,但是他并不是所有场景的稳定性都能保证,和他名字一样他只能在大流量或者突发流量的场景下才能发挥出自己的作用。比如我们的系统最高支持100QPS,但是突然有1000QPS请求打了进来,可能这个时候系统就会直接挂掉,导致后面一个请求都处理不了,但是如果我们有限流的手段,无论他有多大的QPS,我们都只处理100QPS的请求,其他请求都直接拒绝掉,虽然有900的QPS的请求我们拒绝掉了,但是我们的系统没有挂掉,我们系统仍然可以不断的处理后续的请求,这个是我们所期望的。有同学可能会说,现在都上的云了,服务的动态伸缩应该是特别简单的吧,如果我们发现流量特别大的时候,自动扩容机器到可以支撑目标QPS那不就不需要限流了吗?其实有这个想法的同学应该还挺多的,有些同学可能被一些吹牛的文章给唬到了,所以才会这么想,这个想法在特别理想化的时候是可以实现的,但是在现实中其实有下面几个问题:

  • 扩容是需要时间。扩容简单来说就是搞一个新的机器,然后重新发布代码,做java的同学应该是知道发布成功一个代码的时间一般不是以秒级计算,而是以分钟级别计算,有时候你扩容完成,说不定流量尖峰都过去了。

  • 扩容到多少是个特别复杂的问题。扩容几台机器这个是比较复杂的,需要大量的压测计算,以及整条链路上的一个扩容,如果扩容了你这边的机器之后,其他团队的机器没有扩容可能最后还是有瓶颈这个也是一个问题。

所以单纯的扩容是解决不了这个问题的,限流仍然是我们必须掌握的技能!

基本原理

想要掌握好限流,就需要先掌握他的一些基本算法,限流的算法基本上分为三种,计数器,漏斗,令牌桶,其他的一些都是在这些基础上进行演变而来。

计数器算法

首先我们来说一下计数器算法,这个算法比较简单粗暴,我们只需要一个累加变量,然后每隔一秒钟去刷新这个累加变量,然后再判断这个累加变量是否大于我们的最大QPS。

    int curQps = 0;
    long lastTime = System.currentTimeMillis();
    int maxQps = 100;
    Object lock = new Object();
    boolean check(){
        synchronized (lock){
            long now = System.currentTimeMillis();
            if (now - lastTime > 1000){
                lastTime = now;
                curQps = 0;
            }
            curQps++;
            if (curQps > maxQps){
                return false;
            }
        }
        return true;
    }

这个代码比较简单,我们定义了当前的qps,以及上一次刷新累加变量的时间,还有我们的最大qps和我们的lock锁,我们每次检查的时候,都需要判断是否需要刷新,如果需要刷新那么需要把时间和qps都进行重置,然后再进行qps的累加判断。

这个算法因为太简单了所以带来的问题也是特别明显,如果我们最大的qps是100,在0.99秒的时候来了100个请求,然后在1.01秒的时候又来了100个请求,这个是可以通过我们的程序的,但是我们其实在0.03秒之内通过了200个请求,这个肯定不符合我们的预期,因为很有可能这200个请求直接就会将我们机器给打挂。

滑动窗口计数器

为了解决上面的临界的问题,我们这里可以使用滑动窗口来解决这个问题:

Y736Bvy.png!mobile

如上图所示,我们将1s的普通计数器,分成了5个200ms,我们统计的当前qps都需要统计最近的5个窗口的所有qps,再回到刚才的问题,0.99秒和1.01秒其实都在我们的最近5个窗口之内,所以这里不会出现刚才的临界的突刺问题。

其实换个角度想,我们普通的计数器其实就是窗口数量为1的滑动窗口计数器,只要我们分的窗口越多,我们使用计数器方案的时候统计就会越精确,但是相对来说维护的窗口的成本就会增加,等会我们介绍sentinel的时候会详细介绍他是怎么实现滑动窗口计数的。

漏斗算法

解决计数器中临界的突刺问题也可以通过漏斗算法来实现,如下图所示:

7zqI3iU.png!mobile

在漏斗算法中我们需要关注漏桶和匀速流出,不论流量有多大都会先到漏桶中,然后以均匀的速度流出。如何在代码中实现这个匀速呢?比如我们想让匀速为100q/s,那么我们可以得到每流出一个流量需要消耗10ms,类似一个队列,每隔10ms从队列头部取出流量进行放行,而我们的队列也就是漏桶,当流量大于队列的长度的时候,我们就可以拒绝超出的部分。

漏斗算法同样的也有一定的缺点:无法应对突发流量(和上面的临界突刺不一样,不要混淆)。比如一瞬间来了100个请求,在漏桶算法中只能一个一个的过去,当最后一个请求流出的时候时间已经过了一秒了,所以漏斗算法比较适合请求到达比较均匀,需要严格控制请求速率的场景。

令牌桶算法

为了解决突发流量情况,我们可以使用令牌桶算法,如下图所示:

Rn2umuj.png!mobile

这个图上需要关注三个阶段:

  • 生产令牌:我们在这里同样的还是假设最大qps是100,那么我们从漏斗的每10ms过一个流量转化成每10ms生产一个令牌,直到达到最大令牌。

  • 消耗令牌:我们每一个流量都会消耗令牌桶,这里的消耗的规则可以多变,既可以是简单的每个流量消耗一个令牌,又可以是根据不同的流量数据包大小或者流量类型来进行不同的消耗规则,比如查询的流量消耗1个令牌,写入的流量消耗2个令牌。

  • 判断是否通过:如果令牌桶足够那么我们就允许流量通过,如果不足够可以等待或者直接拒绝,这个就可以采用漏斗那种用队列来控制。

单机限流

上面我们已经介绍了限流的一些基本算法,我们把这些算法应用到我们的分布式服务中又可以分为两种,一个是单机限流,一个是集群限流。单机限流指的是每台机器各自做自己的限流,互不影响。我们接下来看看单机限流怎么去实现呢?

guava

guava是谷歌开源的java核心工具库,里面包括集合,缓存,并发等好用的工具,当然也提供了我们这里所需要的的限流的工具,核心类就是RateLimiter。

//  RateLimiter rateLimiter = RateLimiter.create(100, 500, TimeUnit.MILLISECONDS); 预热的rateLimit
    RateLimiter rateLimiter = RateLimiter.create(100); // 简单的rateLimit
    boolean limitResult = rateLimiter.tryAcquire();

使用方式比较简单,如上面代码所示,我们只需要构建一个RateLimiter,然后再调用tryAcquire方法,如果返回为true代表我们此时流量通过,相反则被限流。在guava中RateLimiter也分为两种,一个是普通的令牌桶算法的实现,还有一个是带有预热的RateLimiter,可以让我们令牌桶的释放速度逐步增加直到最大,这个带有预热的在sentinel也有,这个可以在一些冷系统中比如数据库连接池没有完全填满,还在不断初始化的场景下使用。

在这里只简单的介绍一下guava的令牌桶怎么去实现的

rENN7va.png!mobile

普通的令牌桶创建了一个 SmoothBursty 的类,这个类也就是我们实现限流的关键,具体怎么做限流的在我们的tryAcquire中:

eMrMNvY.png!mobile

这里分为四步:

  • Step1: 加上一个同步锁,需要注意一下这里在sentinel中并没有加锁这个环节,在guava中是有这个的,后续也会将sentinel的一些问题。

  • Step2: 判断是否能申请令牌桶,如果桶内没有足够的令牌并且等待时间超过我们的timeout,这里我们就不进行申请了。

  • Step3: 申请令牌并获取等待时间,在我们tryAcquire中的timeout参数就是就是我们的最大等待时间,如果我们只是调用 tryAcquire() ,不会出现等待,第二步的时候已经快速失败了。

  • Step4: sleep等待的时间。

扣除令牌的方法具体在reserverEarliestAvailable方法中:

mIr2eqq.png!mobile

这里虽然看起来过程比较多,但是如果我们只是调用 tryAcquire() ,就只需要关注两个红框:

  • Step1: 根据当前最新时间发放token,在guava中没有采用使用其他线程异步发放token的方式,把token的更新放在了我们每次调用限流方法中,这个设计可以值得学习一下,很多时候不一定需要异步线程去执行也可以达到我们想要的目的,并且也没有异步线程的复杂。

  • Step2: 扣除令牌,这里我们已经在canAcquire中校验过了,令牌一定能扣除成功。

guava的限流目前就提供了这两种方式的限流,很多中间件或者业务服务都把guava的限流作为自己的工具,但是guava的方式比较局限,动态改变限流,以及更多策略的限流都不支持,所以我们接下来介绍一下sentinel。

sentinel

sentinel是阿里巴巴开源的分布式服务框架的轻量级流量控制框架,承接了阿里巴巴近 10 年的双十一大促流量的核心场景,他的核心是流量控制但是不局限于流量控制,还支持熔断降级,监控等等。

使用sentinel的限流稍微比guava复杂很多,下面写了一个最简单的代码:

        String KEY = "test";
        // ============== 初始化规则 =========
        List<FlowRule> rules = new ArrayList<FlowRule>();
        FlowRule rule1 = new FlowRule();
        rule1.setResource(KEY);
        // set limit qps to 20
        rule1.setCount(20);
        rule1.setGrade(RuleConstant.FLOW_GRADE_QPS);
        rule1.setLimitApp("default");
        rules.add(rule1);
        rule1.setControlBehavior(CONTROL_BEHAVIOR_DEFAULT);
        FlowRuleManager.loadRules(rules);
        // ================ 限流判定 ===========
        Entry entry = null;

        try {
            entry = SphU.entry(KEY);
            // do something

        } catch (BlockException e1) {
            // 限流会抛出BlockException 异常
        }finally {
            if (entry != null) {
                entry.exit();
            }
        }
  • Step1:在sentinel中比较强调Resource这个概念,我们所保护的或者说所作用于都是基于Resource来说,所以我们首先需要确定我们的Resource的key,这里我们简单的设置为test了。

  • Step2:然后我们初始化我们这个Resource的一个限流规则,我们这里选择的是针对QPS限流并且策略选择的是默认,这里默认的话就是使用的滑动窗口版的计数器,然后加载到全局的规则管理器里面,整个规则的设置和guava的差别比较大。

  • Step3: 在sentinel第二个比较重要的概念就是Entry,Entry表示一次资源操作,内部会保存当前invocation信息,在finally的时候需要对entry进行退出。我们执行限流判定的时候实际上也就是获取Entry, SphU.entry 也就是我们执行我们上面限流规则的关键,这里和guava不一样如果被限流了,就会抛出BlockException,我们在进行限流的处理。

虽然sentinel的使用整体比guava复杂很多,但是算法的可选比guava的限流也多一点。

基于并发数(线程数)

我们之前介绍的都是基于QPS的,在sentinel中提供了基于并发数的策略,效果类似于信号量隔离,当我们需要让业务线程池不被慢调用耗尽,我们就可以使用这种模式。

通常来说我们同一个服务提供的http接口都是使用的一个线程池,比如我们使用的tomcat-web服务器那么我们就会有个tomcat的业务线程池,如果在http中有两个方法A和B,B的速度相对来说比较快,A的速度相对来说比较慢,如果大量的调用A这个方法,由于A的速度太慢,线程得不到释放,有可能导致线程池被耗尽,另一个方法B就得不到线程。这个场景我们之前有遇到过直接导致整个服务所接收的请求全部被拒绝。有的同学说限制A的QPS不是就可以了吗,要注意的是QPS是每秒的,如果我们这个A接口的耗时大于1s,那么下一波A来了之后QPS是要重新计算的。

基于这个就提供了基于并发数的限流,我们设置Grade为 FLOW_GRADE_THREAD ,就可以实现这个限流模式。

基于QPS

基于QPS的限流sentinel也提供了4种策略:

  • 默认策略:设置Behavior为 CONTROL_BEHAVIOR_DEFAULT ,这个模式是滑动窗口计数器模式。这种方式适用于对系统处理能力确切已知的情况下,比如通过压测确定了系统的准确水位时。

  • Warm Up:设置为Behavior为 CONTROL_BEHAVIOR_WARM_UP ,类似之前guava中介绍的warmup。预热启动方式。当系统长期处于低水位的情况下,当流量突然增加时,直接把系统拉升到高水位可能瞬间把系统压垮。这个模式下QPS的曲线图如下:

V7vQ7vm.png!mobile
  • 匀速排队:设置Behavior为 CONTROL_BEHAVIOR_RATE_LIMITER ,这个模式其实就是漏斗算法,优缺点之前也讲解过了

  • Warm Up + 匀速排队:设置Behavior为CONTROL_BEHAVIOR_WARM_UP_RATE_LIMITER,之前warm up到高水位之后使用的是滑动窗口的算法限流,这个模式下继续使用匀速排队的算法。

基于调用关系

sentinel提供了更为复杂的一种限流,可以基于调用关系去做更为灵活的限流:

  • 根据调用方限流:调用方的限流使用比较复杂,需要调用 ContextUtil.enter(resourceName, origin) ,origin就是我们的调用方标识,然后在我们的rule设置参数的时候,对limitApp进行设置就可以进行对调用方的限流:

    • 设置为 default ,默认对所有调用方都限流。

    • 设置为 {some_origin_name} ,代表对特定的调用者才限流。

    • 设置为 other ,会对配置的一个referResource参数代表的调用者除外的进行限流。

  • 关联流量控制:在sentinel中也支持,两个有关联的资源可以互相影响流量控制,比如有两个接口都使用的是同一个资源,一个接口比较重要,另外一个接口不是那么重要,我们可以设置一个规则当重要的接口大量访问的时候,就可以对另外一个不重要接口进行限流,防止这个接口突然出现流量影响重要的接口。

sentinel的一些问题

sentinel虽然提供了这么多算法,但是也有一些问题:

  • 首先来说sentinel上手比较难,对比guava的两行代码来说,使用sentinel需要了解一些名词,然后针对这些名词再来使用,虽然sentinel提供了一些注解来帮助我们简化使用,但是整体来说还是比guava要复杂。

  • sentinel有一定的运维成本,sentinel的使用往往需要搭建sentinel的server后台,对比guava的开箱即用来说,有一定的运维成本。

  • sentinel的限流统计有一定的并发问题,在sentinel的源码中是没有加锁的地方的,极端情况下如果qps限制的是10,如果有100个同时过限流的逻辑,这个时候都会通过,而guava不会发生这样的情况。

这些问题基本上都是和guava的限流来比较的,毕竟sentinel的功能更多,付出的成本相对来说也会更多。

集群限流

之前说的所有限流都是单机限流,但是我们现在都是微服务集群的架构模式,通常一个服务会有多台机器,比如有一个订单服务,这个服务有10台机器,那么我们想做整个集群限流到500QPS,我们应该怎么去做呢?这个很简单,直接每台机器都限流50就好了,50*10就是500,但是在现实环境中会出现负载不均衡的情况,在微服务调用的时候负载均衡的算法多种多样,比如同机房优先,轮训,随机等算法,这些算法都有可能导致我们的负载不是特别的均衡,就会导致我们整个集群的QPS可能有没有500,甚至在400的时候就被限流了,这个是我们真实场景中所遇到过的。既然单机限流有问题,那么我们应该设计一个更加完善的集群限流的方案

redis

这个方案不依赖限流的框架,我们整个集群使用同一个redis即可,需要自己封装一下限流的逻辑,这里我们使用最简单的计数器去设计,我们将我们的系统时间以秒为单位作为key,设置到redis里面(可以设置一定的过期时间用于空间清理),利用redis的int原子加法,每来一个请求都进行+1,然后再判断当前值是否超过我们限流的最大值。

redis的方案实现起来整体来说比较简单,但是强依赖我们的系统时间,如果不同机器之间的系统时间有偏差限流就有可能不准确。

sentinel

在sentinel中提供了集群的解决方案,这个对比其他的一些限流框架是比较有特色的。在sentinel中提供了两种模式:

  • 独立模式:限流服务作为单独的server进行部署,如下图所示,所有的应用都向单独部署的token-server进行获取token,这种模式适用于跨服务之间的全局限流,比如下面图中,A和B都会去token-server去拿,这个场景一般来说比较少,更多的还是服务内集群的限流比较多。

EBfYbyr.png!mobile
  • 内嵌模式:在内嵌模式下,我们会把server部署到我们应用实例中,我们也可以通过接口转换我们的server-client身份,当然我们可以自己引入一些zk的一些逻辑设置让我们的leader去当server,机器挂了也可以自动切换。这种比较适合同一个服务集群之间的限流,灵活性比较好,但是要注意的是大量的token-server的访问也有可能影响我们自己的机器。

    mm6RVv.png!mobile

当然sentinel也有一些兜底的策略,如果token-server挂了我们可以退化成我们单机限流的模式,不会影响我们正常的服务。

实战

我们上面已经介绍了很多限流的工具,但是很多同学对怎么去限流仍然比较迷惑。我们如果对一个场景或者一个资源做限流的话有下面几个点需要确认一下:

  • 什么地方去做限流

  • 限多少流

  • 怎么去选择工具

什么地方去做限流

这个问题比较复杂,很多公司以及很多团队的做法都不相同,在美团的时候搞了一波SOA,那个时候我记得所有的服务所有的接口都需要做限流,叫每个团队去给接口评估一个合理的QPS上限,这样做理论上来说是对的,我们每个接口都应该给与一个上限,防止把整体系统拖垮,但是这样做的成本是非常之高的,所以大部分公司还是选择性的去做限流。

首先我们需要确定一些核心的接口,比如电商系统中的下单,支付,如果流量过大那么电商系统中的路径就有问题,比如像对账这种边缘的接口(不影响核心路径),我们可以不设置限流。

其次我们不一定只在接口层才做限流,很多时候我们直接在网关层把限流做了,防止流量进一步渗透到核心系统中。当然前端也能做限流,当前端捕获到限流的错误码之后,前端可以提示等待信息,这个其实也算是限流的一部分。其实当限流越在下游触发我们的资源的浪费就越大,因为在下游限流之前上游已经做了很多工作了,如果这时候触发限流那么之前的工作就会白费,如果涉及到一些回滚的工作还会加大我们的负担,所以对于限流来说应该是越上层触发越好。

限多少流

限多少流这个问题大部分的时候可能就是一个历史经验值,我们可以通过日常的qps监控图,然后再在这个接触上加一点冗余的QPS可能这个就是我们的限流了。但是有一个场景需要注意,那就是大促(这里指的是电商系统里面的场景,其他系统类比流量较高的场景)的时候,我们系统的流量就会突增,再也不是我们日常的QPS了,这种情况下,往往需要我们在大促之前给我们系统进行全链路压测,压测出一个合理的上限,然后限流就基于这个上限去设置。

怎么去选择工具

一般来说大一点的互联网公司都有自己的统一限流的工具这里直接采用就好。对于其他情况的话,如果没有集群限流或者熔断这些需求,我个人觉得选择RateLimter是一个比较不错的选择,应该其使用比较简单,基本没有学习成本,如果有其他的一些需求我个人觉得选择sentinel,至于hytrix的话我个人不推荐使用,因为这个已经不再维护了。

总结

限流虽然只有两个字,但是真正要理解限流,做好限流,是一件非常不容易的事,对于我个人而已,这篇文章也只是一些浅薄的见识,如果大家有什么更好的意见可以关注我的公众号留言进行讨论。

如果大家觉得这篇文章对你有帮助,你的关注和转发是对我最大的支持,O(∩_∩)O:

quYJJjm.jpg!mobile

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK