第12章 upstream机制的设计与实现

第5章中曾经举例说明过upstream机制的一种基础用法,本章将讨论upstream机制的设计和实现,以此帮助读者全面了解如何使用upstream访问上游服务器。upstream机制是事件驱动框架与HTTP框架的综合,它既属于HTTP框架的一部分,又可以处理所有基于TCP的应用层协议(不限于HTTP)。它不仅没有任何阻塞地实现了Nginx与上游服务器的交互,同时又很好地解决了一个请求、多个TCP连接、多个读/写事件间的复杂关系。为了帮助Nginx实现反向代理功能,upstream机制除了提供基本的与上游交互的功能之外,还实现了转发上游应用层协议的响应包体到下游客户端的功能(与下游之间当然还是使用HTTP)。在这些过程中,upstream机制使用内存时极其“节省”,特别是在转发响应包体时,它从不会把一份上游的协议包复制多份。考虑到上下游间网速的不对称,upstream机制还提供了以大内存和磁盘文件来缓存上游响应的功能。

因此,拥有高性能、高效率以及高度灵活性的upstream机制值得我们花费精力去了解它的设计、实现,这样才能更好地使用它。同时,通过学习它的设计思想,也可以深入了解配合应用层业务基于第9章的事件框架开发Nginx模块的方法。

由于upstream机制较为复杂,同时在第11章“HTTP框架”中我们已经非常熟悉如何使用事件驱动架构了,所以本章将不会纠结于事件驱动架构的细节、分支,而是专注于upstream机制的主要流程。也就是说,本章将会略过处理upstream的过程中超时、连接关闭、失败后重新执行等非核心事件,仅聚焦于正常的处理过程(在由源代码对应的流程图中,就是会把许多执行失败的分支略过,对于这些错误分支的执行情况,读者可以通过阅读ngx_http_upstream源代码来了解)。虽然upstream机制也包含了部分文件缓存功能的代码,但限于篇幅,本章将不介绍文件缓存,这部分内容也会直接略过。经过这样处理,读者就可以清晰、直观地看到upstream到底是如何工作的了,如果还需要了解细节,那么可以由主要流程附近的相关代码查询到各种分支的处理方式。

Nginx访问上游服务器的流程大致可以分为以下6个阶段:启动upstream机制、连接上游服务器、向上游服务器发送请求、接收上游服务器的响应包头、处理接收到的响应包体、结束请求。本章首先在12.1节系统地讨论upstream机制的设计目的,以及为了实现这些目的需要用到的数据结构,之后会按照顺序介绍上述6个阶段。

12.1 upstream机制概述

本节将说明upstream机制的设计目的,包括它能够解决哪几类问题。接下来就会介绍一个关键结构体ngx_http_upstream_t以及它的conf成员(ngx_http_upstream_conf_t结构体),事实上这两个结构体中的各个成员意义有些混淆不清,有些仅用于upstream框架使用,有些却是希望使用upstream的HTTP模块来设置的,这也是C语言编程的弊端。因此,如果希望直接编写使用upstream机制的复杂模块,可以采取顺序阅读的方式;如果希望更多地了解upstream的工作流程,则不妨先跳过对这两个结构体的详细说明,继续向下了解upstream流程,在流程的每个阶段中都会使用到这两个结构体中的成员,到时可以再返回查询每个成员的意义,这样会更有效率。

12.1.1 设计目的

那么,到底什么是upstream机制?它的设计目的有哪些?先来看看图12-1。

第12章 upstream机制的设计与实现 - 图1

图 12-1 upstream机制的场景示意图

(1)上游和下游

图12-1中出现了上游和下游的概念,这是从Nginx视角上得出的名词,怎么理解呢?我们不妨把它看成一条产业链,Nginx是其中的一环,离消费者近的环节属于下游,离消费者远的环节属于上游。Nginx的客户端可以是一个浏览器,或者是一个应用程序,又或者是一个服务器,对于Nginx来说,它们都属于“下游”,Nginx为了实现“下游”所需要的功能,很多时候是从“上游”的服务器获取一些原材料的(如数据库中的用户信息等)。图12-1中的两个英文单词,upstream表示上游,而downstream表示下游。因此,所谓的upstream机制就是用来使HTTP模块在处理客户端请求时可以访问“上游”的后端服务器。

(2)上游服务器提供的协议

Nginx不仅仅可以用做Web服务器。upstream机制其实是由ngx_http_upstream_module模块实现的,它是一个HTTP模块,使用upstream机制时客户端的请求必须基于HTTP。

既然upstream是用于访问“上游”服务器的,那么,Nginx需要访问什么类型的“上游”服务器呢?是Apache、Tomcat这样的Web服务器,还是memcached、cassandra这样的Key-Value存储系统,又或是mongoDB、MySQL这样的数据库?这就涉及upstream机制的范围了。其实非常明显,回顾一下第9章中系统介绍过的主要用于处理TCP的事件驱动架构,基于事件驱动架构的upstream机制所要访问的就是所有支持TCP的上游服务器。因此,既有ngx_http_proxy_module模块基于upstream机制实现了HTTP的反向代理功能,也有类似ngx_http_memcached_module的模块基于upstream机制使得请求可以访问memcached服务器。

(3)每个客户端请求实际上可以向多个上游服务器发起请求

在图12-1中,似乎一个客户端请求只能访问一个上游服务器,事实上并不是这样,否则Nginx的功能就太弱了。对于每个ngx_http_request_t请求来说,只能访问一个上游服务器,但对于一个客户端请求来说,可以派生出许多子请求,任何一个子请求都可以访问一个上游服务器,这些子请求的结果组合起来就可以使来自客户端的请求处理复杂的业务。

可为什么每个ngx_http_request_t请求只能访问一个上游服务器?这是由于upstream机制还有更复杂的目的。以反向代理功能为例,upstream机制需要把上游服务器的响应全部转发给客户端,那么如果响应的长度特别大怎么办?例如,用户下载一个5GB的视频文件,upstream机制肯定不能够在Nginx接收了完整的响应后,再把它转发给客户端,这样效率太差了。因此,upstream机制不只提供了直接处理上游服务器响应的功能,还具有将来自上游服务器的响应即时转发给下游客户端的功能。因为有了这个独特的需求,每个ngx_http_request_t结构体只能用来访问一个上游服务器,大大简化了设计。

(4)反向代理与转发上游服务器的响应

转发响应时同样有两个需要解决的问题。

1)下游协议是HTTP,而上游协议可以是基于TCP的任何协议,这需要有一个适配的过程。所以,upstream机制会将上游的响应划分为包头、包体两部分,包头部分必须由HTTP模块实现的process_header方法解析、处理,包体则由upstream不做修改地进行转发。

2)上、下游的网速可能差别非常大,通常在产品环境中,Nginx与上游服务器之间是内网,网速会很快,而Nginx与下游的客户端之间则是公网,网速可能非常慢。对于这种情况,将会有以下两种解决方案:

❑当上、下游网速差距不大,或者下游速度更快时,出于能够并发更多请求的考虑,必然希望内存可以使用得少一些,这时将会开辟一块固定大小的内存(由ngx_http_upstream_conf_t中的buffer_size指定大小),既用它来接收上游的响应,也用它来把保存的响应内容转发给下游。这样做也是有缺点的,当下游速度过慢而导致这块充当缓冲区的内存写满时,将无法再接收上游的响应,必须等待缓冲区中的内容全部发送给下游后才能继续接收。

❑当上游网速远快于下游网速时,就必须要开辟足够的内存缓冲区来缓存上游响应(ngx_http_upstream_conf_t中的bufs指定了每块内存缓冲区的大小,以及最多可以有多少块内存缓冲区),当达到内存使用上限时还会把上游响应缓存到磁盘文件中(当然,磁盘文件也是有大小限制的,ngx_http_upstream_conf_t中的max_temp_file_size指定了临时缓存文件的最大长度),虽然内存和磁盘的缓冲都满后,仍然会发生暂时无法接收上游响应的场景,但这种概率就小得多了,特别是临时文件的上限设置得较大时。

转发响应时一个比较难以解决的问题是Nginx对内存使用得太“节省”,即从来不会把接收到的上游响应缓冲区复制为两份。这就带来了一个问题,当同一块缓冲区既用于接收上游响应,又用于向下游发送响应,同时可能还在写入临时文件,那么,这块缓冲区何时可以释放,以便接收新的缓冲区呢?对于这个问题,Nginx是采用多个ngx_buf_t结构体指向同一块内存的做法来解决的,并且这些ngx_buf_t缓冲区的shadow域会互相引用,以确保真实的缓冲区真的不再使用时才会回收、复用。