2006初,我接到了公司分配的一个遗留项目,让我负责一个基于C/S的系统的服务器端。其实是系统是基于HTTP协议的,因为负责客户端的同事对于服务器端编程不甚了解,虽然使用PHP对熟悉C++的他来说是驾轻就熟,但是在进一步实现更多的功能和更高的性能上就捉襟见肘了。项目是在非常突然的情况下交给我的,因为该同事在客户端上有更多的事情要做。我在分析了他的数据库结构和PHP源代码之后,决定按照与客户端的通讯协议重写他的服务器端。为了能应付老板苛刻的时间限制,我打算使用正在学习的Ruby on Rails。后来,项目在功能上非常顺利地交付了。
两年过去了,随着客户端数量的不断增加、客户端功能的增加、与服务器端交互数据的增加、老板对功能的要求不断增加,我在这个项目上走了不少弯路,尤其是在部署——或者说是架构——方面。
我遇到的最大的问题就在于并发链接数上。服务器与客户端的每次交互的数据量并不大,但内容无法缓存。起初用的是Nginx/Apache+Mongrel的部署方式,但当遇到大量并发请求时,常常会遇到Mongrel进程死掉的情况。而客户端的用户在无法登录客户端的时候,经常会反复尝试,加重了服务器的负担、导致最后所有的Mongrel进程都挂掉。
最后,经过不懈努力,在现有的3台低端服务器上,可以满足每天500万次的请求。在这里,我将我的一些心得和研究成果总结出来,与大家分享。
Ruby on Rails的部署方案基本上都是由两层结构组成,前端做请求的分发,后端以多个Ruby进程接受并处理请求,主要的差别便是其中的通讯协议,比如使用FastCGI 或者是HTTP。这里讨论的局限于一台服务器,所以我不称之为架构方案,而仅仅是部署配置方案。
前端也有很多种不同的选择,我们来看一下开源领域主流的选择:
Apache
Apache功能十分强大,稳定性也十分好,是全球市场占有率最高的Web服务器。与它搭配的Rails部署方案也有很多,如:
Lighttpd
轻量级高性能Web服务器,服务静态文件的性能非常高。目前的稳定版本Lighttpd1.4中的反向代理模块有所缺陷,此模块会在1.5中全部重写并提供更好的负载均衡算法,可以对基于FastCGI、HTTP、AJP、SCGI的后端进行请求分发。Lighttpd+FastCGI是目前非常流行的一种部署方式,国内著名的JavaEye便采用的这种方式, JavaEye 负责人之一Robbin对Lighttpd+FastCGI的方式推崇备至。Nginx
Nginx也是一个轻量级高性能的Web服务器/反向代理负载均衡器。Nginx也支持FastCGI,但不像Lighttpd 1.5支持对FastCGI后端的负载均衡调度。Nginx+Mongrel的方式受到很多人推荐。HAproxy
HAproxy是一个纯粹的反向代理均衡器,非常小巧,基于事件机制。它不仅可以做HTTP反向代理,还可以作TCP的转发,所以同样可以对FastCGI做负载均衡。在做HTTP反向代的同时可以对请求作一些修改控制。在ThoughtWorks推出的Rubyworks这个Rails应用套件中用到了它,原因是它能很方便地限制到后端服务器的链接数量。但HAproxy不是Web服务器,它不能处理静态文件。Swiftiply
Swiftiply是一个负载均衡反向代理,但它与后端服务器的通讯方式很特殊——它开放一个端口让后端服务器主动链接到Swiftiply,这样可以与后端服务器建立一个持久链接,而且无须重启就可以非常容易地增加新的后端。Swiftiply使用了Ruby的EventMachine 实现了实践驱动机制。支持它的协议的后端有Swiftiplied Mongrel和Thin。
后端Rails的运行方式可以通过FastCGI或者是Ruby应用服务器的方式,CGI和mod_ruby已经不推荐。可以选的Ruby服务器有有:
WEBrick
WEBrick是Ruby标准库中自带的默认HTTP服务器,完全使用Ruby写成,性能较差。利用了Ruby的线程来服务并发的链接。有人写了利用事件机制来提高性能的补丁,由于应用少,不在讨论范围。
Mongrel
目前最为成熟Ruby应用服务器。使用了C++写的HTTP头解析器,所以具有较好的性能,它同样利用了Ruby的线程机制来服务并发的链接。Mongrel的优点是稳定,兼容性好,很多平台上都可以使用,包括JRuby。Swiftiply小组还写了一个利用EventMachine的Mongrel修改版,称之为Evented Mongrel,使用单线程、事件驱动方式。
Thin
利用事件驱动机制的Ruby应用服务器,并借用了Mongrel的HTTP头解析器,事件机制的部分同样是利用了EventMachine。相比Mongrel来说,稳定性和兼容性略差。例如,我在CentOS 3.2的平台上编译后启动便崩溃。
Ebb
又一个高性能的Ruby应用服务器,有多种工作模式,可以使用事件驱动机制,也可以使用线程模式。据说线程模式服务Merb(另一个Ruby的Web应用框架)可以达到更好的性能。和Thin一样,由于刚出现不久,还不是非常稳定。它要求Glibc 2以上版本,所以老版本的Linux无法使用,另外我在自己的Ubuntu 8.04上编译的版本在运行中无法正常接受链接,所以在本文中没有涉及到Ebb的测试。
为了了解各种前、后端程序搭配的方式各自的性能如何,必须做大量的测试。下面我将我为公司做的这个项目所进行的一系列测试的结果展示出来,并做简单的分析。
我利用了Apache Benchmark(ab),对服务器端压力最大的身份验证部分进行压力测试。
注意:这些测试都相对比较简单和宽松,很多因素、细节没有考虑进去,只是作为一个参考。而且,对于不同的Rails应用、应用中的不同部分,都应该做独立的测试,来找到最合适的部署方法。
测试机器为ThinkPad T43,Intel Pentium M 1.86G,1.5G RAM,系统为Ubuntu 8.04,内核版本为2.6.24-16。
被测试的对象的版本为:
以上全部使用默认配置。测试方法是从1开始,以10为步长,到500的数值,作为并发链接数,每次测试1000个请求,得到的ab的报告中取每秒的请求数作为测试的结果。
由于Ebb没有能在我的系统上成功运行起来,所以尚未得到它的数据。其中Thin还支持KeepAlive,所以单独进行了测试,由于这个服务器的应用主要是动态内容,所以后面的测试都不测KeepAlive。
测试结果如下,左边的图是点和线,为了不让画面太乱,能清晰地了解的性能情况,我取了B样条,如右图:
我们从图上,在结合一些现有的知识,可以印证一些概念:
测试的前半部分FastCGI比HTTP快的原因是:
我们也看到了起初支持KeepAlive的Thin处理请求的速度也很快,同样应该是得益于持久链接,减少了打开关闭套接字的操作而造成的。
两个FastCGI的测试,虽然TCP套接字的额外开销要比UNIX套接字高一点,但基本是接近的,因为这两者性能的差异与Rails应用本身的处理速度相比是很微小的。所以FastCGI无论是走TCP还是走Unix套接字性能都是接近的。
然而,当FastCGI接受的并发链接数量不断上升时,请求的处理速度不断降低,这是因为FastCGI的处理请求方式是阻塞的,每次只处理一个请求(可以从fcgi包中的代码中看出来),随着并发链接数量上升,维持链接的开销越来越大。同样的情况也发生在使用了KeepAlive的Thin中。
测试中,Mongrel的响应速度非常稳定。虽然性能在前250并发量的测试中占下风,但是随着并发链接数的上升,它利用线程的好处逐渐显现出来。Thin虽然使用了单线程,由于是基于事件驱动,与Mongrel性能相差不多,但稳定性则低了。
Evented Mongrel的性能令人印象深刻,在后半部的测试中占据了上风。同样使用了EventMachine的Thin,性能却不如它,我觉得可能在于:一、Mongrel不支持KeepAlive,Thin支持,所以需要作额外的考虑;二、可能是Mongrel的HTTP头解析器性能更好。关于第一个因素,我将最大持久链接数设为了0,最后的测试结果是比Mongrel还要低。至于进一步研究为什么,就不在本文讨论了。
在前面的测试报告中,我们似乎看不出什么问题,好像即使处理速度不够,多开几个进程就可以了。这是因为测试是在比较理想的环境下进行的,而实际的生产环境情况要复杂得多。虽然Mongrel在此次测试的结果中显示了很好的稳定性,但是这并不能表示Mongrel就可以在生产环境中同样保持很好的稳定性。
原因在于Ruby的线程机制。但首先这里要强调的是,虽然Ruby的虚拟机有缺陷,线程是伪线程,线程性能较差,但这并不妨碍Ruby可以做一些同步或异步的工作(可以参考Erlang)。问题的关键在于Rails本身不是线程安全的,如果查看Mongrel的代码则会发现,Mongrel在调用Rails的分发器之前就加了锁,直到Rails处理完这个请求。请参考/var/lib/gems/1.8/gems/mongrel-1.1.4/lib/mongrel/rails.rb
第74行(不同的系统上的目录有所不同,不同版本的mongrel,代码出现的位置也可能不同)如下:
@guard.synchronize {
@active_request_path = request.params[Mongrel::Const::PATH_INFO]
Dispatcher.dispatch(cgi,
ActionController::CgiRequest::DEFAULT_SESSION_OPTIONS,
response.body)
@active_request_path = nil
}
我们知道,对于锁的粒度一般是越小越好的,而这里的锁就这是造成问题的关键原因。
假设一个Mongrel当前的请求被阻塞在Rails的代码中(比如一个较长的查询),后续的请求就会被阻塞,假如阻塞的时间足够长,导致队列中请求满了,那么接下来就是出现大量时间花费在上下文切换和锁的争用上。
这就是为什么Rails应该以进程方式来运行的原因。所以之后很多人致力于开发单线程、非阻塞IO的方式来处理请求,这样做的好处是可以减少对连接的创建、关闭的等待时间,以及省去对锁的操作——锁操作是非常昂贵的。但它并不能解决Rails应用程序中被阻塞时的并发问题。同样地,FastCGI也无法解决这个问题。
而新的Ruby框架如Merb,则实现了线程安全,解决了这个问题,因此可以利用线程的方式来提高并发性。Ebb也宣称其线程模式的运行方式在运行Merb可以承受更大的压力。
为了解决这个,我们要做的就是使用多个进程来进行服务,并限制从前端到后端的链接数。
而使用进程方式的问题就在于,进程之间不能共享信息,进程比线程占用更多的资源。我给公司写的程序在启动完毕Mongrel之后,单个进程占用内存为34M,随着访问量的不断增加,内存占用也会逐渐增长,如果遇到大量并发请求,内存会增长地更快。
1<=min<=smax<=max<=系统限制在1到min之间是与后端创建的最少的连接数,一般是持久连接,min到smax之间是根据请求数量创建的动态链接的数量,smax到max之间的连接被放入连接池中,被给定一个生存时间ttl,大于max数量的链接将等待timeout时间。其中在Prefork模式下系统限制始终为1,Worker方式下为每个进程的线程数。
CFLAGS="-march=pentium4 -O3 -pipe -fomit-frame-pointer" ./configure --prefix=/opt/apache2 --disable-authn-file --disable-authn-default --disable-authz-host --disable-authz-groupfile --disable-authz-user --disable-authz-default --disable-auth-basic --disable-include --disable-filter --disable-charset-lite --disable-log-config --disable-env --disable-setenvif --disable-mime --disable-status --disable-autoindex --disable-asis --disable-cgid --disable-cgi --disable-negotiation --disable-dir --disable-actions --disable-userdir --disable-alias --enable-suexec --enable-http --enable-suexec --enable-proxy --enable-proxy-http --enable-proxy-balancer --enable-static-support --enable-static-htpasswd --enable-static-htdigest --enable-static-rotatelogs --enable-static-logresolve --enable-static-htdbm --enable-static-ab --enable-static-checkgid --with-mpm=prefork这个配置尽量减少了Apache所加载的模块,仅启用和测试相关的以提升Apache的性能。另外,这种配置下必须删除rails应用的public目录下默认的.htaccess文件,否则无法使Passenger成功处理静态文件。
Nginx的配置ServerRoot "/opt/apache2"
Listen 8080
User shiningray
Group shiningray
PidFile "logs/httpd.pid"
LockFile "logs/accept.lock"
ThreadLimit 20000
ServerLimit 32
StartServers 2
MaxClients 8192
MinSpareThreads 64
MaxSpareThreads 1024
ThreadsPerChild 256
MaxRequestsPerChild 0
ServerLimit 1024
StartServers 5
MinSpareServers 5
MaxSpareServers 10
MaxClients 1024
MaxRequestsPerChild 0
ServerAdmin [email protected]
ServerName 127.0.0.1:8080
DocumentRoot "/var/www/"
DirectoryIndex index.html
ErrorLog "logs/error_log"
KeepAlive off
MaxKeepAliveRequests 1024
NameVirtualHost *:8080
LoadModule passenger_module "/var/lib/gems/1.8/gems/passenger-1.0.1/ext/apache2/mod_passenger.so"
RailsSpawnServer /var/lib/gems/1.8/bin/passenger-spawn-server
RailsAutoDetect off
ServerName localhost
DocumentRoot /home/shiningray/NetBeansProjects/botadmin/public
RailsBaseURI /
RailsEnv "production"
RailsMaxPoolSize 10
./configure --prefix=/opt/nginx --cpu-opt=pentium4
配置文件nginx.conf如下:
Swiftiply的配置cluster_address: 0.0.0.0
cluster_port: 8080
daemonize: false
map:
- incoming:
- localhost
- 192.168.1.100
outgoing: 127.0.0.1:4000
default: true
docroot: /home/shiningray/NetBeansProjects/botadmin/public
cache_extensions:
- htm
- html
- txt
make TARGET="linux26" CPU="pentium4"配置文件haproxy.cfg为:
# this config needs haproxy-1.1.28 or haproxy-1.2.1
global
log 127.0.0.1 local0
log 127.0.0.1 local1 notice
maxconn 2000
defaults
log global
mode http
option httplog
option dontlognull
retries 3
redispatch
maxconn 2000
contimeout 5000
clitimeout 50000
srvtimeout 50000
listen localhost 0.0.0.0:8080
balance roundrobin
server mongrel_30000 127.0.0.1:30000 maxconn 1
server mongrel_30001 127.0.0.1:30001 maxconn 1
server mongrel_30002 127.0.0.1:30002 maxconn 1
server mongrel_30003 127.0.0.1:30003 maxconn 1
server mongrel_30004 127.0.0.1:30004 maxconn 1
server mongrel_30005 127.0.0.1:30005 maxconn 1
server mongrel_30006 127.0.0.1:30006 maxconn 1
server mongrel_30007 127.0.0.1:30007 maxconn 1
server mongrel_30008 127.0.0.1:30008 maxconn 1
server mongrel_30009 127.0.0.1:30009 maxconn 1
./configure --prefix=/opt/lighttpd配置文件lighttpd为
server.modules = (FastCGI的TCP方式则是将proxy-core-backends参数中的后端改为:
"mod_rewrite",
"mod_access",
"mod_proxy_core",
"mod_proxy_backend_http",
"mod_proxy_backend_fastcgi")
server.document-root = "/home/shiningray/NetBeansProjects/botadmin/public"
server.bind = "0.0.0.0"
server.port = 8080
server.pid-file = "/opt/lighttpd/logs/lighttpd.pid"
server.username = "shiningray"
server.groupname = "shiningray"
server.errorlog = "/opt/lighttpd/logs/lighttpd.error.log"
url.rewrite-once = ( "^/.*$" => "/dispatch.fcgi" )
$HTTP["url"] =~ "/.fcgi$" {
proxy-core.balancer = "round-robin"
proxy-core.allow-x-sendfile = "enable"
proxy-core.protocol = "fastcgi"
proxy-core.backends = (
" unix:/tmp/rails0.sock ",
"unix:/tmp/rails1.sock",
"unix:/tmp/rails2.sock",
"unix:/tmp/rails3.sock",
"unix:/tmp/rails4.sock",
"unix:/tmp/rails5.sock",
"unix:/tmp/rails6.sock",
"unix:/tmp/rails7.sock",
"unix:/tmp/rails8.sock",
"unix:/tmp/rails9.sock"
)
proxy-core.max-pool-size=64
}
"127.0.0.1:30000",Lighttpd使用HTTP后端的配置文件为:
"127.0.0.1:30001",
"127.0.0.1:30002",
"127.0.0.1:30003",
"127.0.0.1:30004",
"127.0.0.1:30005",
"127.0.0.1:30006",
"127.0.0.1:30007",
"127.0.0.1:30008",
"127.0.0.1:30009",
server.modules = (
"mod_rewrite",
"mod_access",
"mod_proxy_core",
"mod_proxy_backend_http",
"mod_proxy_backend_fastcgi")
server.document-root = "/home/shiningray/NetBeansProjects/botadmin/public"
server.bind = "0.0.0.0"
server.port = 8080
server.pid-file = "/opt/lighttpd/logs/lighttpd.pid"
server.username = "shiningray"
server.groupname = "shiningray"
server.errorlog = "/opt/lighttpd/logs/lighttpd.error.log"
$HTTP["url"] =~ "^.*$" {
proxy-core.balancer = "round-robin"
proxy-core.allow-x-sendfile = "enable"
proxy-core.protocol = "http"
proxy-core.backends = (
"127.0.0.1:30000",
"127.0.0.1:30001",
"127.0.0.1:30002",
"127.0.0.1:30003",
"127.0.0.1:30004",
"127.0.0.1:30005",
"127.0.0.1:30006",
"127.0.0.1:30007",
"127.0.0.1:30008",
"127.0.0.1:30009",
)
proxy-core.max-pool-size=1
}
我们可以发现Lighttpd的FastCGI方式走TCP或Socket在性能方面没有太大差别,但限制链接数容易造成Lighttpd直接给出错误返回,随着连接数限制的放宽,错误数也减少,响应速度也变得稳定。
根据前面对后端的测试,无论何种后端都应该在低链接的情况下有更好的表现,Lighttpd为何在限制了连接数之后反而有更过504错误呢,我认为是Lighttpd未实现像Apache和HAproxy的双重链接池的功能,同时Lighttpd的默认的超时时间又只有10秒钟。在Apache和HAproxy的情况中,当链接未进入某个后端服务器的等待队列时,会等待Timeout时间,而进入了后端等待队列之后会重新等待TTL的时间,这样就避免了快达到Timeout的时候进入等待队列没多久就被放弃的情况。当然Apache和HAproxy的Timeout和TTL参数也需要根据系统的性能和要求小心调整。而对于Lighttpd则可以考虑适当增大Timeout时间。
我先来将本案中的各种部署方式的性能排个名次,以下是后各种方案在并发量>=10的情况下的平均值(去掉出现0值的情况):
前端 |
后端 |
平均每秒响应数 | 最大链接限制 |
Lighttpd |
FastCGI/TCP |
215.33 |
64 |
Lighttpd |
FastCGI/Socket |
214.65 |
64 |
Lighttpd |
Thin |
196.16 |
64 |
Swiftiply |
Swiftiplied Mongrel |
191.33 |
N/A |
HAproxy |
Thin |
188.73 |
10 |
Swiftiply |
Thin(Swiftiplied) |
178.96 |
N/A |
Apache2.2/Prefork |
Passenger |
173.02 |
N/A |
HAproxy |
Mongrel |
170.4 |
1 |
Apache2.2/Worker |
Passenger |
163.9 |
N/A |
Lighttpd |
Mongrel |
149.85 |
64 |
Nginx |
Evented Mongrel |
149.78 |
N/A |
Nginx |
Thin |
143.88 |
N/A |
Nginx |
Mongrel |
138.86 |
N/A |
ThreadsPerChild 1024其中,smax=1表示限制到后端的最大链接数为1,max=10表示等待队列为10。如果后端是Mongrel,这样就可以解决Mongrel接受大量请求对锁和上下文切换的消耗。
MaxRequestsPerChild 0
ServerRoot "C:/Apache2"
Listen 80
KeepAlive off
KeepAliveTimeout 15
Timeout 30
MaxKeepAliveRequests 1024
LoadModule log_config_module modules/mod_log_config.so
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_balancer_module modules/mod_proxy_balancer.so
LoadModule proxy_http_module modules/mod_proxy_http.so
LoadModule rewrite_module modules/mod_rewrite.so
LoadModule status_module modules/mod_status.so
ServerAdmin [email protected]
ServerName shiningray.cn
DocumentRoot "D:/wwwroot/rails/public"
ErrorLog logs/error.log
LogLevel warn
LogFormat "%h %l %u %t /"%r/" %>s %b /"%{Referer}i/" /"%{User-Agent}i/"" combined
LogFormat "%h %l %u %t /"%r/" %>s %b" common
LogFormat "%h %l %u %t /"%r/" %>s %b /"%{Referer}i/" /"%{User-Agent}i/" %I %O" combinedio
CustomLog logs/access.log common
BalancerMember http://127.0.0.1:30000 max=10 smax=1
BalancerMember http://127.0.0.1:30001 max=10 smax=1
#在这里放更多的后端服务器
ProxyRequests off
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^/(.*)$ balancer://mongrels %{REQUEST_URI} [QSA,P,L]
# this config needs haproxy-1.1.28 or haproxy-1.2.1
global
log 127.0.0.1 local0
log 127.0.0.1 local1 notice
#log loghost local0 info
maxconn 2000
#chroot /usr/share/haproxy
#uid 99
#gid 99
#daemon
#debug
#quiet
defaults
log global
mode http
option httplog
option dontlognull
retries 3
redispatch
maxconn 2000
contimeout 5000
clitimeout 50000
srvtimeout 50000
listen test 0.0.0.0:9000
mode tcp
balance roundrobin
server mongrel_30000 127.0.0.1:30000 maxconn 8
server mongrel_30001 127.0.0.1:30001 maxconn 8
server mongrel_30002 127.0.0.1:30002 maxconn 8
server mongrel_30003 127.0.0.1:30003 maxconn 8
server mongrel_30004 127.0.0.1:30004 maxconn 8
server mongrel_30005 127.0.0.1:30005 maxconn 8
server mongrel_30006 127.0.0.1:30006 maxconn 8
server mongrel_30007 127.0.0.1:30007 maxconn 8
server mongrel_30008 127.0.0.1:30008 maxconn 8
server mongrel_30009 127.0.0.1:30009 maxconn 8
worker_processes 1;据我测试,此部署方案的性能也可以与Lighttpd的几种一较高下,但毕竟因为多了一层,还是略占下风。
events {
worker_connections 1024;
use epoll;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
#tcp_nopush on;
#keepalive_timeout 0;
keepalive_timeout 65;
server {
listen 8080;
server_name localhost;
root /home/shiningray/NetBeansProjects/botadmin/public;
location / {
include fastcgi_params;
if (-f $request_filename/index.html) {
rewrite (.*) $1/index.html break;
}
if (-f $request_filename.html) {
rewrite (.*) $1.html break;
}
if (!-f $request_filename) {
fastcgi_pass 127.0.0.1:9000;
break;
}
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}