目录
限流算法
计算器
滑动窗口
令牌桶
漏桶算法
接入层限流
ngx_http_limit_conn_module
使用简介
limit_conn
limit_conn_zone
limit_conn 的执行流程
ngx_http_limit_req_module
limit_req
执行流程:
分布式限流
redis+lua限流
nginx+lua
应用级限流
限制接口并发
时间窗口限流
平滑接口限流
高并发的解决策略有很多,可以采用缓存、降级、限流等方法。但是有的时候需要用到限流,来保持我们系统的稳定性和可用性。比如像一些核心的服务:秒杀抢购,一些同步写的服务;还有一些比较耗时的操作,比如下载资源、上传文件等。限流的目的是对我们客户端的访问速率进行限制,保护我们的系统在可以承受的吞吐量范围内对外提供持续的服务,一但达到了访问速率就拒绝服务,返回兜底数据或兜底页面等,如商品详情页直接返回有库存。
一般限流有:限制总并发数:数据库连接池、线程池;限制瞬时并发:nignx的limit_conn模块,限制时间窗口内平均速率:guava的RateLimiter、nginx的limit_req模块,还有限制远程接口调用频率、限制mq的消费速率。除了根据请求数量或者请求速率限流,还可以根据系统资源限流,比如内存资源、网络;连接数、cpu使用等来进行限制。在我们实际使用的过程中,不管是使用什么方法,只要能满足当前系统要求,满足公司资源都是可行的。下面将从限流算法、接入层限流、分布式限流、应用级限流四个模块阐述限流的使用。
简单粗暴的限流方式,适合一些粗粒度的限流场景,只需限制总的并发数,对速率没有要求,即可采用这种方式。对总数限流可以采用计数器、信号量等方式来实现。在代码实现时可以通过自定义注解和aop拦截需要执行的业务,对我们需要限流的业务进行限流,也可以通过结合redis来实现全局限流。
计算器可以实现对总数的限制,但是不能实现对速率的控制。滑动窗口算法就是为了控制时间窗口内的总数,tcp中大量使用了滑动窗口的算法,像慢启动窗口、滑动窗口协议等。
我们定义每分钟最多接受4组数据,如下是一个时间窗口;
随着计数窗口的滑动,又可以接受新的数据;
但是可能出现下面的情况,在59秒前都没有数据,到了59秒的时候一下发来了4个数据分组。虽然在一分钟内的速率没有变,但是瞬时速率却放大了几十倍。
令牌桶算法是在一个存放固定容量的桶中按照固定速率添加令牌。
漏桶算法是在固定容量桶中按照不定速率流入,固定速率流出。漏桶算法可以用于流量整形和流量控制。
接入层限流指的是在流量请求入口做限制,通常有负载均衡、非法请求过滤、缓存、请求聚合、热点缓存查询、服务质量监控等。可以用nignx做接入层,ningx中有连接限流模块ngx_http_limit_conn_module和漏桶算法实现的请求限流模块ngx_http_limit_req_module模块,通过配置这两个模块来实现。在更加复杂的限流场景,还可以通过OpenResty的Lua限流模块lua-resty-limit-traffic来实现。
ngx_http_limit_conn_module用来对单个key对应的总的网络连接数做限流,可以按照ip、域名等进行限流。ngx_http_limit_req_module是对key请求的平均速率进行限流,对于不同的场景可以有选择地采用平滑模式和突发模式。
ngx_http_limit_conn_module
模块可以按照定义的键限定每个键值的连接数。特别的,可以设定单一 IP 来源的连接数。
并不是所有的连接都会被模块计数;只有那些正在被处理的请求(这些请求的头信息已被完全读入)所在的连接才会被计数。
语法: limit_conn zone number;
默认值: —
上下文: http, server, location
指定一块已经设定的共享内存空间,以及每个给定键值的最大连接数。当连接数超过最大连接数时,服务器将会返回 503 (Service Temporarily Unavailable) 错误。比如,如下配置
limit_conn_zone $binary_remote_addr zone=addr:10m;
server {
location /download/ {
limit_conn addr 1;
}
表示,同一 IP 同一时间只允许有一个连接。
语法: limit_conn_zone $variable zone=name:size;
默认值: —
上下文: http
设定保存各个键的状态的共享内存空间的参数。键的状态中保存了当前连接数。键的值可以是特定变量的任何非空值(空值将不会被考虑)。 使用范例:
limit_conn_zone $binary_remote_addr zone=addr:10m;
这里,设置客户端的IP地址作为键。注意,这里使用的是$binary_remote_addr
变量,而不是$remote_addr
变量。$remote_addr
变量的长度为7字节到15字节不等,而存储状态在32位平台中占用32字节或64字节,在64位平台中占用64字节。而$binary_remote_addr
变量的长度是固定的4字节,存储状态在32位平台中占用32字节或64字节,在64位平台中占用64字节。一兆字节的共享内存空间可以保存3.2万个32位的状态,1.6万个64位的状态。如果共享内存空间被耗尽,服务器将会对后续所有的请求返回 503 (Service Temporarily Unavailable) 错误。
ngx_http_limit_req_module
模块可以通过定义的 键值来限制请求处理的频率。特别的,它可以限制来自单个IP地址的请求处理频率。 限制的方法是通过一种“漏桶”的方法——固定每秒处理的请求数,推迟过多的请求处理。
句法: limit_req zone=name [burst=number] [nodelay | delay=number];
默认: -
语境: http,server,location
设置对应的共享内存限制域和允许被处理的最大请求数阈值。 如果请求的频率超过了限制域配置的值,请求处理会被延迟,所以 所有的请求都是以定义的频率被处理的。 超过频率限制的请求会被延迟,直到被延迟的请求数超过了定义的阈值 这时,这个请求会被终止,并返回503 (Service Temporarily Unavailable) 错误。这个阈值的默认值等于0。
配置示例:
http {
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;...
server {
...
location /search/ {
limit_req zone=one burst=5;
}
限制平均每秒不超过一个请求,同时允许超过频率限制的请求数不多于5个。
如果不希望超过的请求被延迟,可以用nodelay
参数:
limit_req zone=one burst=5 nodelay;
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
这里,状态被存在名为“one”,最大10M字节的共享内存里面。对于这个限制域来说 平均处理的请求频率不能超过每秒一次。
键值是客户端的IP地址。 如果不使用$remote_addr
变量,而用$binary_remote_addr
变量, 可以将每条状态记录的大小减少到64个字节,这样1M的内存可以保存大约1万6千个64字节的记录。 如果限制域的存储空间耗尽了,对于后续所有请求,服务器都会返回 503 (Service Temporarily Unavailable)错误。
请求频率可以设置为每秒几次(r/s)。如果请求的频率不到每秒一次, 你可以设置每分钟几次(r/m)。比如每秒半次就是30r/m。
(1)请求进入判断最后一次请求时间相对于当前时间(第一次为0)是否需要限流,如果需要,执行步骤2,否则执行步骤3;
(2)没有配置burst则容量为0,按照固定速率处理请求。如果被限流返回503;
如果burst>0&&延迟模式(没配置nodelay)。如果桶满了则新请求限流,否则按照固定速率处理(延迟使用休眠处理);
如果burst<0&&配置了nodelay,不会按照固定速率处理,允许突发模式处理请求。桶满了则限流,返回503错误码;
(3)请求处理;
(4) ningx会在相应时机选择一些(3个节点)限流key进行过期处理,回收内存。
分布式限流将服务做成原子化,对我们各个服务进行统一的限流。实现方式主要有redis+lua脚本或者nginx+lua脚本实现。
通过redis的单线程模式保证在lua脚本写并发安全。
lua脚本:
local key = "rate.limit:" .. KEYS[1] --限流KEY
local limit = tonumber(ARGV[1]) --限流大小
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then --如果超出限流大小
return 0
else --请求数+1,并设置2秒过期
redis.call("INCRBY", key,"1")
redis.call("expire", key,"2")
return current + 1
end
java代码:
@Bean
public DefaultRedisScript redisluaScript() {
DefaultRedisScript redisScript = new DefaultRedisScript<>();
//读取 lua 脚本
redisScript.setScriptSource(new ResourceScriptSource(new
ClassPathResource("limit.lua")));
redisScript.setResultType(Number.class);
return redisScript;
}
@Autowired
private RedisTemplate limitRedisTemplate;
pubLlic boolean acquire() {
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();
String ipAddress = getIpAddr(request);
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(ipAddress).append("-")
.append(targetClass.getName()).append("- ")
.append(method.getName()).append("-")
.append(rateLimit.key());
List keys = Collections.singletonList(stringBuffer.toString());
Number number = limitRedisTemplate.execute(redisluaScript, keys, rateLimit.count(), rateLimit.time());
if (number != null && number.intValue() != 0 && number.intValue() <= rateLimit.count()) {
logger.info("限流时间段内访问第:{} 次", number.toString());
return false;
}
reruen true;
}
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 --计数器
local key = "ip:" ..os.time()
local limit = 5 --限流大小
local current =limit_counter:get(key)
if current ~= nil and current + 1> limit then --如果超出限流大小
lock:unlock()
return 0
end
if current == nil then
limit_counter:set(key, 1, 1) --第一次需要设置过期时间,设置key的值为1,过期时间为1秒
else
limit_counter:incr(key, 1) --第二次开始加1即可
end
lock:unlock()
return 1
end
ngx.print(acquire())
使用lua-resty-lock互斥锁来解决原子性问题,并使用ngx.shared.dict共享字典来实现计数器。如果限流则返回0,否则返回1.
nginx 定义两个共享字典(存放锁和计数器):
http {
……
lua_shared_dict locks 10m;
lua_shared_dict limit_counter 10m;
}
流量过大时reids和nginx可能扛不住,这是我们可以基础方案进行优化。可以通过一致性hash算法将分布式限流进行分片,比如在redis限流时我们可以设计key尽量落在redis集群中不同节点中,这样在执行的时候就可以减轻单台服务器的压力和减少并发。还有就是可以在并发量过大时进行降级,降级为应用级限流等。根据具体的使用场景,具体做优化调整。
对于一个应用系统,由于服务器资源的限制允许的并发是有限的,我们需要对系统的TPS/QPS做一定的限制,防止大流量突然涌入击垮系统。可以通过服务器的配置来限制连接数、线程池大小等。比如tomcat服务,可以通过acceptCount限制连接数,maxConnection允许的瞬时最大连接数,maxThreads请求处理最大线程数。
对于一些稀缺资源,也可以进行一些限制。比如我们核心业务线程池的大小,数据库连接池大小限制等。
通过细粒度的编程可以实现对我们高并发接口的限流,必须一些秒杀抢购接口。可以对不同接口设置不同阈值,可以原子变量或者信号量来实现;
伪代码如下:
try{
if(num.incrementAndGet() > 限制){
//拒绝请求
}
} finally{
num.decrementAndGet();
}
这是一种简单粗暴的限流方式,只能简单得做到总访问量的限制,无法限制请求速率,根据实际情况使用。
一个服务能处理很多请求,但是不可能在很短的时间处理很多请求。所在需要根据应用场景对每秒、每分钟、每小时做请求限制,限制在时间窗口内请求数量。
LoadingCache counter = CacheBuilder.newBuilder()
.maximumSize(100) //最大缓存数目
.expireAfterAccess(2, TimeUnit.SECONDS) //缓存2秒后过期
.build(new CacheLoader() {
@Override
public AtomicLong load(Long seconds) throws Exception {
return new AtomicLong(0);
}
});
long limit = 1000;
while(ture) {
//得到当前秒
long currentSecond = System.currentTimeMillis() / 1000;
if(counter.get(currentSecond).incrementAndGet() > limit) {
//限流;
continue;
}
//业务处理
}
通过Guava的cache做限流存储计数器,过期时间为2秒,获取当前时间戳,取秒数作为key进行计数和限流。这种方式也很简单粗暴,如果应用场景合适也是一种不错的选择。
前面的限流都不能很好地平滑我们请求速率,瞬时并发过高一样会导致我们的应用服务宕机。为了因对突发流量和对出入口的流量整形,我们需要使用到前面讲到的令牌桶和楼桶算法。Guava框架已经帮我们将轮子造好,可以直接拿来使用。
Guava的RateLimiter提供了临牌桶算法可用于平滑突发流量(SmoothBursty)和平滑预热限流模式(SmoothSmoothWarningUp)。
举例来说明如何使用RateLimiter,想象下我们需要处理一个任务列表,但我们不希望每秒的任务提交超过10个:
//速率是每秒10个许可
final RateLimiter rateLimiter = RateLimiter.create(10);
void submitTasks(List tasks, Executor executor) {
for (Runnable task : tasks) {
rateLimiter.acquire(); // 也许需要等待
executor.execute(task);
}
}
acquire() 会阻塞当前线程直到许可证可用后获取该许可证。一旦获取到许可证,不需要再释放许可证。RateLimiter.create(10)表示每100ms向桶中存放一个临牌。acquire(10) 表示可以一次性获取临牌桶所有临牌,那么在下次请求获取令牌时将会被阻塞直到获取到临牌。
SmoothSmoothWarningUp
RateLimiter.create(double permitsPerSecond, long warmupPeriod, TimeUnit unit);
permitsPerSecond:每秒新增临牌数;
warmupPeriod:冷启动过渡到平均速率时间;
预热模式和tcp的慢启动有点类似,控制前期的速率,然后慢慢达到系统能承受的正常速率。