共计 9137 个字符,预计需要花费 23 分钟才能阅读完成。
Nginx 限速模块分为哪几种?按请求速率限速的 burst 和 nodelay 参数是什么意思?漏桶算法和令牌桶算法究竟有什么不同?本文将带你一探究竟。我们会通过一些简单的示例展示 Nginx 限速模块是如何工作的,然后结合代码讲解其背后的算法和原理。
核心算法
在探究 Nginx 限速模块之前,我们先来看看网络传输中常用两个的流量控制算法:漏桶算法 和令牌桶算法。这两只“桶”到底有什么异同呢?
漏桶算法(leaky bucket)
漏桶算法 (leaky bucket) 算法思想如图所示:
一个形象的解释是:
- 水(请求)从上方倒入水桶,从水桶下方流出(被处理);
- 来不及流出的水存在水桶中(缓冲),以 固定速率 流出;
- 水桶满后水溢出(丢弃)。
这个算法的核心是:缓存请求、匀速处理、多余的请求直接丢弃。
令牌桶算法(token bucket)
令牌桶 (token bucket) 算法思想如图所示:
算法思想是:
- 令牌以固定速率产生,并缓存到令牌桶中;
- 令牌桶放满时,多余的令牌被丢弃;
- 请求要消耗等比例的令牌才能被处理;
- 令牌不够时,请求被缓存。
相比漏桶算法,令牌桶算法不同之处在于它不但有一只“桶”,还有个队列,这个桶是用来存放令牌的,队列才是用来存放请求的。
从作用上来说,漏桶和令牌桶算法最明显的区别就是是否允许 突发流量 (burst) 的处理,漏桶算法能够 强行限制数据的实时传输(处理)速率 ,对突发流量不做额外处理;而令牌桶算法能够在 限制数据的平均传输速率的同时允许某种程度的突发传输。
Nginx 按请求速率限速模块使用的是漏桶算法,即能够强行保证请求的实时处理速度不会超过设置的阈值。
Nginx 限速模块
Nginx 主要有两种限速方式:按连接数限速(ngx_http_limit_conn_module
)、按请求速率限速(ngx_http_limit_req_module
)。我们着重讲解按请求速率限速。
按连接数限速
按连接数限速是指限制单个 IP(或者其他的 key)同时发起的连接数,超出这个限制后,Nginx 将直接拒绝更多的连接。这个模块的配置比较好理解,详见 ngx_http_limit_conn_module 官方文档。
按请求速率限速
按请求速率限速是指限制单个 IP(或者其他的 key)发送请求的速率,超出指定速率后,Nginx 将直接拒绝更多的请求。采用 leaky bucket 算法实现。为深入了解这个模块,我们先从实验现象说起。开始之前我们先简单介绍一下该模块的配置方式,以下面的配置为例:
http {limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
...
server {
...
location /search/ {limit_req zone=mylimit burst=4 nodelay;
}
使用 limit_req_zone
关键字,我们定义了一个名为 mylimit 大小为 10MB 的共享内存区域 (zone
),用来存放限速相关的统计信息,限速的key
值为二进制的 IP 地址($binary_remote_addr
),限速上限 (rate
) 为 2r/s;接着我们使用 limit_req
关键字将上述规则作用到 /search/
上。burst
和 nodelay
的作用稍后解释。
使用上述规则,对于 /search/ 目录的访问,单个 IP 的访问速度被限制在了 2 请求 / 秒,超过这个限制的访问将直接被 Nginx 拒绝。
实验 1——毫秒级统计
我们有如下配置:
...
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
location / {limit_req zone=mylimit;
}
}
...
上述规则限制了每个 IP 访问的速度为 2r/s,并将该规则作用于跟目录。如果单个 IP 在非常短的时间内并发发送多个请求,结果会怎样呢?
# 单个 IP 10ms 内并发发送 6 个请求
send 6 requests in parallel, time cost: 2 ms
HTTP/1.1 503 Service Temporarily Unavailable
HTTP/1.1 200 OK
HTTP/1.1 503 Service Temporarily Unavailable
HTTP/1.1 503 Service Temporarily Unavailable
HTTP/1.1 503 Service Temporarily Unavailable
HTTP/1.1 503 Service Temporarily Unavailable
end, total time cost: 461 ms
我们使用单个 IP 在 10ms 内发并发送了 6 个请求,只有 1 个成功,剩下的 5 个都被拒绝。我们设置的速度是 2r/s,为什么只有 1 个成功呢,是不是 Nginx 限制错了?当然不是,是因为 Nginx 的限流统计是基于毫秒的,我们设置的速度是 2r/s,转换一下就是 500ms 内单个 IP 只允许通过 1 个请求,从 501ms 开始才允许通过第二个请求。
实验 2——burst 允许缓存处理突发请求
实验 1 我们看到,我们短时间内发送了大量请求,Nginx 按照毫秒级精度统计,超出限制的请求直接拒绝。这在实际场景中未免过于苛刻,真实网络环境中请求到来不是匀速的,很可能有请求“突发”的情况,也就是“一股子一股子”的。Nginx 考虑到了这种情况,可以通过 burst
关键字开启对突发请求的缓存处理,而不是直接拒绝。
来看我们的配置:
...
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {location / {limit_req zone=mylimit burst=4;
}
}
...
我们加入了 burst=4
,意思是每个 key(此处是每个 IP) 最多允许 4 个突发请求的到来。如果单个 IP 在 10ms 内发送 6 个请求,结果会怎样呢?
# 单个 IP 10ms 内发送 6 个请求,设置 burst
send 6 requests in parallel, time cost: 2 ms
HTTP/1.1 200 OK
HTTP/1.1 503 Service Temporarily Unavailable
HTTP/1.1 200 OK
HTTP/1.1 200 OK
HTTP/1.1 200 OK
HTTP/1.1 200 OK
end, total time cost: 2437 ms
相比实验 1 成功数增加了 4 个,这个我们设置的 burst 数目是一致的。具体处理流程是:1 个请求被立即处理,4 个请求被放到 burst 队列里,另外一个请求被拒绝。通过 burst
参数,我们使得 Nginx 限流具备了缓存处理突发流量的能力。
但是请注意:burst 的作用是让多余的请求可以先放到队列里,慢慢处理。如果不加 nodelay 参数,队列里的请求 不会立即处理,而是按照 rate 设置的速度,以毫秒级精确的速度慢慢处理。
实验 3——nodelay 降低排队时间
实验 2 中我们看到,通过设置 burst 参数,我们可以允许 Nginx 缓存处理一定程度的突发,多余的请求可以先放到队列里,慢慢处理,这起到了平滑流量的作用。但是如果队列设置的比较大,请求排队的时间就会比较长,用户角度看来就是 RT 变长了,这对用户很不友好。有什么解决办法呢?nodelay
参数允许请求在排队的时候就立即被处理,也就是说只要请求能够进入 burst 队列,就会立即被后台 worker 处理 ,请注意,这意味着 burst 设置了 nodelay 时,系统瞬间的 QPS 可能会超过 rate 设置的阈值。nodelay
参数要跟 burst
一起使用才有作用。
延续实验 2 的配置,我们加入 nodelay 选项:
...
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {location / {limit_req zone=mylimit burst=4 nodelay;
}
}
...
单个 IP 10ms 内并发发送 6 个请求,结果如下:
# 单个 IP 10ms 内发送 6 个请求
实验3, 设置 burst 和 nodelay | 实验2, 只设置 burst
send 6 requests, time cost: 4 ms | time cost: 2 ms
HTTP/1.1 200 OK | HTTP/1.1 200 OK
HTTP/1.1 200 OK | HTTP/1.1 503 ...
HTTP/1.1 200 OK | HTTP/1.1 200 OK
HTTP/1.1 200 OK | HTTP/1.1 200 OK
HTTP/1.1 503 ... | HTTP/1.1 200 OK
HTTP/1.1 200 OK | HTTP/1.1 200 OK
total time cost: 465 ms | total time cost: 2437 ms
跟实验 2 相比,请求成功率没变化,但是 总体耗时变短了 。这怎么解释呢?实验 2 中,有 4 个请求被放到 burst 队列当中,工作进程每隔 500ms(rate=2r/s) 取一个请求进行处理,最后一个请求要排队 2s 才会被处理;实验 3 中,请求放入队列跟实验 2 是一样的,但不同的是,队列中的请求同时具有了被处理的资格,所以实验 3 中的 5 个请求可以说是同时开始被处理的,花费时间自然变短了。
但是请注意,虽然设置 burst 和 nodelay 能够降低突发请求的处理时间,但是长期来看并不会提高吞吐量的上限,长期吞吐量的上限是由 rate 决定的,因为 nodelay 只能保证 burst 的请求被立即处理,但 Nginx 会限制队列元素释放的速度,就像是限制了令牌桶中令牌产生的速度。
看到这里你可能会问,加入了 nodelay 参数之后的限速算法,到底算是哪一个“桶”,是漏桶算法还是令牌桶算法?当然还算是漏桶算法。考虑一种情况,令牌桶算法的 token 为耗尽时会怎么做呢?由于它有一个请求队列,所以会把接下来的请求缓存下来,缓存多少受限于队列大小。但此时缓存这些请求还有意义吗?如果 server 已经过载,缓存队列越来越长,RT 越来越高,即使过了很久请求被处理了,对用户来说也没什么价值了。所以当 token 不够用时,最明智的做法就是直接拒绝用户的请求,这就成了漏桶算法,哈哈~
源码剖析
经过上面的示例,我们队请求限速模块有了一定的认识,现在我们深入剖析代码实现。按请求速率限流模块 ngx_http_limit_req_module
代码位于 src/http/modules/ngx_http_limit_req_module.c,900 多好代码可谓短小精悍。相关代码有两个核心数据结构:
- 红黑树:通过红黑树记录每个节点(按照声明时指定的 key)的统计信息,方便查找;
- LRU 队列:将红黑树上的节点按照最近访问时间排序,时间近的放在队列头部,以便使用 LRU 队列淘汰旧的节点,避免内存溢出。
这两个关键对象存储在 ngx_http_limit_req_shctx_t
中:
typedef struct {ngx_rbtree_t rbtree; /* red-black tree */
ngx_rbtree_node_t sentinel; /* the sentinel node of red-black tree */
ngx_queue_t queue; /* used to expire info(LRU algorithm) */
} ngx_http_limit_req_shctx_t;
其中除了 rbtree 和 queue 之外,还有一个叫做 sentinel 的变量,这个变量用作红黑树的 NIL 节点。
该模块的核心逻辑在函数 ngx_http_limit_req_lookup()
中,这个函数主要流程是怎样呢?对于每一个请求:
- 从根节点开始查找红黑树,找到 key 对应的节点;
- 找到后修改该点在 LRU 队列中的位置,表示该点最近被访问过;
- 执行漏桶算法;
- 没找到时根据 LRU 淘汰,腾出空间;
- 生成并插入新的红黑树节点;
- 执行下一条限流规则。
流程很清晰,但是代码中牵涉到红黑树、LRU 队列等高级数据结构,是不是会写得很复杂?好在 Nginx 作者功力深厚,代码写得简洁易懂,哈哈~
// 漏桶算法核心流程
ngx_http_limit_req_lookup(...){while (node != sentinel) {// search rbtree
if (hash < node->key) {node = node->left; continue;} // 1. 从根节点开始查找红黑树
if (hash > node->key) {node = node->right; continue;}
rc = ngx_memn2cmp(key->data, lr->data, key->len, (size_t) lr->len);
if (rc == 0) {// found
ngx_queue_remove(&lr->queue); // 2. 修改该点在 LRU 队列中的位置,表示该点最近被访问过
ngx_queue_insert_head(&ctx->sh->queue, &lr->queue);// 2
ms = (ngx_msec_int_t) (now - lr->last);
excess = lr->excess - ctx->rate * ngx_abs(ms) / 1000 + 1000; // 3. 执行漏桶算法
if (excess < 0)
excess = 0;
if ((ngx_uint_t) excess > limit->burst)
return NGX_BUSY; // 超过了突发门限,拒绝
if (account) {// 是否是最后一条规则
lr->excess = excess;
lr->last = now;
return NGX_OK; // 未超过限制,通过
}
...
return NGX_AGAIN; // 6. 执行下一条限流规则
}
node = (rc < 0) ? node->left : node->right; // 1
} // while
...
// not found
ngx_http_limit_req_expire(ctx, 1); // 4. 根据 LRU 淘汰,腾出空间
node = ngx_slab_alloc_locked(ctx->shpool, size); // 5. 生成新的红黑树节点
ngx_rbtree_insert(&ctx->sh->rbtree, node);// 5. 插入该节点,重新平衡红黑树
ngx_queue_insert_head(&ctx->sh->queue, &lr->queue);
if (account) {
lr->last = now;
lr->count = 0;
return NGX_OK;
}
...
return NGX_AGAIN; // 6. 执行下一条限流规则
}
代码有三种返回值,它们的意思是:
- NGX_BUSY 超过了突发门限,拒绝
- NGX_OK 未超过限制,通过
- NGX_AGAIN 未超过限制,但是还有规则未执行,需执行下一条限流规则
上述代码不难理解,但我们还有几个问题:
- LRU 是如何实现的?
- 漏桶算法是如何实现的?
- 每个 key 相关的 burst 队列在哪里?
LRU 是如何实现的
LRU 算法的实现很简单,如果一个节点被访问了,那么就把它移到队列的头部,当空间不足需要淘汰节点时,就选出队列尾部的节点淘汰掉,主要体现在如下代码中:
ngx_queue_remove(&lr->queue); // 2. 修改该点在 LRU 队列中的位置,表示该点最近被访问过
ngx_queue_insert_head(&ctx->sh->queue, &lr->queue);// 2
...
ngx_http_limit_req_expire(ctx, 1); // 4. 根据 LRU 淘汰,腾出空间
漏桶算法是如何实现的
漏桶算法的实现也比我们想象的简单,其核心是这一行公式 excess = lr->excess - ctx->rate * ngx_abs(ms) / 1000 + 1000
,这样代码的意思是:excess 表示当前 key 上遗留的请求数, 本次遗留的请求数 = 上次遗留的请求数 – 预设速率 X 过去的时间 + 1。这个 1 表示当前这个请求,由于 Nginx 内部表示将单位缩小了 1000 倍,所以 1 个请求要转换成 1000。
excess = lr->excess - ctx->rate * ngx_abs(ms) / 1000 + 1000; // 3. 执行漏桶算法
if (excess < 0)
excess = 0;
if ((ngx_uint_t) excess > limit->burst)
return NGX_BUSY; // 超过了突发门限,拒绝
if (account) {// 是否是最后一条规则
lr->excess = excess;
lr->last = now;
return NGX_OK; // 未超过限制,通过
}
...
return NGX_AGAIN; // 6. 执行下一条限流规则
上述代码受限算出当前 key 上遗留的请求数,如果超过了 burst,就直接拒绝;由于 Nginx 允许多条限速规则同时起作用,如果已是最后一条规则,则允许通过,否则执行下一条规则。
单个 key 相关的 burst 队列在哪里
没有单个 key 相关的 burst 队列。上面代码中我们看到当到达最后一条规则时,只要 excess<limit->burst
限速模块就会返回 NGX_OK,并没有把多余请求放入队列的操作,这是因为 Nginx 是基于 timer 来管理请求的,当限速模块返回 NGX_OK 时,调度函数会计算一个延迟处理的时间,同时把这个请求放入到共享的 timer 队列中(一棵按等待时间从小到大排序的红黑树)。
ngx_http_limit_req_handler(ngx_http_request_t *r)
{
...
for (n = 0; n < lrcf->limits.nelts; n++) {
...
ngx_shmtx_lock(&ctx->shpool->mutex);// 获取锁
rc = ngx_http_limit_req_lookup(limit, hash, &key, &excess, // 执行漏桶算法
(n == lrcf->limits.nelts - 1));
ngx_shmtx_unlock(&ctx->shpool->mutex);// 释放锁
...
if (rc != NGX_AGAIN)
break;
}
...
delay = ngx_http_limit_req_account(limits, n, &excess, &limit);// 计算当前请求需要的延迟时间
if (!delay) {return NGX_DECLINED;// 不需要延迟,交给后续的 handler 进行处理
}
...
ngx_add_timer(r->connection->write, delay);// 否则将请求放到定时器队列里
return NGX_AGAIN; // the request has been successfully processed, the request must be suspended until some event. http://www.nginxguts.com/2011/01/phases/
}
我们看到 ngx_http_limit_req_handler()
调用了函数 ngx_http_limit_req_lookup()
,并根据其返回值决定如何操作:或是拒绝,或是交给下一个 handler 处理,或是将请求放入定期器队列。当限速规则都通过后,该 hanlder 通过调用函数ngx_http_limit_req_account()
得出当前请求需要的延迟时间,如果不需要延迟,就将请求交给后续的 handler 进行处理,否则将请求放到定时器队列里。注意这个定时器队列是共享的,并没有为单独的 key(比如,每个 IP 地址)设置队列。关于 handler 模块背景知识的介绍,可参考 Tengine 团队撰写的 Nginx 开发从入门到精通
结尾
本文主要讲解了 Nginx 按请求速率限速模块的用法和原理,其中 burst 和 nodelay 参数是容易引起误解的,虽然可通过 burst 允许缓存处理突发请求,结合 nodelay 能够降低突发请求的处理时间,但是长期来看他们并不会提高吞吐量的上限,长期吞吐量的上限是由 rate 决定的。需要特别注意的是,burst 设置了 nodelay 时,系统瞬间的 QPS 可能会超过 rate 设置的阈值。
本文只是对 Nginx 管中窥豹,更多关于 Nginx 介绍的文章,可参考 Tengine 团队撰写的 Nginx 开发从入门到精通。
下面关于 Nginx 的文章您也可能喜欢,不妨参考下:
Nginx 403 forbidden 的解决办法 http://www.linuxidc.com/Linux/2017-08/146084.htm
CentOS 7 下 Nginx 服务器的安装配置 http://www.linuxidc.com/Linux/2017-04/142986.htm
CentOS 上安装 Nginx 服务器实现虚拟主机和域名重定向 http://www.linuxidc.com/Linux/2017-04/142642.htm
CentOS 6.8 安装 LNMP 环境(Linux+Nginx+MySQL+PHP)http://www.linuxidc.com/Linux/2017-04/142880.htm
Linux 下安装 PHP 环境并配置 Nginx 支持 php-fpm 模块 http://www.linuxidc.com/Linux/2017-05/144333.htm
Nginx 服务的 SSL 认证和 htpasswd 认证 http://www.linuxidc.com/Linux/2017-04/142478.htm
Ubuntu 16.04 上启用加密安全的 Nginx Web 服务器 http://www.linuxidc.com/Linux/2017-07/145522.htm
Linux 中安装配置 Nginx 及参数详解 http://www.linuxidc.com/Linux/2017-05/143853.htm
Nginx 日志过滤 使用 ngx_log_if 不记录特定日志 http://www.linuxidc.com/Linux/2014-07/104686.htm
CentOS 7.2 下 Nginx+PHP+MySQL+Memcache 缓存服务器安装配置 http://www.linuxidc.com/Linux/2017-03/142168.htm
CentOS6.9 编译安装 Nginx1.4.7 http://www.linuxidc.com/Linux/2017-06/144473.htm
Nginx 的详细介绍:请点这里
Nginx 的下载地址:请点这里
本文永久更新链接地址:http://www.linuxidc.com/Linux/2017-12/149688.htm