前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Nginx的字节级限速原理

Nginx的字节级限速原理

作者头像
陶辉
发布2023-10-18 11:16:43
4760
发布2023-10-18 11:16:43
举报
文章被收录于专栏:陶辉笔记

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

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

代码语言:javascript
复制
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协议栈
socket与TCP协议栈

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

代码语言:javascript
复制
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字节限速流程图
nginx字节限速流程图

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

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

Nginx的限速计算公式

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

代码语言:javascript
复制
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指针指向已接收到的字节总数:

代码语言:javascript
复制
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文件:

代码语言:javascript
复制
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函数:

代码语言:javascript
复制
static void
ngx_stream_proxy_handler(ngx_stream_session_t *s)
{
    u->start_sec = ngx_time();
}

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

代码语言:javascript
复制
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,在谈其调用频率之前,先来看看它在多线程上的锁优化设计,这也有微小的时间精度降低:

代码语言:javascript
复制
#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的值移动循环数组:

代码语言:javascript
复制
#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函数(这是更新时间的固定代码段):

代码语言:javascript
复制
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函数:

代码语言:javascript
复制
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,这又是什么鬼?从官方文档中可以看到:

代码语言:javascript
复制
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源码设计的变动,就能更准确的掌握限速的要理。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2023-09-132,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 基于字节的限速实现原理
  • Nginx的限速计算公式
  • Nginx的时间更新方式
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档