0%

Nginx的字节级限速原理

有同学反馈:在配置Nginx四层限速时,proxy_upload_rate和proxy_download_rate有一定的概率不生效。我按照他的步骤也能复现,但这与官方Nginx很稳定(相对其他开源软件)的印象并不相符,是不是Nginx的官方BUG呢?这里的真实原因,其实是Nginx字节限速机制与时间更新频率的协商导致的,这篇文章我们就来研究下Nginx的字节限速。

首先看下测试场景:基于UDP协议搭建四层代理(UDP协议更简单,更容易复现BUG),在nginx.conf中配置每秒最大上传10个字节:

1
proxy_upload_rate 10;

客户端先发送10字节,服务器接收到后(用回包触发)客户端立刻再次发送10字节,预期服务器将在1秒后收到第2个报文,但实际上服务器有可能立刻收到报文,即proxy_upload_rate未生效或者不可控!一旦配置项处于不可解释的状态,这对于严谨的应用场景是不可接受的。而这个现象的原因,本质上是目前Nginx实现机制所致,接下来我会基于1.21版本的源码上解释其原理。

基于字节的限速实现原理

首先,我们要明确上例属于Nginx中的哪种限速。由于Nginx使用了内核协议栈,因此Nginx既不能对Packet级别的报文、也不能对TCP连接建立进行限速,而是只能在用户态基于调用socket编程API的时机,在字节转发速率、应用层协议的HTTP请求上(如官方的limit_req)做限制。
socket与TCP协议栈

对于TCP协议的限速,当限速低于当前TCP连接的传输速率时,是通过零通告窗口来降低传输速率的。其具体作用原理为:作为接收端的Nginx所在服务器上,会有一个接收缓冲区,比如Linux中的tcp_rmem:

1
net.ipv4.tcp_rmem = 4096	131072	6291456

tcp_rmem由TCP滑动窗口和socket读缓冲区共用,当Nginx在Epoll返回“可读”IO事件时,却不去读取socket数据,那么,当tcp_rmem被接收到的数据占满后,接收滑动窗口就会变为0,此时TCP连接的对端就会收到零窗口通知,进而停止发送数据,如下图所示:
零窗口探测

UDP协议与之类似,只不过因为没有重传机制,新收到的UDP报文会被直接丢弃。

对于字节转发速率的限制,Nginx正是通过上述机制生效的。无论是四层的proxy_upload_rate和proxy_download_rate,或者是七层的limit_rate,Nginx都是基于每秒转发字节数进行限速的,区别只在于,四层的2个指令都是在socket接收时生效,而七层则在socket发送到下游客户端(单向)时生效(这里并不基于TCP滑动窗口生效,因为Nginx只需保证降低发送HTTP响应的速率即可达到设计目标)。

下图是我以STREAM四层为例,画出的限速流程示意图:
nginx字节限速流程图

可以看到,在执行socket.read函数前会先计算一次限速公式,如果已经达到限速阈值,则根据计算出的等待时间添加定时器退出,此后就有可能出现TCP零窗口或者UDP报文丢弃;反之,才会将socket缓冲区中的数据拷贝到用户态转发。当然,在接收完数据后,还会做一次限速计算,此操作不影响本次数据的转发,只影响当前事件下是否会连续多次读取socket缓冲区。

开篇提到的限速失效问题关键就在于图中的限速公式。

Nginx的限速计算公式

先来看Nginx计算限速的关键代码,它在ngx_stream_proxy_module.c文件的ngx_stream_proxy_process函数中:

1
2
3
4
5
6
7
8
9
10
11
12
if (limit_rate) { 
//limit_rate就是proxy_upload_rate(上行)或者proxy_download_rate(下行)的值
limit = (off_t) limit_rate * (ngx_time() - u->start_sec + 1)
- *received;
//limit<=0就达到了限速条件
if (limit <= 0) {
delay = (ngx_msec_t) (- limit * 1000 / limit_rate + 1);
//delay定时器到达后,才会继续转发数据
ngx_add_timer(src->read, delay);
break;
}
}

这个公式同时适用于上、下游的数据转发,当从下游客户端转发数据时,limit_rate值为nginx.conf配置文件的proxy_upload_rate指令,而从上游服务器转发数据时,limit_rate值则为配置文件的proxy_download_rate指令,而received指针指向已接收到的字节总数:

1
2
3
4
5
6
7
if (from_upstream) {
limit_rate = u->download_rate;
received = &u->received;
} else {
limit_rate = u->upload_rate;
received = &s->received;
}

这个公式的设计思想是:如果ngx_time()当前时间与u->start_sec请求处理的起始时间的时间差内,转发的字节数received超出了limit_rate限制,就要立刻停止转发数据,其中暂停的时间是delay毫秒。虽然这个公式由STREAM四层使用,但HTTP七层也差不多,参见ngx_http_write_filter_module.c文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (r->limit_rate) {
limit = (off_t) r->limit_rate * (ngx_time() - r->start_sec + 1)
- (c->sent - r->limit_rate_after);

if (limit <= 0) {
c->write->delayed = 1;
delay = (ngx_msec_t) (- limit * 1000 / r->limit_rate + 1);
ngx_add_timer(c->write, delay);

c->buffered |= NGX_HTTP_WRITE_BUFFERED;

return NGX_AGAIN;
}
}

为了方便理解,我们继续以四层代码为例说明问题。当应该限速时,公式返回的limit变量就会大于0,而本文开头提到的测试场景,第2秒发送10字节时,*received的值肯定是10(第1秒转发),而ngx_time() - u->start_sec预期为1,所以limit的值预期为10*(1+1)-10,也就是10,进而delay值应为1秒才对。但实测结果却是有很大概率limit为0,导致Nginx没有限速。这是什么原因呢?

Nginx的时间更新方式

其实公式中的“变量”只可能是时间,毕竟limit_rate是配置文件中的指令,*received是已转发字节,这两者都不可能出错。所以,问题肯定出在u->start_sec或者ngx_time()的精准度上!前者u->start_sec的赋值很简单,参见ngx_stream_proxy_handler函数:

1
2
3
4
5
static void
ngx_stream_proxy_handler(ngx_stream_session_t *s)
{
u->start_sec = ngx_time();
}

当Nginx接收到下游客户端的数据,准备向上游服务器建立会话连接时,u->start_sec就被初始化为当前时间。再来看ngx_time函数是如何返回系统时间的:

1
2
3
4
extern volatile ngx_time_t  *ngx_cached_time;

#define ngx_time() ngx_cached_time->sec
#define ngx_timeofday() (ngx_time_t *) ngx_cached_time

Nginx在新版本实现中为了优化性能,使用了缓存时间ngx_cached_time(即使调用ngx_timeofday函数返回的也是缓存时间),这带来了2个问题:

  1. ngx_time函数只取了秒,直接舍弃了毫秒精度(连四舍五入也没有考虑);
  2. ngx_cached_time的更新频率必然影响限速的时间精度。

本文开头问题与上述二者都有关系。忽略毫秒必然带来最大1秒的时间误差,而ngx_cached_time的更新频率会在此基础上放大ngx_time() - u->start_sec的误差,再来看下ngx_cached_time是如何更新的。

ngx_time_update函数负责更新ngx_cached_time,在谈其调用频率之前,先来看看它在多线程上的锁优化设计,这也有微小的时间精度降低:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#define ngx_gettimeofday(tp)  (void) gettimeofday(tp, NULL);
void
ngx_time_update(void)
{
ngx_tm_t tm, gmt;
time_t sec;
ngx_uint_t msec;
ngx_time_t *tp;
struct timeval tv;

//加锁
if (!ngx_trylock(&ngx_time_lock)) {
return;
}
//真实执行操作系统的gettimeofday系统调用
ngx_gettimeofday(&tv);

sec = tv.tv_sec;
msec = tv.tv_usec / 1000;

tp = &cached_time[slot];

if (tp->sec == sec) {
tp->msec = msec;
ngx_unlock(&ngx_time_lock);
return;
}
//循环复用cached_time数组
if (slot == NGX_TIME_SLOTS - 1) {
slot = 0;
} else {
slot++;
}

tp = &cached_time[slot];

tp->sec = sec;
tp->msec = msec;

//volatile类型的原子操作
ngx_cached_time = tp;

//解锁
ngx_unlock(&ngx_time_lock);
}

Nginx支持多线程(虽然用得不多),因此为了减少加锁操作,Nginx使用了含有64个元素的数组cached_time循环复用保存时间,这样读时间时就省去了加锁操作,只在更新时才会加锁并通过变更slot的值移动循环数组:

1
2
3
4
5
6
7
#define NGX_TIME_SLOTS   64

static ngx_time_t cached_time[NGX_TIME_SLOTS];
static ngx_uint_t slot;
static ngx_atomic_t ngx_time_lock;

volatile ngx_time_t *ngx_cached_time;

ngx_time_update调用完成后,ngx_cached_time就会保存最新的时间。
再来看ngx_time_update函数的调用时机,这里主要参见Linux epoll多路复用机制中的ngx_epoll_process_events函数(这是更新时间的固定代码段):

1
2
3
4
5
6
7
8
9
static ngx_int_t
ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags)
{
events = epoll_wait(ep, event_list, (int) nevents, timer);

if (flags & NGX_UPDATE_TIME || ngx_event_timer_alarm) {
ngx_time_update();
}
}

可见,每处理一批IO事件时,只要flags参数中携带了NGX_UPDATE_TIME标志,就会更新时间。那么,究竟何时会携带NGX_UPDATE_TIME标志位呢?这里要参见worker进程中循环调用的ngx_process_events_and_timers函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
void
ngx_process_events_and_timers(ngx_cycle_t *cycle)
{
ngx_uint_t flags;

if (ngx_timer_resolution) {
flags = 0;
} else {
flags = NGX_UPDATE_TIME;
}

(void) ngx_process_events(cycle, timer, flags);
}

这里又多出了一个ngx_timer_resolution,这又是什么鬼?从官方文档中可以看到:

1
2
3
4
Syntax:	timer_resolution interval;
Default: —
Context: main
Reduces timer resolution in worker processes, thus reducing the number of gettimeofday() system calls made. By default, gettimeofday() is called each time a kernel event is received.

timer_resolution是用于降低时间更新频率的。当然,默认情况下我们并不会配置timer_resolution,此时每批Epoll IO事件都会更新一次时间。

到这里,终于可以彻底回答本文开头的问题了。虽然Nginx的限速公式没有问题,但是Nginx时间精度却有2个问题,导致公式中的时间差ngx_time() - u->start_sec存在秒级的计算误差
1、在系统不繁忙时,舍弃毫秒会导致最大1秒的误差;
2、时间更新频率则受到timer_resolution指令、epoll事件的批次数量、锁优化设计下的时间数组更新误差、worker进程的延迟调度等因素综合影响。

所以,我们在验证或者设计测试场景时,需要将上述2个因素都纳入考虑。同时,在Nginx更新版本时,综合评估Nginx源码设计的变动,就能更准确的掌握限速的要理。