谈谈高并发系统的限流

开涛大神在博客中说过:在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流。本文结合做者的一些经验介绍限流的相关概念、算法和常规的实现方式。html

缓存

缓存比较好理解,在大型高并发系统中,若是没有缓存数据库将分分钟被爆,系统也会瞬间瘫痪。使用缓存不仅仅可以提高系统访问速度、提升并发访问量,也是保护数据库、保护系统的有效方式。大型网站通常主要是“读”,缓存的使用很容易被想到。在大型“写”系统中,缓存也经常扮演者很是重要的角色。好比累积一些数据批量写入,内存里面的缓存队列(生产消费),以及HBase写数据的机制等等也都是经过缓存提高系统的吞吐量或者实现系统的保护措施。甚至消息中间件,你也能够认为是一种分布式的数据缓存。node

降级

服务降级是当服务器压力剧增的状况下,根据当前业务状况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行。降级每每会指定不一样的级别,面临不一样的异常等级执行不一样的处理。根据服务方式:能够拒接服务,能够延迟服务,也有时候能够随机服务。根据服务范围:能够砍掉某个功能,也能够砍掉某些模块。总之服务降级须要根据不一样的业务需求采用不一样的降级策略。主要的目的就是服务虽然有损可是总比没有好。nginx

限流

限流能够认为服务降级的一种,限流就是限制系统的输入和输出流量已达到保护系统的目的。通常来讲系统的吞吐量是能够被测算的,为了保证系统的稳定运行,一旦达到的须要限制的阈值,就须要限制流量并采起一些措施以完成限制流量的目的。好比:延迟处理,拒绝处理,或者部分拒绝处理等等。算法

 

限流的算法

常见的限流算法有:计数器、漏桶和令牌桶算法。数据库

计数器api

计数器是最简单粗暴的算法。好比某个服务最多只能每秒钟处理100个请求。咱们能够设置一个1秒钟的滑动窗口,窗口中有10个格子,每一个格子100毫秒,每100毫秒移动一次,每次移动都须要记录当前服务请求的次数。内存中须要保存10次的次数。能够用数据结构LinkedList来实现。格子每次移动的时候判断一次,当前访问次数和LinkedList中最后一个相差是否超过100,若是超过就须要限流了。缓存

image

很明显,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。服务器

示例代码以下:网络

//服务访问次数,能够放在Redis中,实现分布式系统的访问计数
Long counter = 0L;
//使用LinkedList来记录滑动窗口的10个格子。
LinkedList<Long> ll = new LinkedList<Long>();

public static void main(String[] args)
{
    Counter counter = new Counter();

    counter.doCheck();
}

private void doCheck()
{
    while (true)
    {
        ll.addLast(counter);
        
        if (ll.size() > 10)
        {
            ll.removeFirst();
        }
        
        //比较最后一个和第一个,二者相差一秒
        if ((ll.peekLast() - ll.peekFirst()) > 100)
        {
            //To limit rate
        }
        
        Thread.sleep(100);
    }
}

 

漏桶算法数据结构

漏桶算法即leaky bucket是一种很是经常使用的限流算法,能够用来实现流量整形(Traffic Shaping)和流量控制(Traffic Policing)。贴了一张维基百科上示意图帮助你们理解:

image

漏桶算法的主要概念以下:

  • 一个固定容量的漏桶,按照常量固定速率流出水滴;

  • 若是桶是空的,则不需流出水滴;

  • 能够以任意速率流入水滴到漏桶;

  • 若是流入水滴超出了桶的容量,则流入的水滴溢出了(被丢弃),而漏桶容量是不变的。

 

漏桶算法比较好实现,在单机系统中可使用队列来实现(.Net中TPL DataFlow能够较好的处理相似的问题,你能够在这里找到相关的介绍),在分布式环境中消息中间件或者Redis都是可选的方案。

 

令牌桶算法

令牌桶算法是一个存放固定容量令牌(token)的桶,按照固定速率往桶里添加令牌。令牌桶算法基本能够用下面的几个概念来描述:

  • 令牌将按照固定的速率被放入令牌桶中。好比每秒放10个。
  • 桶中最多存放b个令牌,当桶满时,新添加的令牌被丢弃或拒绝。
  • 当一个n个字节大小的数据包到达,将从桶中删除n个令牌,接着数据包被发送到网络上。
  • 若是桶中的令牌不足n个,则不会删除令牌,且该数据包将被限流(要么丢弃,要么缓冲区等待)。

以下图:

image

令牌算法是根据放令牌的速率去控制输出的速率,也就是上图的to network的速率。to network咱们能够理解为消息的处理程序,执行某段业务或者调用某个RPC。

 

漏桶和令牌桶的比较

令牌桶能够在运行时控制和调整数据处理的速率,处理某时的突发流量。放令牌的频率增长能够提高总体数据处理的速度,而经过每次获取令牌的个数增长或者放慢令牌的发放速度和下降总体数据处理速度。而漏桶不行,由于它的流出速率是固定的,程序处理速度也是固定的。

总体而言,令牌桶算法更优,可是实现更为复杂一些。

 

 

限流算法实现

Guava

Guava是一个Google开源项目,包含了若干被Google的Java项目普遍依赖的核心库,其中的RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现。

1. 常规速率:

建立一个限流器,设置每秒放置的令牌数:2个。返回的RateLimiter对象能够保证1秒内不会给超过2个令牌,而且是固定速率的放置。达到平滑输出的效果

public void test()
{
    /**
     * 建立一个限流器,设置每秒放置的令牌数:2个。速率是每秒能够2个的消息。
     * 返回的RateLimiter对象能够保证1秒内不会给超过2个令牌,而且是固定速率的放置。达到平滑输出的效果
     */
    RateLimiter r = RateLimiter.create(2);

    while (true)
    {
        /**
         * acquire()获取一个令牌,而且返回这个获取这个令牌所须要的时间。若是桶里没有令牌则等待,直到有令牌。
         * acquire(N)能够获取多个令牌。
         */
        System.out.println(r.acquire());
    }
}

上面代码执行的结果以下图,基本是0.5秒一个数据。拿到令牌后才能处理数据,达到输出数据或者调用接口的平滑效果。acquire()的返回值是等待令牌的时间,若是须要对某些突发的流量进行处理的话,能够对这个返回值设置一个阈值,根据不一样的状况进行处理,好比过时丢弃。

image

 

2. 突发流量:

突发流量能够是突发的多,也能够是突发的少。首先来看个突发多的例子。仍是上面例子的流量,每秒2个数据令牌。以下代码使用acquire方法,指定参数。

System.out.println(r.acquire(2));
System.out.println(r.acquire(1));
System.out.println(r.acquire(1));
System.out.println(r.acquire(1));

获得以下相似的输出。

image

 

若是要一次新处理更多的数据,则须要更多的令牌。代码首先获取2个令牌,那么下一个令牌就不是0.5秒以后得到了,仍是1秒之后,以后又恢复常规速度。这是一个突发多的例子,若是是突发没有流量,以下代码:

System.out.println(r.acquire(1));
Thread.sleep(2000);
System.out.println(r.acquire(1));
System.out.println(r.acquire(1));
System.out.println(r.acquire(1));

获得以下相似的结果:

image

等了两秒钟以后,令牌桶里面就积累了3个令牌,能够连续不花时间的获取出来。处理突发其实也就是在单位时间内输出恒定。这两种方式都是使用的RateLimiter的子类SmoothBursty。另外一个子类是SmoothWarmingUp,它提供的有必定缓冲的流量输出方案。

 

/**
* 建立一个限流器,设置每秒放置的令牌数:2个。速率是每秒能够210的消息。
* 返回的RateLimiter对象能够保证1秒内不会给超过2个令牌,而且是固定速率的放置。达到平滑输出的效果
* 设置缓冲时间为3秒
*/
RateLimiter r = RateLimiter.create(2,3,TimeUnit.SECONDS);

while (true) {
    /**
     * acquire()获取一个令牌,而且返回这个获取这个令牌所须要的时间。若是桶里没有令牌则等待,直到有令牌。
     * acquire(N)能够获取多个令牌。
     */
    System.out.println(r.acquire(1));
    System.out.println(r.acquire(1));
    System.out.println(r.acquire(1));
    System.out.println(r.acquire(1));
}

输出结果以下图,因为设置了缓冲的时间是3秒,令牌桶一开始并不会0.5秒给一个消息,而是造成一个平滑线性降低的坡度,频率愈来愈高,在3秒钟以内达到本来设置的频率,之后就以固定的频率输出。图中红线圈出来的3次累加起来正好是3秒左右。这种功能适合系统刚启动须要一点时间来“热身”的场景。

image

 

Nginx

对于Nginx接入层限流可使用Nginx自带了两个模块:链接数限流模块ngx_http_limit_conn_module和漏桶算法实现的请求限流模块ngx_http_limit_req_module。

1. ngx_http_limit_conn_module

咱们常常会遇到这种状况,服务器流量异常,负载过大等等。对于大流量恶意的攻击访问,会带来带宽的浪费,服务器压力,影响业务,每每考虑对同一个ip的链接数,并发数进行限制。ngx_http_limit_conn_module 模块来实现该需求。该模块能够根据定义的键来限制每一个键值的链接数,如同一个IP来源的链接数。并非全部的链接都会被该模块计数,只有那些正在被处理的请求(这些请求的头信息已被彻底读入)所在的链接才会被计数。

咱们能够在nginx_conf的http{}中加上以下配置实现限制:

#限制每一个用户的并发链接数,取名one
limit_conn_zone $binary_remote_addr zone=one:10m;

#配置记录被限流后的日志级别,默认error级别
limit_conn_log_level error;
#配置被限流后返回的状态码,默认返回503
limit_conn_status 503;

而后在server{}里加上以下代码:

#限制用户并发链接数为1
limit_conn one 1;

而后咱们是使用ab测试来模拟并发请求:
ab -n 5 -c 5 http://10.23.22.239/index.html

获得下面的结果,很明显并发被限制住了,超过阈值的都显示503:

image

另外刚才是配置针对单个IP的并发限制,仍是能够针对域名进行并发限制,配置和客户端IP相似。

#http{}段配置
limit_conn_zone $ server_name zone=perserver:10m;
#server{}段配置
limit_conn perserver 1;

 

2. ngx_http_limit_req_module

上面咱们使用到了ngx_http_limit_conn_module 模块,来限制链接数。那么请求数的限制该怎么作呢?这就须要经过ngx_http_limit_req_module 模块来实现,该模块能够经过定义的键值来限制请求处理的频率。特别的,能够限制来自单个IP地址的请求处理频率。 限制的方法是使用了漏斗算法,每秒固定处理请求数,推迟过多请求。若是请求的频率超过了限制域配置的值,请求处理会被延迟或被丢弃,因此全部的请求都是以定义的频率被处理的。

在http{}中配置

#区域名称为one,大小为10m,平均处理的请求频率不能超过每秒一次。

limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;

在server{}中配置

#设置每一个IP桶的数量为5
limit_req zone=one burst=5;

上面设置定义了每一个IP的请求处理只能限制在每秒1个。而且服务端能够为每一个IP缓存5个请求,若是操做了5个请求,请求就会被丢弃。

使用ab测试模拟客户端连续访问10次:ab -n 10 -c 10 http://10.23.22.239/index.html

以下图,设置了通的个数为5个。一共10个请求,第一个请求立刻被处理。第2-6个被存放在桶中。因为桶满了,没有设置nodelay所以,余下的4个请求被丢弃。

image

 

 

 

 

架构点滴

相关文章
相关标签/搜索