在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流
缓存:缓存的目的是提升系统访问速度和增大系统处理容量
降级:降级是当服务出现问题或者影响到核心流程时,需要暂时屏蔽掉,待高峰或者问题解决后再打开
限流:限流的目的是通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理
常见的应用:短信/邮件的提供商需要限制每秒可以发多少封短信/邮件
当前最主要的限流方式有两种:漏桶算法和令牌桶算法
一、漏桶算法
漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。
nginx 有两个限流模块,从 github 上 clone 代码,位置在 nginx/src/http/modules 目录下:
ngx_http_limit_req_module.c (nginx 的 limit_req 模块,用来 限制时间窗口内的平均速率)
ngx_http_limit_conn_module.c (nginx 的 limit_conn 模块,用来限制并发连接数)
两者都是按照 IP 或者域名限制的
nginx Github地址:https://github.com/nginx/nginx/tree/master/src/http/modules
nginx 文档:http://nginx.org/en/docs/http/ngx_http_limit_conn_module.html
limit_conn_zone $binary_remote_addr zone=addr:10m;
server {
location /download/ {
limit_conn addr 1;
}
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
server {
location /search/ {
limit_req zone=one burst=5 nodelay; #delay 并发数 burst桶的大小
}
//limit,限流策略;hash,记录key的hash值;data,记录key的数据内容;len,记录key的数据长度;ep,待处理请求数目;account,是否是最后一条限流策略
static ngx_int_t ngx_http_limit_req_lookup(ngx_http_limit_req_limit_t *limit, ngx_uint_t hash, u_char *data, size_t len, ngx_uint_t *ep, ngx_uint_t account)
{
//红黑树查找指定界定,sentinel代表红黑树的NULL节点
while (node != sentinel) {
if (hash < node->key) {
node = node->left;
continue;
}
if (hash > node->key) {
node = node->right;
continue;
}
//hash值相等,比较数据是否相等
lr = (ngx_http_limit_req_node_t *) &node->color;
rc = ngx_memn2cmp(data, lr->data, len, (size_t) lr->len);
//查找到
if (rc == 0) {
ngx_queue_remove(&lr->queue);
ngx_queue_insert_head(&ctx->sh->queue, &lr->queue); //将记录移动到LRU队列头部
ms = (ngx_msec_int_t) (now - lr->last); //当前时间减去上次访问时间
if (ms < -60000) {
ms = 1;
} else if (ms < 0) {
ms = 0;
}
//漏桶算法
excess = lr->excess - ctx->rate * ms / 1000 + 1000; //待处理请求书-限流速率*时间段+1个请求(速率,请求数等都乘以1000了)
//当前积压令牌数 = 上次积压令牌数 - 这段时间可以产生的令牌数 + 本次请求(1 个令牌)
if (excess < 0) {
excess = 0;
}
*ep = excess;
//待处理数目超过burst(等待队列大小),返回NGX_BUSY拒绝请求(没有配置burst时,值为0)
if ((ngx_uint_t) excess > limit->burst) {
return NGX_BUSY;
}
if (account) { //如果是最后一条限流策略,则更新上次访问时间,待处理请求数目,返回NGX_OK
lr->excess = excess;
lr->last = now;
return NGX_OK;
}
//访问次数递增
lr->count++;
ctx->node = lr;
return NGX_AGAIN; //非最后一条限流策略,返回NGX_AGAIN,继续校验下一条限流策略
}
node = (rc < 0) ? node->left : node->right;
}
//假如没有查找到节点,需要新建一条记录
*ep = 0;
size = offsetof(ngx_rbtree_node_t, color)
+ offsetof(ngx_http_limit_req_node_t, data)
+ len;
//尝试淘汰记录(LRU)
ngx_http_limit_req_expire(ctx, 1);
node = ngx_slab_alloc_locked(ctx->shpool, size);
if (node == NULL) { //空间不足,分配失败
ngx_http_limit_req_expire(ctx, 0); //强制淘汰记录
node = ngx_slab_alloc_locked(ctx->shpool, size);
if (node == NULL) { //分配失败,返回NGX_ERROR
return NGX_ERROR;
}
}
node->key = hash;
lr = (ngx_http_limit_req_node_t *) &node->color;
lr->len = (u_char) len;
lr->excess = 0;
ngx_memcpy(lr->data, data, len);
ngx_rbtree_insert(&ctx->sh->rbtree, node); //插入记录到红黑树与LRU队列
ngx_queue_insert_head(&ctx->sh->queue, &lr->queue);
if (account) { //如果是最后一条限流策略,则更新上次访问时间,待处理请求数目,返回NGX_OK
lr->last = now;
lr->count = 0;
return NGX_OK;
}
lr->last = 0;
lr->count = 1;
ctx->node = lr;
return NGX_AGAIN; //非最后一条限流策略,返回NGX_AGAIN,继续校验下一条限流策略
}
当一个新请求进入 Nginx 的限流流程大致如下:
计算当前请求 IP 地址 hash 值(hash 值相等后进而使用 IP 内容判断),在存放请求 IP 的红黑树中查找对应位置
计算当前请求和上次请求时间 (保存在红黑树节点的 value 中) 的差值 ms
根据公式 “excess = lr->excess - ctx->rate * ms / 1000 + 1000” 计算(漏桶算法的核心)
更新当前节点信息(上一次请求时间等),根据限流结果返回响应
二、令牌桶算法
令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
RateLimiter
令牌桶比较代表的实现方法是Guava下的RateLimiter
RateLimiter有两种实现方式,SmoothBursty(非预热)及SmoothWarmingUp(预热,冷启动),这里主要讨论的是非预热的方式,预热的方式暂时没看明白
import com.google.common.util.concurrent.RateLimiter;
import lombok.extern.slf4j.Slf4j;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Created by yangzaining on 2020-10-14.
*/
@Slf4j
public class TestDemo {
/**
* QPS:5/s 两个线程,各间隔50ms获取一次,每次获取一个令牌
*
* 疑问:
* 先前五个为什么不是都获取成功?
* 刚初始化,所以没有令牌,所以获取到一个则返回一个
*/
public static void test1() throws InterruptedException {
testRateLimiter(2, 11, 50, 5, 0, false);
}
/**
* QPS:5/s 两个线程,各间隔50ms获取一次,每次获取一个令牌,令牌桶初始化1s
*
* 疑问:
* 令牌存储的是5个,为啥前6次都成功了?
* 头五次用的是令牌桶中的令牌,第6次用的是下一个刻度的令牌,延迟计算
*/
public static void test2() throws InterruptedException {
testRateLimiter(2, 11, 50, 5, 1000, false);
}
/**
*
* QPS:5/s 两个线程,各间隔50ms获取一次,每次获取一个令牌,令牌桶初始化1s
* 清楚的看见后面的访问等待的时间逐步增加
*/
public static void test3() throws InterruptedException {
testRateLimiter(2, 11, 50, 5, 1000, true);
}
private static void testRateLimiter(int threadNumber, int count, int taskGapTime, int qps, int sleepTime, boolean useAcquire) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(threadNumber);
ZonedDateTime start = ZonedDateTime.now();
RateLimiter rateLimiter = RateLimiter.create(qps);
log.info("StartTime = {}", DateTimeFormatter.ISO_INSTANT.format(start));
if (sleepTime != 0) {
Thread.sleep(sleepTime);
}
for (int i = 0; i < threadNumber; i++) {
executorService.submit(() -> {
Boolean flag = null;
double waitTime = 0;
for (int j = 0; j < count; j++) {
if (useAcquire) {
waitTime = rateLimiter.acquire();
} else {
flag = rateLimiter.tryAcquire();
}
ZonedDateTime endTime = ZonedDateTime.now();
try {
Thread.sleep(taskGapTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (useAcquire) {
log.info("waitTime = {}, time = {}", waitTime, DateTimeFormatter.ISO_INSTANT.format(endTime));
} else {
log.info("canAcquire = {}, time = {}", flag, DateTimeFormatter.ISO_INSTANT.format(endTime));
}
}
});
}
}
public static void main(String[] args) throws InterruptedException {
// test1();
// test2();
test3();
}
}
@Override
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
resync(nowMicros);//重新计算当前存储的令牌数(距离上次计算的时间),nextFreeTicketMicros = nowMicros
long returnValue = nextFreeTicketMicros; //nowMicros
double storedPermitsToSpend = min(requiredPermits, this.storedPermits);//存储令牌数
double freshPermits = requiredPermits - storedPermitsToSpend;//还需要的令牌数
long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
+ (long) (freshPermits * stableIntervalMicros);//请求令牌需要等待的时间
this.nextFreeTicketMicros = nextFreeTicketMicros + waitMicros;//下次生成令牌的时间
this.storedPermits -= storedPermitsToSpend;//更新剩余令牌数
return returnValue; //nowMicros
}
如图所示,任务7若需要请求令牌,就需要偿还任务6获取令牌所需等待时间
Redis+lua
RateLimiter只满足了单机的限流方式,多台机器的限流需要用到redis lua脚本的方式去实现
1.减少网络开销:本来多次网络请求的操作,可以用一个请求完成,原先多次请求的逻辑放在lua脚本中,通过redis lua解释器去完成。使用脚本,减少了网络往返时延。
2.原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。
@Override
public RFuture trySetRateAsync(RateType type, long rate, long rateInterval, RateIntervalUnit unit) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"redis.call('hsetnx', KEYS[1], 'rate', ARGV[1]);"//速率
+ "redis.call('hsetnx', KEYS[1], 'interval', ARGV[2]);"//过期时间
+ "return redis.call('hsetnx', KEYS[1], 'type', ARGV[3]);",//类型
Collections.
总结
算法 | 特点 | 内容 |
---|---|---|
nginx req_limit_model(漏桶算法) | 把请求以平均速率消费,多出桶内数量的请求则被拒绝 | 根据上一次消费的时间计算本次消费所堆积的数量,excess = lr->excess - ctx->rate * ms / 1000 + 1000 |
Guava rateLimiter | 同漏桶算法,额外支持突发的流量请求(存储的令牌数),需要注意第一次消费数量不受限 | 根据访问的时间计算,当前是否需要等待令牌产生,无需等待,则后续的请求补偿本次的等待时间 |
Redis lua | 通过lua脚本的形式保证原子性,适用集群的限流 | 通过以速率作为过期时间存储令牌数,若剩余的数量不足时,则返回对应的等待时间,与rateLimiter不同的是,rateLimiter是边初始化边放令牌,而redis lua是一开始就存储最大令牌数 |
参考文献:https://segmentfault.com/a/1190000020272200?utm_source=tag-newest