应百林哲笑含的邀请,于2018.6.9号至7.1号前往广州白云国际会议中心参加《CSDI Summit 中国软件研发管理行业技术峰会》。会上认识了很多互联网一线老师是最大的收获:
本次我分享的主题是《兼顾灵活与性能的nginx》:
意外的惊喜是CSDI的讲师证书非常精美:
最后附上本次演讲的PPT内容:
兼顾灵活与性能的nginx
以下为文字速录内容:
大家好,我是杭州市智链达数据有限公司的联合创始人和CTO,为什么又要介绍一下?因为我们公司是一个互联网服务企业,但是我们面向的客户是建筑企业,所以在座的各位都不是我的潜在客户,所以接下来不会有任何介绍关于我们产品的推广和介绍:-)。从我的这个分享的标题中可以看到,这里其实有两个关键词,一个是性能,一个是灵活,我们接下来讨论这两点中nginx是怎么做到的。当前nginx已经是所有的互联网企业的一个标配底层组件,所以能分享这样一个大众化的广为使用的工具,我个人感到很荣幸。不管是小流量还是大流量场景使用了nginx后都可以有一个立竿见影的效果。
那么本来的话nginx介绍这部分可以没有,但是我相信在座的各位应该在生产环境中时实际操作过nginx的同学应该不是很多吧?能不能请在生产环境中直接使用过nginx,或者你带的团队负责nginx的同学,能不能举一下手?我看一下还是有一半以上的同学没有操作过nginx的,所以我会用五分钟的时间先做个简单的介绍。
首先我们肯定是先看它的使用场景,那么场景的话呢先从这个最右边的这个静态资源来看,我们现在不管是开发一个web页面或者是开发一个APP webview,都会去拉取大量的CSS、JS、小图片等资源,这些资源的空间占用量其实很大,也很难放在内存中,所以只能放在磁盘上。nginx非常擅长把磁盘中的内容以http协议的方式返回给客户端,所以这是nginx第一个场景。第二个场景中,如果我们的应用服务是用python写的,可能也就几百QPS,JAVA写的服务可能有上千QPS,如果是GOLANG写的可能有上万QPS,但是nginx拥有上百万QPS的系统能力,所以如果我们需要尽量提高我们的这个系统容量,那么需要把很多的应用服务组成一个集群来对用户提供服务,在早期的时候,我们可能会用DNS等手段做负载均衡,而现在呢都是采用nginx做反向代理,因为它卓越的单机性能很适合该场景。那么在反向代理使用场景中就会有另外一个问题,负载均衡,我们经常会需要扩容,宕机容灾时这个负载均衡可以发挥很好的作用。那么有了反向代理后又会引出另外一个问题就是缓存。
因为其实在互联网行业中,只要我们想提升用户的体验,基本上都是在缓存上下功夫,而缓存基本上你是放在离用户越近的地方效果越好。比如说你放在手机APP的存储上,或者放在浏览器的storage里,这样的用户体验最好!或者说再差一点到网络中了,那么放在cdn效果也是很好,但如果请求到了我们企业内网中,这个时候,往往离用户最近效果最好,比如说像mysql数据库它虽然专注于只做一件事,以致于他的这个缓存做的是非常厉害,但是他的能力再强也没有用,因为数据库前面会有一个业务应用服务,应用服务强调的是快速迭代,它强调的是对程序员友好以提升开发效率,所以呢你想它性能好是不可能的。所以这个时候呢我们在这个nginx上做缓存,因为反向代理协议就很简单,做缓存也很方便,我们最后也可以拿到好的结果。
第三个场景呢就是中间这个广场。有一些高频的接口调用,比如说像用户鉴权,还有像前天有一位阿里巴巴国际部的老师说他们的流量导流等应用,这些东西都需要这个nginx要发挥自己的特长,然后不要跟慢吞吞的应用服务扯上关系。就像我刚刚说的,其实数据库例如mysql他的能力是很强的,那么如果这些业务可以直接在nginx上实现,那么其实我们就可以提供一个API。那么API服务实现上有几个难点,第一个呢以前的nginx往往是通过每个第三方模块自行定义它自己独特的配置格式,以此实现复杂的业务功能,但这种模式是会有很多问题,因为你是独特的不是通用的,而且且学习成本很高,扩展性也不好。所以呢以通用编程语言实现是一个好思路,官方还搞了一个javascript版本,而openresty搞了一个lua版本,那么因为引入了编程语言,那么你可以很方便的调用工具SDK,所以做API服务就有了可行性,这是最主要的应用场景。
server {
listen localhost:80; listen 8000;
server_name zlddata.com zlddata.cn;
location /static { ... } location / { ... }
}
Server {
listen 80;
server_name fuzhong.pub; ….
接着,我们接收完http request header后,可以从HOST头部获取到域名,而这可以匹配server_name配置后的虚拟主机域名列表,这样就唯一确定了一个server{}配置块。从URL中还可以再次匹配location后的正则表达式,这样我们就找到了具体的location配置。
再看这张图,我们谈谈11个阶段间http模块间的配合。这里仅以官方模块举例。当一个请求读完http header后,我们先进入preaccess阶段,这一阶段里有两个模块:limit_conn和limit_req模块。前前限连接,后者限请求。可见,前后顺序乱不得,否则就导致limit_conn无法正常生效了。当limit_conn模块决定请求不受限制后,它会返回NGX_OK给钩子函数,这样进入当前preaccess阶段的下一个模块limit_req模块继续处理。而limit_req模块也认为不受限制,可以继续处理,因为当前preaccess阶段没有其他模块了,故进入下一阶段access阶段继续处理。而在access阶段中,若第一个模块auth_basic认为无须进入下一个access模块处理,那么它可以返回NGX_AGAIN给钩子函数的调用者,这样access阶段其后的模块是得不到执行的。可见http模块还是很灵活的。当content阶段生成内容后,首先由header filter模块处理。为什么呢?因为http是流式协议,先返回header,再返回body。比如我需要做压缩,那么就需要先在header中添加content-encoding头部,再压缩body。这里需要注意的是,这些模块间也有顺序要求!比如现有一张图片,你只能先做缩略做再压缩,如果反过来,压缩后是没办法做缩略图的。所以这个顺序也是由configure这个脚本决定的,大家可以看源码时看到里面的注释明确的写着不能改order顺序。
这张图是openresty官方的图。用好openresty的关键是,搞清楚指令与sdk。其中sdk比较简单,就是形如ngx.xxx这样的函数,可以在lua代码中调用。它实际上就是lua与C语言的交互,通过先在nginx模块中提供相应的函数,再封装给lua作为lua函数即可,目前主要在用ffi方式,最新的openresty都在用ffi方式重构。而指令就是nginx配置,它会决定其中{}大括号内的代码在什么时候执行。这张图中,有初始nginx启动阶段、有rewrite/access阶段、有content阶段以及log阶段。这与我们之前的所说的11个阶段有什么关系呢?
我们来看这张图,有点复杂,最上面的绿框是nginx启动过程,其中黑色的框是master进程,而紫色的框是worker进程,中间的红点是钩子函数。中间的紫色框是worker进程在处理请求。最下面的绿色框是nginx在退出。可以看到,当nginx启动在,通过在配置文件中各第三方模块可以介入,在init_module回调函数实现东西也可以介入nginx的启动。当派生出worker子进程后,仍然可以通过回调init_master、init_process等回调方法介入启动过程。而实际处理请求时,先可以通过8个http阶段介入与请求的处理,在content阶段还可以使用排他性的r->content_handler(用于反向代理)来生成响应内容。在生成响应内容时,还可以通过init_upstream钩子函数决定选择哪一台上游服务器。生成响应内容后,通过filter过滤模块也可以介入请求的处理,最后在access log阶段也可以介入请求的处理。在nginx退出时仍然可以介入处理。
而openresty的指令就是像图中这么介入处理的。例如,rewrite_by_lua实际是在post_read阶段介入处理的,因为就像上面说过的,rewrite阶段都是官方模块在处理,所以openresty实际是在postread阶段,所以这个指令是相当靠前的。而balance_by_lua实际是在init_upstream钩子里介入的。
最后我们看一下nginx变量。有一类模块会生成新的nginx变量,它们通过处理http请求时定义的取值方法,生成了变量名对应的变量值,并以$符号或者lua中的ngx.var等方式提供给使用变量的模块。这些模块既包含C模块,也包括lua模块。C模块更关注高效,往往提供变量的模块都是C模块,而lua模块关注业务。所以,这两类语言最好的解耦方法就是使用变量。
最后我们看看第三部分nginx性能的优化。我们希望nginx可以把一台服务器的性能压榨到极致,主要从5个方面入手。首先是不能有长时占有CPU的代码段。因为nginx是事件驱动的、非阻塞的、异步架构代码,就像图中所示,nginx把本来操作系统应该做的事:切换不同的请求处理,改为在nginx进程内部处理了。怎么讲呢?传统的进程是同一时间只处理一个请求,所有处理请求的方法都是阻塞的,所以在处理完一个请求前不会处理下一个请求。因此,当大量并发请求存在时,意味着大量运行中的进程或者线程。操作系统希望最大化吞吐量,它就会切换不同的进程到CPU上执行,当一个进程因为阻塞请求导致的系统调用不满足时,例如读取磁盘转头磁头,就会被切换到内存中等待下次执行。而nginx采用的事件驱动,则是把这一过程放在nginx的用户态代码内了,首先用非阻塞系统调用检测到条件不满足,如果执行会导致操作系统执行进程间切换时,就会把该请求切到内存中等待下次执行,而nginx会选择条件满足的请求继续执行。因此,如果处理一个请求时消耗了大量的CPU时间,就会导致其他请求长时间得不到处理,以至于大量超时,形成恶性循环。所以,遇到某些第三方模块会大量消耗CPU时务必谨慎使用,真有这样的场景也不应当在nginx中做,可以用nginx反向代理到多线程应用中处理。因为操作系统会为每个进程分配5ms-800ms的时间片,它也会区分IO型或者CPU型进程,而上述进程是明显的CPU型进程,上下文切换不会很频繁。
第二个优化点就是减少上下文切换。在这页PPT中我们提到一个工具叫pidstat,它可以清晰的看到主动切换与被动切换。何谓主动切换呢?就是执行了某些阻塞式系统调用,当条件不满足时内核就会把进程切换出去,叫做cswch/s。而操作系统微观上串行宏观上并行实现的多任务,是使用抢占式内核实现的,它为每个进程分配时间片,时间片耗尽必须切出,这就叫nvswch/s。我们通过增加进程的静态优先级来增大时间片的大小。静态优先级分为40级,默认进程是0级,最大是-19,我们可以在nginx.conf里修改静态优先级。另外,还可以通过把worker进程绑定CPU,减少在多核服务器上的进程间切换代价。对于主动切换,则需要减少使用类似nginx模块的场景。有时这很难避免,例如读取静态文件,当频繁读取的内容打破内存缓存时,使用nio或者sendfile也没有用,仍然退化为阻塞式调用,此时用threadpool线程池就很有意义了,官方有个博客上提到此种场景下线程池有9倍的性能提升。当然,目前线程池只能用于读取静态资源。
第三个优化是减少内存的使用。很多并发的连接是不活跃的,但它们还是会在内核态、用户态占有大量的内存,而总内存其实很有限,所以我们的内存大小及各种内存相关配置影响了我们的并发量。先从连接谈起,在Nginx进程内为每个连接会分配一个ngx_connection_t结构体,每个ngx_connection_t各分配一个ngx_event_t结构体用作读、写事件,在64位操作系统下以上结构体每个连接(无论是TCP还是UDP)消耗的内存是232+96*2字节。在操作系统内核中,为了处理复杂的TCP协议,必须分配读、写缓冲用于进程的读写、滑动窗口、拥塞窗口等相关的协议收发,而linux为了高效使用内存,设立了普通模式和压力模式,即内存宽裕情况下为每个连接多分配一些缓存以提高吞吐量,在压力模式下则每个连接少分配一些缓存以提高并发连接数,这是通过tcp_moderate_rcvbuf开关控制的,而调整幅度可通过tcp_adv_win_scale控制,调整区间在读写缓存上设置。Nginx中含有大量内存池,形如*_pool_size都是在控制初始内存分配,即必须分配出去的内存。还有一类分配如8 4K这样的多块不连续内存,比如对于large header或者gzip buffer等,它们使用ngx_buffers_t结构体存储。共享内存是用于跨worker进程通讯的,而openresty里的share_dict就是通过共享内存实现的,当然使用共享内存通常要用slab伙伴系统管理内存块,再用rbtree红黑树或者链表等数据结构管理实际的逻辑。Nginx中还会用到大量的hash表,比如存储server_names等,这里会定义桶大小和桶个数。
第四个是优化网络。我们先从TCP层面看,无非是读、写消息、建立与关闭连接等功能。如果是读消息,我们需要关注tcp_rmem设置缓存区的大小,需要关注初始拥塞窗口rwnd的大小以提升网络可以快速达到最优值。在nginx上还有许多控制读取到固定消息的超时时间,在读取上游服务发来的响应时还可以通过limit_rate限流。在发送消息时,同样可以设置tcp_wmem设置缓存区大小,通过iptables命令对cwnd来提升初始窗口,nodelay和nopush都是为了提升吞吐量的算法,当然它们的副产品就是牺牲了及时性增大了latency。总体来说对于大流量场景应该打开它们。当然nopush只对sendfile有效。当发送响应给客户端时,也可以通过limit_rate进行流控。作为服务器建立连接时,Tcp Fast Open技术可以在SYN包里就携带请求,这减少了一次RTT,但有可能带来反复收到相同包的情况,一般不打开;使用Defered可以减少nginx对某连接的唤醒次数,提升CPU使用效率;reuseport可以提高负载均衡效果,使多worker进程更好的协同工作;backlog可以增大半连接与全连接队列,特别是新连接很多而Nginx worker非常繁忙时。关闭连接最复杂,特别是nginx主动关连接时,fin_wait_1状态下linger可以控制关连接的时机以减少RST包的发送;tcp_orphan_retries控制发送次数。fin_wait_2状态下可通过tcp_fin_timeout控制超时时间。在time_wait状态下通常会打开tcp_tw_reuse提升端口的利用率,但tcp_tw_recycle会使得time_wait状态近乎消失,这会带来端口复用时被丢包补发的FIN包关闭连接。
最后是减少IO调用。这只对读取本地的静态资源有效,例如打开sendfile采用了零拷贝技术就减少了内存拷贝次数以及进程间上下文切换次数。
最后对今天的演讲做个总结。我前阵子听梁宁的30堂产品课,上面说到看待产品或者人都是从5个层面,我觉得很适用于nginx。首先从表面层。例如相亲时先对异性的长相、谈吐、衣着,而看nginx则是看它的配置文件格式、access.log日志格式、进程启动方式等。第二层是角色层,例如与一个同事沟通,那么他是HR或者是前端工程师,都会影响他的谈吐以及沟通方式。而nginx的角色层就是最开始提到的静态资源服务、反向代理、API服务器,这影响它的表现层。第三层是资源层,对人则是人脉资源、精神资源、知识结构等,而对nginx则是它的大量的第三方模块、社区等。第四则是能力圈,对人就是一个人的能力大小,对nginx就是上文提到的nginx的核心架构、模块化、设计思路、算法、容器等。第五层是最内核的存在感,对人则是什么状态能让人满足,对nginx则是它的设计意义,就是我们前面提到的把一台服务器的硬件能力使用到极限以提供强大的web服务能力。这里底层总是在影响着上层,所以当我们掌握了nginx的底层,无论上层怎么变都很容易理解。