从Nginx学架构(待续)
本文主要提炼出nginx中一些优秀的架构思想,包括反向代理机制,事件触发模型,负载均衡策略,内存管理,高级数据结构和算法的应用等等,这些思想基本上覆盖了linux下网络编程的精华,对于从事高并发分布式系统的开发还是很有借鉴意义的。
整体架构
Nginx 使用 master-work 的架构方式来对外提供服务。这种并发模型很常见了。即一个master 进程管理多个 work 进程,而真正对外提供服务的是work进程。master 进程仅专注自己的管理工作。比如各个work进程的启停,配置文件重载,平滑升级等等。master 和 work 之间会有心跳包来保活,当master检测到work失联的时候,还会负责重新启用新的work进程。
每台服务器开启的work进程跟其CPU个数相关。当每个CPU都只绑定一个work进程时,那work之间的进程切换代价是最低的。
nginx中的反向代理
首先,反向代理指的是,由某个代理服务器接受客户端的所有请求,然后再将这些请求分发到内部不同的服务器上去处理,收到处理结果之后,再由该代理服务器回复给客户端。此时对于客户端来说,屏蔽了服务器内部请求处理的细节,只需要跟代理服务器进行通信即可。这种模式下,往往要求代理服务器具有强大的并发能力,能够扛得住客户端的大流量冲击。
nginx 支持反向代理的模式,比如有的业务不适合使用nginx处理,此时nginx 可以作为反向代理服务器的角色,将这部分业务转发到上游服务器去,得到结果后再返回给客户端。同时nginx也支持直接向客户端提供服务。
那么nginx的反向代理和别的反向代理服务器有何区别或优势呢?
nginx 收到客户端的 http 请求时,并不会立刻转发到别的服务器,而是先缓存到本地进程(内存或磁盘),当接受到一个完整的请求时才会交付给上游服务器处理。相对于Squid等代理服务器,多了一个缓存的过程。
当然这样做的缺点很明显,对于Squid之类的代理服务器来说,反向代理行为类似于透传,来一个包就转发一个包,只是多了一个转发的过程,就跟路由器差不多。而要缓存包,则需要占用nginx的内存或磁盘资源,同时一个请求的时延也变长了。那么优点在哪里呢?
nginx 的缓存策略其实是为了降低上游服务器的负载。通常,客户端与代理服务器之间的网络环境会比较复杂,多半是走公网,网速平均下来可能较慢,因此,一个请求可能要持续很久才能完成。而代理服务器与上游服务器之间一般是走内网,传输速度较快,跟RPC调用差不多。
如果某个请求要上传一个1GB的文件,那么每次透传给上游服务器的代价就是上游服务器必须始终要维持这个连接,直到请求完成,对于一个耗时非常长的请求,这对上游服务器的并发处理能力提出了挑战。而nginx是在接收到完整的客户端请求(如1GB的文件)后,才会与上游服务器建立连接转发请求,由于是内网,所以这个转发过程会执行得很快。这样,一个长耗时的请求占用上游服务器的连接时间就会非常短。所以这种缓存策略其实是将上游服务器的并发处理,转移到了代理服务器上。
另外注意的一点是,nginx 收到上游服务器的回复时,没有必要缓存,而是直接回复给客户端,也就是回去的时候采用的是透传的方式,因为客户端不需要等到全部完成才统一处理。
下一个问题: nginx 分发客户端请求给上游服务器如何负载均衡?
如果上游服务器有缓存的话,此时需要将同一个人的请求固定转发到同一台服务器,这里就涉及负载均衡的机制。负载均衡除了要考虑各个上游服务器的负载之外,还要考虑缓存的命中率。简单的 hash 可以保证缓存较高的命中率。但是如果上游服务器变动较为频繁的时候,比如某一台掉线,或者加入新的服务器,缓存命中可能就惨不忍睹了。这时可以使用一致性hash来解决。(只是一点联想,nginx 中并没有实现一致性hash,用的是简单的取余算法)
nginx中的进程同步和惊群处理
服务端服务端的并发模型通常有四种,多进程两种,多线程两种。对于多进程模型来说 ( master -> workers ),worker 子进程是 master 通过 fork 调用产生的。一个 socket 建立的过程分为 socket(),bind(),listen(),accept()。对accept() 的处理可以有两种方式。
方式一是由父进程统一 listen() ,当有新的连接产生(accept() 有返回) 之后再 fork 一个子进程去处理 accept() 得到的 fd。或者预先fork 出一定数量的子进程,再将 accept() 得到的 fd 通过 RPC 等进程间通信的方式分发给子进程处理。这种方式要求父进程可以并发处理大量的 accpet() 工作,对单个进程的处理能力要求很高。
方式二是由父进程在 fork 之后在子进程中进行 accept(),这样所有的子进程都在等待客户端的连接到来。这种方式避免了将accept() 的工作全部压到父进程上的问题,但是由于最终只会有一个子进程可以成功处理客户端的连接,而其余的子进程虽然被唤醒,但是由于没有抢到 fd 只能再次进入休眠——这种场景成为”惊群”效应,会带来很大的进程间切换的代价。
nginx 使用的是第二种并发模型。虽然linux 高版本的内核已经解决了惊群问题,但是nginx 还是提供了自己的解决方式。linux 内核由于拥有进程调度的权限,在多个进程阻塞在 accept() 上时,如果有连接到来,只会唤醒队首的进程去处理这个连接。nginx 则是通过进程间的锁来实现的。
具体的进程通信方式有共享内存,信号,文件锁等等。nginx 有个 accept_mutex,看名字也很好理解,用于accept() 操作的互斥量。所有想要进行 accept() 操作的进程都需要先拿到这个锁,也就是有个 accept_mutex_try() 的过程,这个调用是立刻返回的,所以进程轮询请求拿这个锁,也就是说这是一种自旋锁。只有拿到锁的进程才有资格去拿到客户端的连接。这里每次 accept_mutex_try 之后有个sleep 的时间是可配置的。