You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

17 KiB

加餐4百万并发下Nginx的优化之道

你好我是专栏编辑冬青。今天的课程有点特别作为一期加餐我为你带来了陶辉老师在GOPS 2018 · 上海站的分享,以文字讲解+ PPT的形式向你呈现。今天的内容主要集中在Nginx的性能方面希望能给你带来一些系统化的思考帮助你更有效地去做Nginx。

优化方法论

今天的分享重点会看这样两个问题:

  • 第一,如何有效使用每个连接分配的内存,以此实现高并发。
  • 第二在高并发的同时怎样提高QPS。

当然实现这两个目标既可以从单机中的应用、框架、内核优化入手也可以使用类似F5这样的硬件设备或者通过DNS等方案实现分布式集群。

而Nginx最大的限制是网络所以将网卡升级到万兆比如10G或者40G吞吐量就会有很大提升。作为静态资源、缓存服务时磁盘也是重点关注对象比如固态硬盘的IOPS或者BPS要比不超过1万转每秒的机械磁盘高出许多。

这里我们重点看下CPU如果由操作系统切换进程实现并发代价太大毕竟每次都有5微秒左右的切换成本。Nginx将其改到进程内部由epoll切换ngx_connection_t连接的处理成本会非常低。OpenResty切换Lua协程也是基于同样的方式。这样CPU的计算力会更多地用在业务处理上。

从整体上看只有充分、高效地使用各类IT资源才能减少RTT时延、提升并发连接。

请求的“一生”

只有熟悉Nginx处理HTTP请求的流程优化时才能做到有的放矢。

首先我们要搞清楚Nginx的模块架构。Nginx是一个极其开放的生态它允许第三方编写的C模块与框架协作共同处理1个HTTP请求。比如所有的请求处理模块会构成一个链表以PipeAndFilter这种架构依次处理请求。再比如生成HTTP响应后所有过滤模块也会依次加工。

1. 请求到来

试想一下当用户请求到来时服务器到底会做哪些事呢首先操作系统内核会将完成三次握手的连接socket放入1个ACCEPT队列如果打开了reuseport内核会选择某个worker进程对应的队列某个Nginx Worker进程事件模块中的代码需要调用accept函数取出socket。

建立好连接并分配ngx_connection_t对象后Nginx会为它分配1个内存池它的默认大小是512字节可以由connection_pool_size指令修改只有这个连接关闭的时候才会去释放。

接下来Nginx会为这个连接添加一个默认60秒client_header_timeout指令可以配置的定时器其中需要将内核的socket读缓冲区里的TCP报文拷贝到用户态内存中。所以此时会将连接内存池扩展到1KBclient_header_buffer_size指令可以配置来拷贝消息内容如果在这段时间之内没有接收完请求则返回失败并关闭连接。

2. 处理请求

当接收完HTTP请求行和HEADER后就清楚了这是一个什么样的请求此时会再分配另一个默认为4KBrequest_pool_size指令可以修改这里请你思考为什么这个请求内存池比连接内存池的初始字节数多了8倍的内存池。

Nginx会通过协议状态机解析接收到的字符流如果1KB内存还没有接收到完整的HTTP头部就会再从请求内存池上分配出32KB继续接收字符流。其中这32KB默认是分成4次分配每次分配8KB可以通过large_client_header_buffers指令修改这样可以避免为少量的请求浪费过大的内存。

接下来各类HTTP处理模块登场。当然它们并不是简单构成1个链表而是通过11个阶段构成了一个二维链表。其中第1维长度是与Web业务场景相关的11个阶段第2维的长度与每个阶段中注册的HTTP模块有关。

这11个阶段不用刻意死记你只要掌握3个关键词就能够轻松地把他们分解开。首先是5个阶段的预处理包括post_read以及与rewrite重写URL相关的3个阶段以及URL与location相匹配的find_config阶段。

其次是访问控制包括限流限速的preaccess阶段、控制IP访问范围的access阶段和做完访问控制后的post_access阶段。

最后则是内容处理比如执行镜象分流的precontent阶段、生成响应的content阶段、记录处理结果的log阶段。

每个阶段中的HTTP模块会在configure脚本执行时就构成链表顺序地处理HTTP请求。其中HTTP框架允许某个模块跳过其后链接的本阶段模块直接进入下一个阶段的第1个模块。

content阶段会生成HTTP响应。当然其他阶段也有可能生成HTTP响应返回给客户端它们通常都是非200的错误响应。接下来会由HTTP过滤模块加工这些响应的内容并由write_filter过滤模块最终发送到网络中。

3. 请求的反向代理

Nginx由于性能高常用来做分布式集群的负载均衡服务。由于Nginx下游通常是公网网络带宽小、延迟大、抖动大而上游的企业内网则带宽大、延迟小、非常稳定因此Nginx需要区别对待这两端的网络以求尽可能地减轻上游应用的负载。

比如当你配置proxy_request_buffering on指令默认就是打开的Nginx会先试图将完整的HTTP BODY接收完当内存不够默认是16KB你可以通过client_body_buffer_size指令修改时还会保存到磁盘中。这样在公网上漫长的接收BODY流程中上游应用都不会有任何流量压力。

接收完请求后会向上游应用建立连接。当然Nginx也会通过定时器来保护自己比如建立连接的最长超时时间是60秒可以通过proxy_connect_timeout指令修改

当上游生成HTTP响应后考虑到不同的网络特点如果你打开了proxy_buffering on该功能也是默认打开的功能Nginx会优先将内网传来的上游响应接收完毕包括存储到磁盘上这样就可以关闭与上游之间的TCP连接减轻上游应用的并发压力。最后再通过缓慢的公网将响应发送给客户端。当然针对下游客户端与上游应用还可以通过proxy_limit_rate与limit_rate指令限制传输速度。如果设置proxy_buffering offNginx会从上游接收到一点响应就立刻往下游发一些。

4. 返回响应

当生成HTTP响应后会由注册为HTTP响应的模块依次加工响应。同样这些模块的顺序也是由configure脚本决定的。由于HTTP响应分为HEADER包括响应行和头部两部分、BODY所以每个过滤模块也可以决定是仅处理HEADER还是同时处理HEADER和BODY。

因此OpenResty中会提供有header_filter_by_lua和body_filter_by_lua这两个指令。

应用层优化

1. 协议

应用层协议的优化可以带来非常大的收益。比如HTTP/1 HEADER的编码方式低效REST架构又放大了这一点改为HTTP/2协议后就大有改善。Nginx对HTTP/2有良好的支持包括上游、下游以及基于HTTP/2的gRPC协议。

2. 压缩

对于无损压缩信息熵越大压缩效果就越好。对于文本文件的压缩来说Google的Brotli就比Gzip效果好你可以通过https://github.com/google/ngx_brotli 模块让Nginx支持Brotli压缩算法。

对于静态图片通常会采用有损压缩这里不同压缩算法的效果差距更大。目前Webp的压缩效果要比jpeg好不少。对于音频、视频则可以基于关键帧做动态增量压缩。当然只要是在Nginx中做实时压缩就会大幅降低性能。除了每次压缩对CPU的消耗外也不能使用sendfile零拷贝技术因为从磁盘中读出资源后copy_filter过滤模块必须将其拷贝到内存中做压缩这增加了上下文切换的次数。更好的做法是提前在磁盘中压缩好然后通过add_header等指令在响应头部中告诉客户端该如何解压。

3. 提高内存使用率

只在需要时分配恰当的内存可以提高内存效率。所以下图中Nginx提供的这些内存相关的指令需要我们根据业务场景谨慎配置。当然Nginx的内存池已经将内存碎片、小内存分配次数过多等问题解决了。必要时通过TcMalloc可以进一步提升Nginx申请系统内存的效率。

同样提升CPU缓存命中率也可以提升内存的读取速度。基于cpu cache line来设置哈希表的桶大小就可以提高多核CPU下的缓存命中率。

4. 限速

作为负载均衡Nginx可以通过各类模块提供丰富的限速功能。比如limit_conn可以限制并发连接而limit_req可以基于leacky bucket漏斗原理限速。对于向客户端发送HTTP响应可以通过limit_rate指令限速而对于HTTP上游应用可以使用proxy_limit_rate限制发送响应的速度对于TCP上游应用则可以分别使用proxy_upload_rate和proxy_download_rate指令限制上行、下行速度。

5. Worker间负载均衡

当Worker进程通过epoll_wait的读事件获取新连接时就由内核挑选1个Worker进程处理新连接。早期Linux内核的挑选算法很糟糕特别是1个新连接建立完成时内核会唤醒所有阻塞在epoll_wait函数上的Worker进程然而只有1个Worker进程可以通过accept函数获取到新连接其他进程获取失败后重新休眠这就是曾经广为人知的“惊群”现象。同时这也很容易造成Worker进程间负载不均衡由于每个Worker进程绑定1个CPU核心当部分Worker进程中的并发TCP连接过少时意味着CPU的计算力被闲置了所以这也降低了系统的吞吐量。

Nginx早期解决这一问题是通过应用层accept_mutex锁完成的在1.11.3版本前它是默认开启的accept_mutex on;

其中负载均衡功能是在连接数达到worker_connections的八分之七后进行次数限制实现的。

我们还可以通过accept_mutex_delay配置控制负载均衡的执行频率它的默认值是500毫秒也就是最多500毫秒后并发连接数较少的Worker进程会尝试处理新连接accept_mutex_delay 500ms;

当然在1.11.3版本后Nginx默认关闭了accept_mutex锁这是因为操作系统提供了reuseportLinux3.9版本后才提供这一功能)这个更好的解决方案。

图中横轴中的default项开启了accept_mutex锁。我们可以看到使用reuseport后QPS吞吐量有了3倍的提高同时处理时延有明显的下降特别是时延的波动蓝色的标准差线有大幅度的下降。

6. 超时

Nginx通过红黑树高效地管理着定时器这里既有面对TCP报文层面的配置指令比如面对下游的send_timeout指令也有面对UDP报文层面的配置指令比如proxy_responses还有面对业务层面的配置指令比如面对下游HTTP协议的client_header_timeout。

7. 缓存

只要想提升性能必须要在缓存上下工夫。Nginx对于七层负载均衡提供各种HTTP缓存比如http_proxy模块、uwsgi_proxy模块、fastcgi_proxy模块、scgi_proxy模块等等。由于Nginx中可以通过变量来命名日志文件因此Nginx很有可能会并行打开上百个文件此时通过open_file_cacheNginx可以将文件句柄、统计信息等写入缓存中提升性能。

8. 减少磁盘IO

Nginx虽然读写磁盘远没有数据库等服务要多但由于它对性能的极致追求仍然提供了许多优化策略。比如为方便统计和定位错误每条HTTP请求的执行结果都会写入access.log日志文件。为了减少access.log日志对写磁盘造成的压力Nginx提供了批量写入、实时压缩后写入等功能甚至你可以在另一个服务器上搭建rsyslog服务然后配置Nginx通过UDP协议将access.log日志文件从网络写入到 rsyslog中这完全移除了日志磁盘IO。

系统优化

最后,我们来看看针对操作系统内核的优化。

首先是为由内核实现的OSI网络层IP协议、传输层TCP与UDP协议修改影响并发性的配置。毕竟操作系统并不知道自己会作为高并发服务所以很多配置都需要进一步调整。

其次优化CPU缓存的亲和性对于Numa架构的服务器如果Nginx只使用一半以下的CPU核心那么就让Worker进程只绑定一颗CPU上的核心。

再次调整默认的TCP网络选项更快速地发现错误、重试、释放资源。

还可以减少TCP报文的往返次数。比如FastOpen技术可以减少三次握手中1个RTT的时延而增大初始拥塞窗口可以更快地达到带宽峰值。

还可以提高硬件资源的利用效率比如当你在listen指令后加入defer选项后就使用了TCP_DEFER_ACCEPT功能这样epoll_wait并不会返回仅完成三次握手的连接只有连接上接收到的TCP数据报文后它才会返回socket这样Worker进程就将原本2次切换就降为1次了虽然会牺牲一些即时性但提高了CPU的效率。

Linux为TCP内存提供了动态调整功能这样高负载下我们更强调并发性而低负载下则可以更强调高传输速度。

我们还可以将小报文合并后批量发送通过减少IP与TCP头部的占比提高网络效率。在nginx.conf文件中打开tcp_nopush、tcp_nodelay功能后都可以实现这些目的。

为了防止处理系统层网络栈的CPU过载还可以通过多队列网卡将负载分担到多个CPU中。

为了提高内存、带宽的利用率我们必须更精确地计算出BDP也就是通过带宽与ping时延算出的带宽时延积决定socket读写缓冲区影响滑动窗口大小

Nginx上多使用小于256KB的小内存而且我们通常会按照CPU核数开启Worker进程这样一种场景下TCMalloc的性能要远高于Linux默认的PTMalloc2内存池。

作为Web服务器Nginx必须重写URL以应对网址变化或者应用的维护这需要正则表达式的支持。做复杂的URL或者域名匹配时也会用到正则表达式。优秀的正则表达式库可以提供更好的执行性能。

以上就是今天的加餐分享,有任何问题欢迎在留言区中提出。