限流浅析

ksfzhaohui

前言

我们每个系统在做压测的时候,都有一个处理峰值,当接近峰值继续接受请求的时候,会导致整个系统响应缓慢;为了保护系统,需要拒绝处理过载的请求,这就是我们下面介绍的限流,通过设定一个峰值阈值,限制请求达到这个峰值,以此来保护系统;我们常见的一些中间件比如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源码、架构、算法和面试。
阅读 857

ksfzhaohui技术专栏
专注java后端技术分析
376 声望
58 粉丝
0 条评论
你知道吗?

376 声望
58 粉丝
宣传栏