为什么80%的码农都做不了架构师?>>>
结束了一份Ruby为主的工作,想把个方面总结一下,这篇是关于系统性能方面的.以下数据都是简单回忆的数据,加之企业保密数据的需要,和精确数有些出入,仅供参考.
说起Ruby的性能,无论从官方到社区,都公认是劣于其它的框架的.
那么问题来了,当Ruby为主的系统需要很高的性能的时候,要如何去处理呢?
以下是我优化一个经手的系统的经过,仅供参考 .
项目背景
我经手的是一个影票系统.原先是java架构,后因java更新和维护都无法满足商务方面的要求,才使用Ruby来重构了核心系统,这也是Ruby的一个重要优势之一,更新修改很灵活.但这也是这个项目优化时最困难的约束之一:必须兼容以前的老版本.
最早重构版本,只是原来java版本API的重新实现,外加一个很酷的自动配价功能.几乎没有性能上要求.
先说说性能要求的背景,影票系统本身并没有很多的性能上的要求,毕竟是消费型的系统,起初高峰的日子也就1w左右的成交量,如果算访问成交比20:1,也才20w左右的日访问量.当然,这里不包括抓取数据的部分.正常这些访问会被分配到每天15-20点左右,只要不是太差的系统都能吃下这些流量.
直到某天某行开始做整点抢活动,即在指定的时间放出大量很廉价或是免费的票.这就是性能问题的开始.
这样业务模型会造成流量的瞬间暴发,系统终于没有意外的崩溃了.
资源文件优化
我们第一次活动最早的表象问题是:系统缓慢,无法进入活动页面/进入活动页面白屏/支付无法点击或是支付无结果.
当时统计,活动开始时访问量大概是2w/分钟.即每秒要并发300多个访问.这对纯访问型的系统如新闻网站可能不是很高的数据,但是对于一个复杂资讯并有资金交易的系统来说,是很致命的.关键和敏感的奖金交易请求被淹没在普通的访问中,这就是上述表象问题的后续问题:交易访问被淹没抛弃.
这直接导致大量愤怒的用户:抢到票无法交易的,支付完了无法出票,出票没有结果的.这某种程度认为活动是失败的了.这必须马上被解决.
当时解决问题的主要约束是时间,活动是以周为单位的,两天活动,5天处理,包括周末.不仅要保证系统正常,还能处理好问题,提高性能,所以花时间的高大上的解决方案都会是不现实的.
最现实的办法就实际问题实际分析,将问题一个一个处理.
系统缓慢就不用说了,性能还上不去,自然缓慢,这个后面处理.
进入页面的白屏,是可以解决的.最早我认为只是糟糕的web页面造成的.这个活动页面是一个webView的页面,这个web页面几乎没有清理过,包含了这个项目上线以后各种历史功能.是的,历史功能.其中包括发短信等匪夷所思的功能.在项目的早期,因为我是空降兵,对项目了解有所不足,所以很多的功能只能先粗暴的复制,留下的技术债务在这个最糟糕的时刻不得不拼命偿还.
页面优化后,我还发现一个Ruby应用框架很大问题:在某种情况下,通过Ruby Web应用,如passenger/thin等的请求(这里只试出了资源类型js和图片),有可能形成无限传输,即用wget url得到一个无限大的文件,并且连接不会停止,这样页面就无法正常显示给用用户.
具体原因当时没有时间去深究.解决办法是简单把资源文件放在nginx或是专用的资源服务器上,这里我使用了资源服务器,因为资源服务器在不同的机房,这样也给带宽做了分流.
在高压环境下,把资源文件这种简单粗重的活直接丢给nginx前端或是专用资源服务器(如s3)是很有较的.
数据库及代码优化
数据库优化永远是系统优化的第一步.数据库问题也是系统性能的第一重要瓶颈.糟糕的查询/没有索引/过大的数据,都会引起数据库问题.
糟糕的查询,也是糟糕的代码.或许很多Ruby的程序员都抱着”不需要考虑性能问题”的教条.但是行而上学的方法会给项目代码和自身的发展都事带来很大的不良影响.
我们真的可以”不需要考虑性能问题”?.当然不是,也许给出这个教条的大神并没有生活在天朝it圈这个比较低端的环境,并不知道:原来还可能写出这么糟糕的代码!
我遇到比较糟糕的代码是像这样的:
# 取出正在上影的影片列表
Even.includes(‘films').where(“…”).map{|e| e.film}.uniq
这个代码取出所有有排期的排期,只是为了取出正在上影的影片.这段代码在初期的时候因为认为结果会被缓存,数据量也不大,所以没有什么问题.
但在数据量增大后,总量达到千万级,有效数据万条以上时,就足以拖慢整个系统.
这样的问题代码,不能总是从代码review中取得,从数据库日志中得到提示是更加聪明的办法.
特别是你不是从头开始就管这个项目的时候,拿我们使用的postgres来说,我们可以在数据库的日志中找出有问题的查询语句,再反查对应的项目和语句.
pg日志中,标记为duration的语句,就是糟糕的查询(需要配置).
不同的语句有不同的速度要求,不过一般情况下,10ms左右的查询是优质的,100ms左右还过得去,大于200ms是默认的duration值了,过了1s,这些查询在需要性能的系统里就很致命了.
那么duration是1s,是不是我这个请求也就1s多一点能处理完成?在系统压力小的时候是的,但是压力上去后,这些查询就会把你的系统卡得死死的,他们由1s变成1min,也使得其它10ms能处理的查询 变成1s以上.系统就是这样崩溃的.
从系统中找出这些代码,优化他们,修改查询方案或是添加新的索引,都是很好的解决办法.
优化数据库访问相关的代码,可以大大地减少每一个请求影响的时间,但这只是处理问题的开始.
pgbuncer
单独的把pgbuncer当成一个优化内容,因为这其实是一个优化数据库连接池的内容.一个高性能的系统是不能没有数据库连接池的,而Ruby容器在这方面表现很差.
Ruby擅长慢功出细活,但每一个”活”都要占用一个数据库连接,这就很惨了,我一台64G24core的数据库服务器开800个连接,已经是比较乱来的配置了.但如果不用连接池,这还远远不够.
比如,我在抢票的时候,希望出一分钟内出1000个订单,这些订单要经过复杂的网络交互,加起来占上20/30s都是快的.那么我开400个进程已经是最少了,如果没有连接池,这400个进程每个都要占用一个连接,直到进程完成,甚至直到这些进程已经不再使用.
而使用pgbuncer可以分配上万个连接数,而只有正在查询的语句才会占用真正的连接数.
引入新的高性能框架golang
Ruby系统的性能很差,有的人不相信,并且罢出很多HelloWorld来表示我们也可以和java等应用一拼高下.这是没有意义的,起码对我的企业级应用来说,起码连接一次redis,从中取得数据,这样的实例测试才是有效的.我做过测试,从redis中取得个缓存结果集返回给客户端,go只占了2G左右内存,就肯完所有的CPU,并在一分钟完成了100w次请求,能力暴表,而Ruby应用在2万次左右就上不去了,特别的问题是已经耗光了所有的内存,而cpu并没有完全利用.
以下是我认为Ruby系统性能差的原因:
1. 内存占有大. 一个进程sinatra类的也有100M左右.而内存是很固定而且昂贵的
2. 线程纤程类的支持差. 最新的Ruby或是Rails都可以支持线程和纤程之类消耗小但是可以提高性能的功能.但是可以支持和优秀的支持是有很大差距的.使用线程模式后在大压力下,进程出现很多奇怪错误并有僵死的问题.加大线程数并没有其它的架构那样有明显的提高.这里也可能和pg没有好的Ruby并发gem有关.而在这个方面,我使用过最好用的是go.这也是我后面用go来进行开发cache服务器的原因.
3. 垃圾回收问题.1.9的垃圾回收很差,这点在2.1之后得到了改进,但是,在我使用的时候,2.1还有很大的问题,在试验性使用后,出现了Rails进程”长大”到20+G搞坏系统的情况,极不稳定.
这些问题都很大的影响了Ruby系统的性能,在到达一定瓶颈后,性能和可靠性都受到了极大的挑战.
为了解决这个问题,我引入了go写了一个高性能缓存系统.
go的特点
1. 天然支持高并发.
2. 内存占用小,gorountine把第一/二点结合得淋漓尽至.
3. 有现代语言的特征,让我们在获得高性能的同时,不会受到c/c++语言的折磨.
go占内存小cpu利用率高,Ruby占内存大,占CPU小,两者配合相得益彰.
我把我的go服务称为CacheServer,而把原来的ruby系统称为RealtimeServer,原先的请求通过nginx分成两个部分,一部分是读,一部分是写(交易).
读的部分丢给CacheServer,如果CacheServer不能处理(找不到cache),就丢给RealTimeserver,ReadTimeServer负责写入,cacheServer等待RealTimeServer写完缓存后把缓存处理返回给用户.这里之所以CacheServer是将缓存内容返回而不是ReadTimeServer的返回返回,因为我们的系统很复杂,我不得不在Cache端也对Ruby返回的数据进行了一定的处理,这个我后面有详细讲到.
交易部分处理仍然由原来的Ruby来直接处理.
这样做的好处.go分担了高性能要求的粗重活,而Ruby分担了复杂的工作.如果有修改,还是只需要修改Ruby就足够了.这是我很满意的设计.
将交易分离,提高可靠性
在压力环境中,有一部分请求是要被抛弃的.在压力测试中,90%的可靠性,就算通过了.但交易性的接口显然不能是这样的.它必须是100%可靠的.
为了保证金额交易部分的可靠性,大访问量的接口与敏感交易接口分开是一个很好的办法,并且让交易接口使用线程安全模式.确保每次访问在代码正确的情况下不会因为线性安全的问题而出现问题.请求从nginx前端就分离,如果有条件,最好分配不同的机器和网络接口.
更细粒度的缓存
企业级应用,与资讯网站的不同是每个接口对不同的用户,甚至相同用户的不同时间进行访问,都需要有不同的结果.
一个用户有一个结果,如果只缓存最终的结果,就相当于不缓存.而不同时刻不同结果,这样的接口对缓存带来了很多的麻烦.
比如我们有一个影院列表的接口,他返回用户所在城市,有排期的(城市加或不加影片)影院列表,列表附上用户是否去过的标志和对用户的距离,列表按距离排序.
这样不同的城市人来访问有不同的列表,不同时间去过没去的标志不同,而不同坐标有不同距离,更变态的,是还需要对此进行排序.这样的接口几乎是不能够进行缓存的.
我只能对他进行拆分,细分缓存的粒度.
首先来看流程:
1. 根据城市(+影片)代码取出有排期的影院
2. 根据用户号码查看用户去过哪些影院
3. 根据用户定位信息给出影院的距离
这是糟糕的设计,但必须兼容.这就是要求用户每次请求都有不同的结果.根本不能缓存.但让我们试试拆分一下.
首先,一个城市的影院是固定的,问题只是其下只否有排期或指定的影片是否有排期.那么缓存一个城市(+影片)的影院列表是可行的,这样不同用户但同一个城市看同一个电影,可以从同样缓存中取出.这样的场景在这个应用用是非常大的,看电影人大多的是在北上广,而同档期里火爆的电影也就那么几部,这样这个缓存就非常有意义了.
再次,用户去过的影院,这个其实变化得很少,这个数据是用户购票成功过的影院,这个功能最后被我直接变成一个固定的放在redis里的kv值,这个表会记录一个用户去过的影院的ids,定时会更新新生成的订单的用户的记录.想知道用户去过哪个影院,直接从这个redis里取出就可以.这样的修改甚至可以让我们的产品线的产品可以通用这个信息.
有了上面的缓存,下面”缓存”接口要做的,只是计算下影院的距离,这样,接口避开了与pg数据库的连接,能快速地响应用户.而这些,我可以直接在go cache接口完成.
缓存的内容,还包括一些select的结果集,由于很多的缓存机制都是对id=的单体结果,所以我自己写了一个集合的缓存gem:https://github.com/azhao1981/kv_cache
交换机的问题
在压力环境下,交换机也是很容易出问题的部分.
我们有五台机器,两台应用,一台数据库,一个测试机,一个交换机.
做活动的时候,我们发现了大量请求发不出去,包括rails c连接到数据库查看,但是查看系统连接数,却只是3w多个连接,并没有达到系统连接数的上限(5w+),数据库的连接数也没有达到上限.
这是路由器问题的表象,我们最先使用单个交换机,用虚拟网段分内网和外网.出问题后,我们通过借用机房外出接口,发现性能上去一些,这意味着交换机是一个瓶颈.
我猜想问题是这样产生的:
首先,交换机是物理单体的,无论他分成多少网段,总的吞吐量都是一个定值.
其次,交换机使用虚拟网段进行连接,在高压力环境下,可能会容易形成死循环.想象下,一个外部的连接请求,需要差不多数5/6次的内部请求来完成,外部的连接要保持并等待内部的连接交互的完成.而只要内部的连接有一个出现瓶颈,外部的连接就不能关闭,那么就很容易出现等待的死循环.
然后是路由器防火墙,在高压力环境下,默认的路由器防火墙会把请求当成洪水攻击处理掉.这个是在一定量后可靠性上不去后我们才发现的.
系统的调优
服务器有很多的参数,比如ulimit的文件最大数量,TIME_WAIT时间等,都是性能产生问题的原因.这个就得请专业的运维人员来处理了.
这里要注意一下nginx如果有http转发,就要设置一下http/1.1的头,不然这些TIME_WAIT的连接并不能很好的得用.影响很大.一些应用中的http调用连接,最好也要检查一下,默认是不是有http/1.1的报头,如果没有,需要人工指定.
读写分离
我这里的读写分离不是双机热备的那种读写分离,我们的系统原来是没有双机热备的.我这里只是把只要读取的接口的数据分离到另一个库里.相当于一个软读写分离.
我要做的很简单,使用原来的go cacheServer做为读取的应用,优化一个redis配置专门用于cache.定时将pg库里的信息写到这个redis中.cacheServer由原来不能处理的丢还给RealtimeServer变成直接返回错误信息.而定时任务来保证cacheServer能获取所有的应取得的东西.
这就相当于把读数据分离到redis中,应用也由高性能的go来处理.而写的部分仍然由原来的pg和ruby来完成.
这样的修改很轻巧,几乎可以无缝的进行.接口也可以一个个的进行修改,下次活动来到的时候,有的接口没有完成,也是可以接受的.这对于我的时间约束来说是一个好的消息.
到此,读写分离最终完成了.系统的性能得到了很大的提高.特别是读取信息部分,系统处理的功能远远大于带宽的供给,而处理这部分功能的产用的资源很小,高峰期也只占到不到几百M而以.
横向扩展RealtimeServer
上面都是对cache层面的优化,核心就是减少响应时间.但是RealtimeServer就不能这么干了.
RealTimeServer剩下的接口本身是一个多方交互的过程,又出于可靠的需求,使用进程安全的模式.在抢票的时候,内存和cpu都在向上飙升.剩下的解决方案就是横向扩展了,一台能开400个进程,那两台就是800,在需要的时候利用空闲的资源顶上.
更多的优化
优化是一个可持续的工作, 我们的旧版本因为要兼容老版本的关系,只能用各种拆分的方法来进行改进.但在新的版本中,我采用了服务器端尽量简单的原则.
一个互联网项目的发展,一开始可能因为希望可控性更强,把更多的功能放在服务器端,这样更新就不需要等待客户端的升级,可直接修改服务器就可以了,但是一但发展到服务器端要承受压力的时候,那么就必须考虑把更多的功能放到客户端了,就比如我上面提到的影院列表,距离之类的,其实用户端很容易的进行计算,而用影院坐标代替距离,可以让服务器非常简单,缓存起来也很容易.而且现在的手机端已经拥有很强大的能力,不仅可以给你距离,还可以给出方向,这些都是服务端不能做到的.
从设计让让服务端更加简单,这是从根本上解决的办法.
AWS云
AWS在其实在早些年已经进入了中国,但不知道为什么,现在都没有正式的运营.但现在公司好像已经可以申请账号.
云的优势不用置疑.AWS超强的性能.弹性的带宽等等,都是不是我们小公司自己罢几个服务器可以比拟的.如果我们一早就是使用AWS,那上面的目标估计不用做都可以实现了.只可惜,在准备迁移前我已经因病离开公司.无缘这个迁移优化部分了.
总结
系统性能的提升,是一个长期而艰难的过程.在有实际业务压力的情况下,每一步的修改都要小心翼翼.而每步的改进又必须经受实际压力的检验.不夸夸其谈高大上的集群或云,而是一小步一小步的实实在在提升自己系统的性能.也是自身能力的提升.