0

限流浅析

 3 years ago
source link: https://segmentfault.com/a/1190000038400601
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.
neoserver,ios ssh client

前言

我们每个系统在做压测的时候,都有一个处理峰值,当接近峰值继续接受请求的时候,会导致整个系统响应缓慢;为了保护系统,需要拒绝处理过载的请求,这就是我们下面介绍的限流,通过设定一个峰值阈值,限制请求达到这个峰值,以此来保护系统;我们常见的一些中间件比如tomcat,mysql,redis等等都有类似的限制。

限流算法

做限流的时候我们有一些常用的限流算法包括:计数器限流,令牌桶限流,漏桶限流;

  • 1.令牌桶限流

令牌桶算法的原理是系统以一定速率向桶中放入令牌,填满了就丢弃令牌;请求来时会先从桶中取出令牌,如果能取到令牌,则可以继续完成请求,否则等待或者拒绝服务;令牌桶允许一定程度突发流量,只要有令牌就可以处理,支持一次拿多个令牌;

  • 2.漏桶限流

漏桶算法的原理是按照固定常量速率流出请求,流入请求速率任意,当请求数超过桶的容量时,新的请求等待或者拒绝服务;可以看出漏桶算法可以强制限制数据的传输速度;

  • 3.计数器限流

计数器是一种比较简单粗暴的算法,主要用来限制总并发数,比如数据库连接池、线程池、秒杀的并发数;计数器限流只要一定时间内的总请求数超过设定的阀值则进行限流;

如何限流

了解了限流算法之后,我们需要知道在什么地方限流,以及如何限流;对于一个系统来说我们常常可以在接入层进行限流,这个大部分情况下可以直接使用nginx,OpenResty等中间件直接处理;也可以在业务层进行限流,这个需要根据我们不同的业务需求使用相关的限流算法来处理。

业务层限流

对于业务层我们可能是单节点的,也可能是多节点用户绑定的,也可能是多节点无绑定的;这时候我们就要区分是进程内的限流还是需要分布式限流。

进程内限流

对于进程内限流相对来说还是比较简单的,guava是我们经常使用的利器,下面分别看看如何限制接口的总并发量,某个时间窗口的请求数,以及使用令牌桶和漏桶算法更加平滑的限流;

  • 限制接口的总并发量

只需要配置一个总并发量,然后使用一个计算器记录每次请求,然后和总并发量比较即可:

private static int max = 10;
private static AtomicInteger limiter = new AtomicInteger();

if (limiter.incrementAndGet() > max){
    System.err.println("超过最大限制数");
    return;
}
  • 限制时间窗口请求数

限制某个接口在指定时间之内的请求量,可以使用guava的cache来缓存计数器,然后再设置过期时间;比如下面设置每分钟最大请求为100:

LoadingCache<Long, AtomicLong> counter = CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.MINUTES).build(new CacheLoader<Long, AtomicLong>() {
        @Override
        public AtomicLong load(Long key) throws Exception {
            return new AtomicLong(0);
        }
});

private static int max = 100;
long curMinutes = System.currentTimeMillis() / 1000 * 60;
if (counter.get(curMinutes).incrementAndGet() > max) {
    System.err.println("时间窗口请求数超过上限");
    return;
}

过期时间为一分钟,每分钟自动清零;这种处理方式可能会出现超限的情况,比如前59秒都没有消息,到60的时候一下子来了200条消息,这时候先接受了100条消息,刚好到期计数器清0,然后又接受了100条消息;这种情况可以参考TCP的滑动窗口思路来解决。

  • 平滑限流请求

计数器的方式还是比较粗暴的,令牌桶和漏桶限流这两种算法相对来说还是比较平滑的,可以直接使用guava提供的RateLimiter类:

RateLimiter limiter = RateLimiter.create(2);
System.out.println(limiter.acquire(4));
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire(2));
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());

create(2)表示桶容量为2并且每秒新增2个令牌,也就是500毫秒新增一个令牌,acquire()表示从里面获取一个令牌,返回值为等待的时间,输出结果如下:

0.0
1.998633
0.49644
0.500224
0.999335
0.500186

可以看到此算法是允许一定突发情况的,第一次获取4个令牌等待时间为0,后面再获取需要等待2秒才可以,后面每次获取需要500毫秒。

分布式限流

现在大部分系统都采用了多节点部署,所以一个业务可能在多个进程内被处理,所以这时候分布式限流必不可少,比如常见的秒杀系统,可能同时有N台业务逻辑节点;

常规的做法是使用Redis+lua和OpenResty+lua来实现,将限流服务做成原子化,同时也要保证高性能;Redis和OpenResty都已高性能著称,同时也提供了原子化方案,具体如下所示;

  • Redis+lua

Redis在服务端对消息的处理是单线程的,同时支持lua脚本的执行,可以将限流的相关逻辑用lua脚本实现,来保证原子性,大体实现如下:

-- 限流 key
local key = KEYS[1]
-- 限流大小
local limit = tonumber(ARGV[1])
-- 过期时间
local expire = tonumber(ARGV[2])

local current = tonumber(redis.call('get',key) or "0")

if current + 1 > limit then
    return 0;
else
    redis.call("INCRBY", key, 1)
    redis.call("EXPIRE", key, expire)
    return current + 1
end

以上使用计数器算法来实现限流,在调用lua的地方可以传入限流key,限流大小以及key的有效期;返回结果如果为0表示超出限流大小,否则返回当前累计的值。

  • OpenResty+lua

OpenResty核心就是nginx,但是在这个基础之上加了很多第三方模块,ngx_lua模块将lua嵌入到了nginx中,使得nginx可以作为一个web服务器来使用;还有其他常用的开发模块如:lua-resty-lock,lua-resty-limit-traffic,lua-resty-memcached,lua-resty-mysql,lua-resty-redis等等;

本小节我们先使用lua-resty-lock模块来实现一个简单计数器限流,相关lua代码如下:

local locks = require "resty.lock";

local function acquire()
    local lock = locks:new("locks");
    local elapsed, err = lock:lock("limit_key");
    local limit_counter = ngx.shared.limit_counter;
    --获取客户端ip
    local key = ngx.var.remote_addr;
    --限流大小
    local limit = 5; 
    local current = limit_counter:get(key);
    
    --打印key和当前值
    ngx.say("key="..key..",value="..tostring(current));
    
    if current ~= nil and current + 1 > limit then 
       lock:unlock();
       return 0;
    end
    
    if current == nil then 
       limit_counter:set(key,1,5); --设置过期时间为5秒
    else 
       limit_counter:incr(key,1);
    end
    lock:unlock();
    return 1;
end

以上是一个对ip进行限流的实例,因为需要保证原子性,所以使用了resty.lock模块,同时也类似redis设置了过期时间重置,另外一点需要注意对锁的释放;还需要设置两个共享字典

http {
    ...
    #lua_shared_dict <name> <size> 定义一块名为name的共享内存空间,内存大小为size;  通过该命令定义的共享内存对象对于Nginx中所有worker进程都是可见的
    lua_shared_dict locks 10m;
    lua_shared_dict limit_counter 10m;
}

接入层限流

接入层通常就是流量入口处,Nginx被很多系统用作流量入口,当然OpenResty也不例外,而且OpenResty提供了更强大的功能,比如这里将要介绍的lua-resty-limit-traffic模块,是一个功能强大的限流模块;在使用lua-resty-limit-traffic之前我们先大致看一下如何使用OpenResty;

OpenResty安装使用

  • 下载安装配置

直接去官方下载即可: http://openresty.org/en/download.html ,启动,重载,停止命令如下:

nginx.exe
nginx.exe -s reload
nginx.exe -s stop

打开ip+端口,可以看到:Welcome to OpenResty! 即表示启动成功;

  • lua脚本实例

首先需要在nginx.conf的http目录下做如下配置:

http {
    ...
    lua_package_path "/lualib/?.lua;;";  #lua 模块  
    lua_package_cpath "/lualib/?.so;;";  #c模块   
    include lua.conf;   #导入自定义lua配置文件
}

这里自定义了一个lua.conf,有关lua的请求都在这里面配置,放在和nginx.conf一个路径下即可;已一个test.lua为例,lua.conf配置如下:

#lua.conf  
server {  
    charset utf-8; #设置编码
    listen       8081;  
    server_name  _;  
    location /test {  
        default_type 'text/html';  
        content_by_lua_file lua/api/test.lua;
    } 
}

这里把所有的lua文件都放在lua/api目录下,比如一个最简单的hello world:

ngx.say("hello world");

lua-resty-limit-traffic模块

lua-resty-limit-traffic提供了限制最大并发连接数,时间窗口请求数,以及平滑限制请求数三种方式,分别对应:resty.limit.conn,resty.limit.count,resty.limit.req;相关文档可以直接在pod/lua-resty-limit-traffic中找到,里面有完整的实例;

以下会用到三个共享字典,事先在http下配置:

http {
    lua_shared_dict my_limit_conn_store 100m;
    lua_shared_dict my_limit_count_store 100m;
    lua_shared_dict my_limit_req_store 100m;
}
  • 限制最大并发连接数

提供的resty.limit.conn限制最大连接数,具体脚本如下:

local limit_conn = require "resty.limit.conn"

--B<syntax:> C<obj, err = class.new(shdict_name, conn, burst, default_conn_delay)>
local lim, err = limit_conn.new("my_limit_conn_store", 1, 0, 0.5)
if not lim then
    ngx.log(ngx.ERR,
            "failed to instantiate a resty.limit.conn object: ", err)
    return ngx.exit(500)
end

local key = ngx.var.binary_remote_addr
local delay, err = lim:incoming(key, true)
if not delay then
    if err == "rejected" then
        return ngx.exit(502)
    end
    ngx.log(ngx.ERR, "failed to limit req: ", err)
    return ngx.exit(500)
end

if lim:is_committed() then
    local ctx = ngx.ctx
    ctx.limit_conn = lim
    ctx.limit_conn_key = key
    ctx.limit_conn_delay = delay
end

local conn = err

if delay >= 0.001 then
    ngx.sleep(delay)
end

new()参数分别是:字典名称,允许的最大并发请求数,允许的突发连接数,连接延迟;

incoming()中commit是一个布尔值,当为true时表示记录当前请求的数量,否则就直接运行;

返回值:如果请求不超过方法中指定的conn值,则此方法返回0作为延迟以及当前时间的并发请求(或连接)数;

  • 限制时间窗口请求数

提供的resty.limit.count可以限制一定请求数在一个时间窗口内,具体脚本如下:

local limit_count = require "resty.limit.count"

--B<syntax:> C<obj, err = class.new(shdict_name, count, time_window)>
--速率限制在20/10s
local lim, err = limit_count.new("my_limit_count_store", 20, 10)
if not lim then
    ngx.log(ngx.ERR, "failed to instantiate a resty.limit.count object: ", err)
    return ngx.exit(500)
end

local local key = ngx.var.binary_remote_addr
--B<syntax:> C<delay, err = obj:incoming(key, commit)>
local delay, err = lim:incoming(key, true)

if not delay then
    if err == "rejected" then
        return ngx.exit(503)
    end
    ngx.log(ngx.ERR, "failed to limit count: ", err)
    return ngx.exit(500)
end

new()中指定的三个参数分别是:字典名称,指定的请求阈值,请求个数复位前的窗口时间,以秒为单位;

incoming()中commit是一个布尔值,当为true时表示记录当前请求的数量,否则就直接运行;

返回值:如果请求数在限制范围内,则返回当前请求被处理的延迟和将被处理的请求的剩余数;

  • 平滑限制请求数

提供的resty.limit.req可以已更加平滑的方式限制请求,具体脚本如下:

local limit_req = require "resty.limit.req"

--B<syntax:> C<obj, err = class.new(shdict_name, rate, burst)>
--限制在200个请求/秒以下,给与100个请求/秒的突发请求;也就说每秒请求最大可以200-300之间,超出300报错
local lim, err = limit_req.new("my_limit_req_store", 200, 100)
if not lim then
    ngx.log(ngx.ERR,
            "failed to instantiate a resty.limit.req object: ", err)
    return ngx.exit(500)
end

local key = ngx.var.binary_remote_addr
local delay, err = lim:incoming(key, true)
if not delay then
    if err == "rejected" then
        return ngx.exit(503)
    end
    ngx.log(ngx.ERR, "failed to limit req: ", err)
    return ngx.exit(500)
end

if delay >= 0.001 then
    local excess = err
    ngx.sleep(delay)
end

new()三个参数分别是:字典名称,请求速率(每秒数)阈值,每秒允许延迟的过多请求数;

incoming()中commit是一个布尔值,当为true时表示记录当前请求的数量,否则就直接运行,可以理解为一个开关;

返回值:如果请求数在限制范围内,则此方法返回0作为当前时间的延迟和每秒过多请求的(零)个数;

更多可以直接查看官方文档:pod/lua-resty-limit-traffic目录下

总结

本文首先介绍了常见的限流算法,然后介绍在业务层进程内和分布式应用分别是如何进行限流的,最后接入层通过OpenResty的lua-resty-limit-traffic模块进行限流。

感谢关注

可以关注微信公众号「 回滚吧代码 」,第一时间阅读,文章持续更新;专注Java源码、架构、算法和面试。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK