在服务器端程序开发领域,性能问题一直是备受关注的重点。业界有大量的框架、组件、类库都是以性能为卖点而广为人知。然而,服务器端程序在性能问题上应该有何种基本思路,这个却不多被这些项目的文档说起。本文正式但愿介绍服务器端解决性能问题的基本策略和经典实践,并分为几个部分来讲明:java
1. 缓存策略的概念和实例nginx
2.缓存策略的难点:不一样特色的缓存数据的清理机制程序员
3.分布策略的概念和实例算法
4.分布策略的难点:共享数据安全性与代码复杂度的平衡数据库
缓存 缓存策略的概念apache
咱们提到服务器端性能问题的时候,每每会混淆不清。由于当咱们访问一个服务器时,出现服务卡住不能获得数据,就会认为是“性能问题”。可是实际上这个性能问题多是有不一样的缘由,表现出来都是针对客户请求的延迟很长甚至中断。咱们来看看这些缘由有哪些:第一个是所谓并发数不足,也就是同时请求的客户过多,致使超过容纳能力的客户被拒绝服务,这种状况每每会由于服务器内存耗尽而致使的;第二个是处理延迟过长,也就是有一些客户的请求处理时间已经超过用户能够忍受的长度,这种状况经常表现为CPU占用满额100%。编程
咱们在服务器开发的时候,最经常使用到的有下面这几种硬件:CPU、内存、磁盘、网卡。其中CPU是表明计算机处理时间的,硬盘的空间通常很大,主要是读写磁盘会带来比较大的处理延迟,而内存、网卡则是受存储、带宽的容量限制的。因此当咱们的服务器出现性能问题的时候,就是这几个硬件某一个甚至几个都出现负荷占满的状况。这四个硬件的资源通常能够抽象成两类:一类是时间资源,好比CPU和磁盘读写;一类是空间资源,好比内存和网卡带宽。因此当咱们的服务器出现性能问题,有一个最基本的思路,就是——时间空间转换。咱们能够举几个例子来讲明这个问题。promise
[水坝就是用水库空间来换流量时间的例子]浏览器
当咱们访问一个WEB的网站的时候,输入的URL地址会被服务器变成对磁盘上某个文件的读取。若是有大量的用户访问这个网站,每次的请求都会形成对磁盘的读操做,可能会让磁盘不堪重负,致使没法即时读取到文件内容。可是若是咱们写的程序,会把读取过一次的文件内容,长时间的保存在内存中,当有另一个对一样文件的读取时,就直接从内存中把数据返回给客户端,就无需去让磁盘读取了。因为用户访问的文件每每很集中,因此大量的请求可能都能从内存中找到保存的副本,这样就能大大提升服务器能承载的访问量了。这种作法,就是用内存的空间,换取了磁盘的读写时间,属于用空间换时间的策略。缓存
[方便面预先缓存了大量的烹饪操做]
举另一个例子:咱们写一个网络游戏的服务器端程序,经过读写数据库来提供玩家资料存档。若是有大量玩家进入这个服务器,一定有不少玩家的数据资料变化,好比升级、得到武器等等,这些经过读写数据库来实现的操做,可能会让数据库进程负荷太重,致使玩家没法即时完成游戏操做。咱们会发现游戏中的读操做,大部分都是针是对一些静态数据的,好比游戏中的关卡数据、武器道具的具体信息;而不少写操做,其实是会覆盖的,好比个人经验值,可能每打一个怪都会增长几十点,可是最后记录的只是最终的一个经验值,而不会记录下打怪的每一个过程。因此咱们也可使用时空转换的策略来提供性能:咱们能够用内存,把那些游戏中的静态数据,都一次性读取并保存起来,这样每次读这些数据,都和数据库无关了;而玩家的资料数据,则不是每次变化都去写数据库,而是先在内存中保持一个玩家数据的副本,全部的写操做都先去写内存中的结构,而后按期再由服务器主动写回到数据库中,这样能够把屡次的写数据库操做变成一次写操做,也能节省不少写数据库的消耗。这种作法也是用空间换时间的策略。
[拼装家具很省运输空间,可是安装很费时]
最后说说用时间换空间的例子:假设咱们要开发一个企业通信录的数据存储系统,客户要求咱们能保存下通信录的每次新增、修改、删除操做,也就是这个数据的全部变动历史,以即可以让数据回退到任何一个过去的时间点。那么咱们最简单的作法,就是这个数据在任何变化的时候,都拷贝一份副本。可是这样会很是的浪费磁盘空间,由于这个数据自己变化的部分可能只有很小一部分,可是要拷贝的副本可能很大。这种状况下,咱们就能够在每次数据变化的时候,都记下一条记录,内容就是数据变化的状况:插入了一条内容是某某的联系方法、删除了一条某某的联系方法……,这样咱们记录的数据,仅仅就是变化的部分,而不须要拷贝不少份副本。当咱们须要恢复到任何一个时间点的时候,只须要按这些记录依次对数据修改一遍,直到指定的时间点的记录便可。这个恢复的时间可能会有点长,可是却能够大大节省存储空间。这就是用CPU的时间来换磁盘的存储空间的策略。咱们如今常见的MySQL InnoDB日志型数据表,以及SVN源代码存储,都是使用这种策略的。
另外,咱们的Web服务器,在发送HTML文件内容的时候,每每也会先用ZIP压缩,而后发送给浏览器,浏览器收到后要先解压,而后才能显示,这个也是用服务器和客户端的CPU时间,来换取网络带宽的空间。
在咱们的计算机体系中,缓存的思路几乎无处不在,好比咱们的CPU里面就有1级缓存、2级缓存,他们就是为了用这些快速的存储空间,换取对内存这种相对比较慢的存储空间的等待时间。咱们的显示卡里面也带有大容量的缓存,他们是用来存储显示图形的运算结果的。
[通往大空间的郊区路上容易交通堵塞]
缓存的本质,除了让“已经处理过的数据,不须要重复处理”之外,还有“以快速的数据存储读写,代替较慢速的存储读写”的策略。咱们在选择缓存策略进行时空转换的时候,必须明确咱们要转换的时间和空间是否合理,是否能达到效果。好比早期有一些人会把WEB文件缓存在分布式磁盘上(例如NFS),可是因为经过网络访问磁盘自己就是一个比较慢的操做,并且还会占用可能就不充裕的网络带宽空间,致使性能可能变得更慢。
在设计缓存机制的时候,咱们还容易碰到另一个风险,就是对缓存数据的编程处理问题。若是咱们要缓存的数据,并非彻底无需处理直接读写的,而是须要读入内存后,以某种语言的结构体或者对象来处理的,这就须要涉及到“序列化”和“反序列化”的问题。若是咱们采用直接拷贝内存的方式来缓存数据,当咱们的这些数据须要跨进程、甚至跨语言访问的时候,会出现那些指针、ID、句柄数据的失效。由于在另一个进程空间里,这些“标记型”的数据都是不存在的。所以咱们须要更深刻的对数据缓存的方法,咱们可能会使用所谓深拷贝的方案,也就是跟着那些指针去找出目标内存的数据,一并拷贝。一些更现代的作法,则是使用所谓序列化方案来解决这个问题,也就是用一些明肯定义了的“拷贝方法”来定义一个结构体,而后用户就能明确的知道这个数据会被拷贝,直接取消了指针之类的内存地址数据的存在。好比著名的Protocol Buffer就能很方便的进行内存、磁盘、网络位置的缓存;如今咱们常见的JSON,也被一些系统用来做为缓存的数据格式。
可是咱们须要注意的是,缓存的数据和咱们程序真正要操做的数据,每每是须要进行一些拷贝和运算的,这就是序列化和反序列化的过程,这个过程很快,也有可能很慢。因此咱们在选择数据缓存结构的时候,必需要注意其转换时间,不然你缓存的效果可能被这些数据拷贝、转换消耗去不少,严重的甚至比不缓存更差。通常来讲,缓存的数据越解决使用时的内存结构,其转换速度就越快,在这点上,Protocol Buffer采用TLV编码,就比不上直接memcpy的一个C结构体,可是比编码成纯文本的XML或者JSON要来的更快。由于编解码的过程每每要进行复杂的查表映射,列表结构等操做。
缓存策略的难点
虽然使用缓存思想彷佛是一个很简单的事情,可是缓存机制却有一个核心的难点,就是——缓存清理。咱们所说的缓存,都是保存一些数据,可是这些数据每每是会变化的,咱们要针对这些变化,清理掉保存的“脏”数据,却可能不是那么容易。
首先咱们来看看最简单的缓存数据——静态数据。这种数据每每在程序的运行时是不会变化的,好比Web服务器内存中缓存的HTML文件数据,就是这种。事实上,全部的不是由外部用户上传的数据,都属于这种“运行时静态数据”。通常来讲,咱们对这种数据,能够采用两种创建缓存的方法:一是程序一启动,就一股脑把全部的静态数据从文件或者数据库读入内存;二就是程序启动的时候并不加载静态数据,而是等有用户访问相关数据的时候,才去加载,这也就是所谓lazy load的作法。第一种方法编程比较简单,程序的内存启动后就稳定了,不太容易出现内存漏洞(若是加载的缓存太多,程序在启动后马上会因内存不足而退出,比较容易发现问题);第二种方法程序启动很快,但要对缓存占用的空间有所限制或者规划,不然若是要缓存的数据太多,可能会耗尽内存,致使在线服务中断。
通常来讲,静态数据是不会“脏”的,由于没有用户会去写缓存中的数据。可是在实际工做中,咱们的在线服务每每会须要“马上”变动一些缓存数据。好比在门户网站上发布了一条新闻,咱们会但愿马上让全部访问的用户都看到。按最简单的作法,咱们通常只要重启一下服务器进程,内存中的缓存就会消失了。对于静态缓存的变化频率很是低的业务,这样是能够的,可是若是是新闻网站,就不能每隔几分钟就重启一下WEB服务器进程,这样会影响大量在线用户的访问。常见的解决这类问题有两种处理策略:
第一种是使用控制命令。简单来讲,就是在服务器进程上,开通一个实时的命令端口,咱们能够经过网络数据包(如UDP包),或者Linux系统信号(如kill SIGUSR2进程号)之类的手段,发送一个命令消息给服务器进程,让进程开始清理缓存。这种清理可能执行的是最简单的“所有清理”,也有的能够细致一点的,让命令消息中带有“想清理的数据ID”这样的信息,好比咱们发送给WEB服务器的清理消息网络包中会带一个字符串URL,表示要清理哪个HTML文件的缓存。这种作法的好处是清理的操做很精准,能够明确的控制清理的时间和数据。可是缺点就是比较繁琐,手工去编写发送这种命令很烦人,因此通常咱们会把清理缓存命令的工做,编写到上传静态数据的工具当中,好比结合到网站的内容发布系统中,一旦编辑提交了一篇新的新闻,发布系统的程序就自动的发送一个清理消息给WEB服务器。
第二种是使用字段判断逻辑。也就是服务器进程,会在每次读取缓存前,根据一些特征数据,快速的判断内存中的缓存和源数据内容,是否有不一致(是否脏)的地方,若是有不一致的地方,就自动清理这条数据的缓存。这种作法会消耗一部分CPU,可是就不须要人工去处理清理缓存的事情,自动化程度很高。如今咱们的浏览器和WEB服务器之间,就有用这种机制:检查文件MD5;或者检查文件最后更新时间。具体的作法,就是每次浏览器发起对WEB服务器的请求时,除了发送URL给服务器外,还会发送一个缓存了此URL对应的文件内容的MD5校验串、或者是此文件在服务器上的“最后更新时间”(这个校验串和“最后更新时间”是第一次获的文件时一并从服务器得到的);服务器收到以后,就会把MD5校验串或者最后更新时间,和磁盘上的目标文件进行对比,若是是一致的,说明这个文件没有被修改过(缓存不是“脏”的),能够直接使用缓存。不然就会读取目标文件返回新的内容给浏览器。这种作法对于服务器性能是有必定消耗的,因此若是每每咱们还会搭配其余的缓存清理机制来用,好比咱们会在设置一个“超时检查”的机制:就是对于全部的缓存清理检查,咱们都简单的看看缓存存在的时间是否“超时”了,若是超过了,才进行下一步的检查,这样就不用每次请求都去算MD5或者看最后更新时间了。可是这样就存在“超时”时间内缓存变脏的可能性。
[WEB服务器静态缓存例子]
上面说了运行时静态的缓存清理,如今说说运行时变化的缓存数据。在服务器程序运行期间,若是用户和服务器之间的交互,致使了缓存的数据产生了变化,就是所谓“运行时变化缓存”。好比咱们玩网络游戏,登陆以后的角色数据就会从数据库里读出来,进入服务器的缓存(多是堆内存或者memcached、共享内存),在咱们不断进行游戏操做的时候,对应的角色数据就会产生修改的操做,这种缓存数据就是“运行时变化的缓存”。这种运行时变化的数据,有读和写两个方面的清理问题:因为缓存的数据会变化,若是另一个进程从数据库读你的角色数据,就会发现和当前游戏里的数据不一致;若是服务器进程忽然结束了,你在游戏里升级,或者捡道具的数据可能会从内存缓存中消失,致使你白忙活了半天,这就是没有回写(缓存写操做的清理)致使的问题。这种状况在电子商务领域也很常见,最典型的就是火车票网上购买的系统,火车票数据缓存在内存必须有合适的清理机制,不然让两个买了同一张票就麻烦了,但若是不缓存,大量用户同时抢票,服务器也应对不过来。所以在运行时变化的数据缓存,应该有一些特别的缓存清理策略。
在实际运行业务中,运行变化的数据每每是根据使用用户的增多而增多的,所以首先要考虑的问题,就是缓存空间不够的可能性。咱们不太可能把所有数据都放到缓存的空间里,也不可能清理缓存的时候就所有数据一块儿清理,因此咱们通常要对数据进行分割,这种分割的策略常见的有两种:一种是按重要级来分割,一种是按使用部分分割。
先举例说说“按重要级分割”,在网络游戏中,一样是角色的数据,有些数据的变化可能会每次修改都马上回写到数据库(清理写缓存),其余一些数据的变化会延迟一段时间,甚至有些数据直到角色退出游戏才回写,如玩家的等级变化(升级了),武器装备的得到和消耗,这些玩家很是看重的数据,基本上会马上回写,这些就是所谓最重要的缓存数据。而玩家的经验值变化、当前HP、MP的变化,就会延迟一段时间才写,由于就算丢失了缓存,玩家也不会太过关注。最后有些好比玩家在房间(地区)里的X/Y坐标,对话聊天的记录,可能会退出时回写,甚至不回写。这个例子说的是“写缓存”的清理,下面说说“读缓存”的按重要级分割清理。
假如咱们写一个网店系统,里面容纳了不少产品,这些产品有一些会被用户频繁检索到,比较热销,而另一些商品则没那么热销。热销的商品的余额、销量、评价都会比较频繁的变化,而滞销的商品则变化不多。因此咱们在设计的时候,就应该按照不一样商品的访问频繁程度,来决定缓存哪些商品的数据。咱们在设计缓存的结构时,就应该构建一个能够统计缓存读写次数的指标,若是有些数据的读写频率太低,或者空闲(没有人读、写缓存)时间超长,缓存应该主动清理掉这些数据,以便其余新的数据能进入缓存。这种策略也叫作“冷热交换”策略。实现“冷热交换”的策略时,关键是要定义一个合理的冷热统计算法。一些固定的指标和算法,每每并不能很好的应对不一样硬件、不一样网络状况下的变化,因此如今人们广泛会用一些动态的算法,如Redis就采用了5种,他们是:
1.根据过时时间,清理最长时间没用过的
2.根据过时时间,清理即将过时的
3.根据过时时间,任意清理一个
4.不管是否过时,随机清理
5.不管是否过时,根据LRU原则清理:所谓LRU,就是Least Recently Used,最近最久未使用过。这个原则的思想是:若是一个数据在最近一段时间没有被访问到,那么在未来他被访问的可能性也很小。LRU是在操做系统中很常见的一种原则,好比内存的页面置换算法(也包括FIFO,LFU等),对于LRU的实现,仍是很是有技巧的,可是本文就不详细去说明如何实现,留待你们上网搜索“LRU”关键字学习。
数据缓存的清理策略其实远不止上面所说的这些,要用好缓存这个武器,就要仔细研究须要缓存的数据特征,他们的读写分布,数据之中的差异。而后最大化的利用业务领域的知识,来设计最合理的缓存清理策略。这个世界上不存在万能的优化缓存清理策略,只存在针对业务领域最优化的策略,这须要咱们程序员深刻理解业务领域,去发现数据背后的规律。
分布 分布策略的概念
任何的服务器的性能都是有极限的,面对海量的互联网访问需求,是不可能单靠一台服务器或者一个CPU来承担的。因此咱们通常都会在运行时架构设计之初,就考虑如何能利用多个CPU、多台服务器来分担负载,这就是所谓分布的策略。分布式的服务器概念很简单,可是实现起来却比较复杂。由于咱们写的程序,每每都是以一个CPU,一块内存为基础来设计的,因此要让多个程序同时运行,而且协调运做,这须要更多的底层工做。
首先出现能支持分布式概念的技术是多进程。在DOS时代,计算机在一个时间内只能运行一个程序,若是你想一边写程序,同时一边听mp3,都是不可能的。可是,在WIN95操做系统下,你就能够同时开多个窗口,背后就是同时在运行多个程序。在Unix和后来的Linux操做系统里面,都广泛支持了多进程的技术。所谓的多进程,就是操做系统能够同时运行咱们编写的多个程序,每一个程序运行的时候,都好像本身独占着CPU和内存同样。在计算机只有一个CPU的时候,实际上计算机会分时复用的运行多个进程,CPU在多个进程之间切换。可是若是这个计算机有多个CPU或者多个CPU核,则会真正的有几个进程同时运行。因此进程就好像一个操做系统提供的运行时“程序盒子”,能够用来在运行时,容纳任何咱们想运行的程序。当咱们掌握了操做系统的多进程技术后,咱们就能够把服务器上的运行任务,分为多个部分,而后分别写到不一样的程序里,利用上多CPU或者多核,甚至是多个服务器的CPU一块儿来承担负载。
[多进程利用多CPU]
这种划分多个进程的架构,通常会有两种策略:一种是按功能来划分,好比负责网络处理的一个进程,负责数据库处理的一个进程,负责计算某个业务逻辑的一个进程。另一种策略是每一个进程都是一样的功能,只是分担不一样的运算任务而已。使用第一种策略的系统,运行的时候,直接根据操做系统提供的诊断工具,就能直观的监测到每一个功能模块的性能消耗,由于操做系统提供进程盒子的同时,也能提供对进程的全方位的监测,好比CPU占用、内存消耗、磁盘和网络I/O等等。可是这种策略的运维部署会稍微复杂一点,由于任何一个进程没有启动,或者和其余进程的通讯地址没配置好,均可能致使整个系统没法运做;而第二种分布策略,因为每一个进程都是同样的,这样的安装部署就很是简单,性能不够就多找几个机器,多启动几个进程就完成了,这就是所谓的平行扩展。
如今比较复杂的分布式系统,会结合这两种策略,也就是说系统既按一些功能划分出不一样的具体功能进程,而这些进程又是能够平行扩展的。固然这样的系统在开发和运维上的复杂度,都是比单独使用“按功能划分”和“平行划分”要更高的。因为要管理大量的进程,传统的依靠配置文件来配置整个集群的作法,会显得愈来愈不实用:这些运行中的进程,可能和其余不少进程产生通讯关系,当其中一个进程变动通讯地址时,势必影响全部其余进程的配置。因此咱们须要集中的管理全部进程的通讯地址,当有变化的时候,只须要修改一个地方。在大量进程构建的集群中,咱们还会碰到容灾和扩容的问题:当集群中某个服务器出现故障,可能会有一些进程消失;而当咱们须要增长集群的承载能力时,咱们又须要增长新的服务器以及进程。这些工做在长期运行的服务器系统中,会是比较常见的任务,若是整个分布系统有一个运行中的中心进程,能自动化的监测全部的进程状态,一旦有进程加入或者退出集群,都能即时的修改全部其余进程的配置,这就造成了一套动态的多进程管理系统。开源的ZooKeeper给咱们提供了一个能够充当这种动态集群中心的实现方案。因为ZooKeeper自己是能够平行扩展的,因此它本身也是具有必定容灾能力的。如今愈来愈多的分布式系统都开始使用以ZooKeeper为集群中心的动态进程管理策略了。
[动态进程集群]
在调用多进程服务的策略上,咱们也会有必定的策略选择,其中最著名的策略有三个:一个是动态负载均衡策略;一个是读写分离策略;一个是一致性哈希策略。动态负载均衡策略,通常会搜集多个进程的服务状态,而后挑选一个负载最轻的进程来分发服务,这种策略对于比较同质化的进程是比较合适的。读写分离策略则是关注对持久化数据的性能,好比对数据库的操做,咱们会提供一批进程专门用于提供读数据的服务,而另一个(或多个)进程用于写数据的服务,这些写数据的进程都会每次写多份拷贝到“读服务进程”的数据区(可能就是单独的数据库),这样在对外提供服务的时候,就能够提供更多的硬件资源。一致性哈希策略是针对任何一个任务,看看这个任务所涉及读写的数据,是属于哪一片的,是否有某种能够缓存的特征,而后按这个数据的ID或者特征值,进行“一致性哈希”的计算,分担给对应的处理进程。这种进程调用策略,能很是的利用上进程内的缓存(若是存在),好比咱们的一个在线游戏,由100个进程承担服务,那么咱们就能够把游戏玩家的ID,做为一致性哈希的数据ID,做为进程调用的KEY,若是目标服务进程有缓存游戏玩家的数据,那么全部这个玩家的操做请求,都会被转到这个目标服务进程上,缓存的命中率大大提升。而使用“一致性哈希”,而不是其余哈希算法,或者取模算法,主要是考虑到,若是服务进程有一部分因故障消失,剩下的服务进程的缓存依然能够有效,而不会整个集群全部进程的缓存都失效。具体有兴趣的读者能够搜索“一致性哈希”一探究竟。
以多进程利用大量的服务器,以及服务器上的多个CPU核心,是一个很是有效的手段。可是使用多进程带来的额外的编程复杂度的问题。通常来讲咱们认为最好是每一个CPU核心一个进程,这样能最好的利用硬件。若是同时运行的进程过多,操做系统会消耗不少CPU时间在不一样进程的切换过程上。可是,咱们早期所得到的不少API都是阻塞的,好比文件I/O,网络读写,数据库操做等。若是咱们只用有限的进程来执行带这些阻塞操做的程序,那么CPU会大量被浪费,由于阻塞的API会让有限的这些进程停着等待结果。那么,若是咱们但愿能处理更多的任务,就必需要启动更多的进程,以便充分利用那些阻塞的时间,可是因为进程是操做系统提供的“盒子”,这个盒子比较大,切换耗费的时间也比较多,因此大量并行的进程反而会无谓的消耗服务器资源。加上进程之间的内存通常是隔离的,进程间若是要交换一些数据,每每须要使用一些操做系统提供的工具,好比网络socket,这些都会额外消耗服务器性能。所以,咱们须要一种切换代价更少,通讯方式更便捷,编程方法更简单的并行技术,这个时候,多线程技术出现了。
[在进程盒子里面的线程盒子]
多线程的特色是切换代价少,能够同时访问内存。咱们能够在编程的时候,任意让某个函数放入新的线程去执行,这个函数的参数能够是任何的变量或指针。若是咱们但愿和这些运行时的线程通讯,只要读、写这些指针指向的变量便可。在须要大量阻塞操做的时候,咱们能够启动大量的线程,这样就能较好的利用CPU的空闲时间;线程的切换代价比进程低得多,因此咱们能利用的CPU也会多不少。线程是一个比进程更小的“程序盒子”,他能够放入某一个函数调用,而不是一个完整的程序。通常来讲,若是多个线程只是在一个进程里面运行,那实际上是没有利用到多核CPU的并行好处的,仅仅是利用了单个空闲的CPU核心。可是,在JAVA和C#这类带虚拟机的语言中,多线程的实现底层,会根据具体的操做系统的任务调度单位(好比进程),尽可能让线程也成为操做系统能够调度的单位,从而利用上多个CPU核心。好比Linux2.6以后,提供了NPTL的内核线程模型,JVM就提供了JAVA线程到NPTL内核线程的映射,从而利用上多核CPU。而Windows系统中,听说自己线程就是系统的最小调度单位,因此多线程也是利用上多核CPU的。因此咱们在使用JAVA\C#编程的时候,多线程每每已经同时具有了多进程利用多核CPU、以及切换开销低的两个好处。
早期的一些网络聊天室服务,结合了多线程和多进程使用的例子。一开始程序会启动多个广播聊天的进程,每一个进程都表明一个房间;每一个用户链接到聊天室,就为他启动一个线程,这个线程会阻塞的读取用户的输入流。这种模型在使用阻塞API的环境下,很是简单,但也很是有效。
当咱们在普遍使用多线程的时候,咱们发现,尽管多线程有不少优势,可是依然会有明显的两个缺点:一个内存占用比较大且不太可控;第二个是多个线程对于用一个数据使用时,须要考虑复杂的“锁”问题。因为多线程是基于对一个函数调用的并行运行,这个函数里面可能会调用不少个子函数,每调用一层子函数,就会要在栈上占用新的内存,大量线程同时在运行的时候,就会同时存在大量的栈,这些栈加在一块儿,可能会造成很大的内存占用。而且,咱们编写服务器端程序,每每但愿资源占用尽可能可控,而不是动态变化太大,由于你不知道何时会由于内存用完而当机,在多线程的程序中,因为程序运行的内容致使栈的伸缩幅度可能很大,有可能超出咱们预期的内存占用,致使服务的故障。而对于内存的“锁”问题,一直是多线程中复杂的课题,不少多线程工具库,都推出了大量的“无锁”容器,或者“线程安全”的容器,而且还大量设计了不少协调线程运做的类库。可是这些复杂的工具,无疑都是证实了多线程对于内存使用上的问题。
[同时排多条队就是并行]
因为多线程仍是有必定的缺点,因此不少程序员想到了一个釜底抽薪的方法:使用多线程每每是由于阻塞式API的存在,好比一个read()操做会一直中止当前线程,那么咱们能不能让这些操做变成不阻塞呢?——selector/epoll就是Linux退出的非阻塞式API。若是咱们使用了非阻塞的操做函数,那么咱们也无需用多线程来并发的等待阻塞结果。咱们只须要用一个线程,循环的检查操做的状态,若是有结果就处理,无结果就继续循环。这种程序的结果每每会有一个大的死循环,称为主循环。在主循环体内,程序员能够安排每一个操做事件、每一个逻辑状态的处理逻辑。这样CPU既无需在多线程间切换,也无需处理复杂的并行数据锁的问题——由于只有一个线程在运行。这种就是被称为“并发”的方案。
[服务员兼了点菜、上菜就是并发]
实际上计算机底层早就有使用并发的策略,咱们知道计算机对于外部设备(好比磁盘、网卡、显卡、声卡、键盘、鼠标),都使用了一种叫“中断”的技术,早期的电脑使用者可能还被要求配置IRQ号。这个中断技术的特色,就是CPU不会阻塞的一直停在等待外部设备数据的状态,而是外部数据准备好后,给CPU发一个“中断信号”,让CPU转去处理这些数据。非阻塞的编程实际上也是相似这种行为,CPU不会一直阻塞的等待某些I/O的API调用,而是先处理其余逻辑,而后每次主循环去主动检查一下这些I/O操做的状态。
多线程和异步的例子,最著名就是Web服务器领域的Apache和Nginx的模型。Apache是多进程/多线程模型的,它会在启动的时候启动一批进程,做为进程池,当用户请求到来的时候,从进程池中分配处理进程给具体的用户请求,这样能够节省多进程/线程的建立和销毁开销,可是若是同时有大量的请求过来,仍是须要消耗比较高的进程/线程切换。而Nginx则是采用epoll技术,这种非阻塞的作法,可让一个进程同时处理大量的并发请求,而无需反复切换。对于大量的用户访问场景下,apache会存在大量的进程,而nginx则能够仅用有限的进程(好比按CPU核心数来启动),这样就会比apache节省了很多“进程切换”的消耗,因此其并发性能会更好。
[Nginx的固定多进程,一个进程异步处理多个客户端]
[Apache的多态多进程,一个进程处理一个客户]
在现代服务器端软件中,nginx这种模型的运维管理会更简单,性能消耗也会稍微更小一点,因此成为最流行的进程架构。可是这种好处,会付出一些另外的代价:非阻塞代码在编程的复杂度变大。
分布式编程复杂度
之前咱们的代码,从上往下执行,每一行都会占用必定的CPU时间,这些代码的直接顺序,也是和编写的顺序基本一致,任何一行代码,都是惟一时刻的执行任务。当咱们在编写分布式程序的时候,咱们的代码将再也不好像那些单进程、单线程的程序同样简单。咱们要把同时运行的不一样代码,在同一段代码中编写。就好像咱们要把整个交响乐团的每一个乐器的曲谱,所有写到一张纸上。为了解决这种编程的复杂度,业界发展出了多种编码形式。
在多进程的编码模型上,fork()函数能够说一个很是典型的表明。在一段代码中,fork()调用以后的部分,可能会被新的进程中执行。要区分当前代码的所在进程,要靠fork()的返回值变量。这种作法,等于把多个进程的代码都合并到一块,而后经过某些变量做为标志来划分。这样的写法,对于不一样进程代码大部份相同的“同质进程”来讲,仍是比较方便的,最怕就是有大量的不一样逻辑要用不一样的进程来处理,这种状况下,咱们就只能本身经过规范fork()附近的代码,来控制混乱的局面。比较典型的是把fork()附近的代码弄成一个相似分发器(dispatcher)的形式,把不一样功能的代码放到不一样的函数中,以fork以前的标记变量来决定如何调用。
[动态多进程的代码模式]
在咱们使用多线程的API时,状况就会好不少,咱们能够用一个函数指针,或者一个带回调方法的对象,做为线程执行的主体,而且以句柄或者对象的形式来控制这些线程。做为开发人员,咱们只要掌握了对线程的启动、中止等有限的几个API,就能很好的对并行的多线程进行控制。这对比多进程的fork()来讲,从代码上看会更直观,只是咱们必需要分清楚调用一个函数,和新建一个线程去调用一个函数,之间的差异:新建线程去调用函数,这个操做会很快的结束,并不会依序去执行那个函数,而是表明着,那个函数中的代码,可能和线程调用以后的代码,交替的执行。
因为多线程把“并行的任务”做为一个明确的编程概念定义了出来,以句柄、对象的形式封装好,那么咱们天然会但愿对多线程能更多复杂而细致的控制。所以出现了不少多线程相关的工具。比较典型的编程工具备线程池、线程安全容器、锁这三类。线程池提供给咱们以“池”的形态,自动管理线程的能力:咱们不须要本身去考虑怎么创建线程、回收线程,而是给线程池一个策略,而后输入须要执行的任务函数,线程池就会自动操做,好比它会维持一个同时运行线程数量,或者保持必定的空闲线程以节省建立、销毁线程的消耗。在多线程操做中,不像多进程在内存上彻底是区分开的,因此能够访问同一分内存,也就是对堆里面的同一个变量进行读写,这就可能产生程序员所预计不到的状况(由于咱们写程序只考虑代码是顺序执行的)。还有一些对象容器,好比哈希表和队列,若是被多个线程同时操做,可能还会由于内部数据对不上,形成严重的错误,因此不少人开发了一些能够被多个线程同时操做的容器,以及所谓“原子”操做的工具,以解决这样的问题。有些语言如Java,在语法层面,就提供了关键字来对某个变量进行“上锁”,以保障只有一个线程能操做它。多线程的编程中,不少并行任务,是有必定的阻塞顺序的,因此有各类各样的锁被发明出来,好比倒数锁、排队锁等等。java.concurrent库就是多线程工具的一个大集合,很是值得学习。然而,多线程的这些五花八门的武器,其实也是证实了多线程自己,是一种不太容易使用的顺手的技术,可是咱们一会儿尚未更好的替代方案罢了。
[多线程的对象模型]
在多线程的代码下,除了启动线程的地方,是和正常的执行顺序不一样之外,其余的基本都仍是比较近似单线程代码的。可是若是在异步并发的代码下,你会发现,代码必定要装入一个个“回调函数”里。这些回调函数,从代码的组织形态上,几乎彻底没法看出来其预期的执行顺序,通常只能在运行的时候经过断点或者日志来分析。这就对代码阅读带来了极大的障碍。所以如今有愈来愈多的程序员关注“协程”这种技术:能够用相似同步的方法来写异步程序,而无需把代码塞到不一样的回调函数里面。协程技术最大的特色,就是加入了一个叫yield的概念,这个关键字所在的代码行,是一个相似return的做用,可是又表明着后续某个时刻,程序会从yield的地方继续往下执行。这样就把那些须要回调的代码,从函数中得以解放出来,放到yield的后面了。在不少客户端游戏引擎中,咱们写的代码都是由一个框架,以每秒30帧的速度在反复执行,为了让一些任务,能够分别放在各帧中运行,而不是一直阻塞致使“卡帧”,使用协程就是最天然和方便的了——Unity3D就自带了协程的支持。
在多线程同步程序中,咱们的函数调用栈就表明了一系列同属一个线程的处理。可是在单线程的异步回调的编程模式下,咱们的一个回调函数是没法简单的知道,是在处理哪个请求的序列中。因此咱们每每须要本身写代码去维持这样的状态,最多见的作法是,每一个并发任务启动的时候,就产生一个序列号(seqid),而后在全部的对这个并发任务处理的回调函数中,都传入这个seqid参数,这样每一个回调函数,均可以经过这个参数,知道本身在处理哪一个任务。若是有些不一样的回调函数,但愿交换数据,好比A函数的处理结果但愿B函数能获得,还能够用seqid做为key把结果存放到一个公共的哈希表容器中,这样B函数根据传入的seqid就能去哈希表中得到A函数存入的结果了,这样的一份数据咱们每每叫作“会话”。若是咱们使用协程,那么这些会话可能都不须要本身来维持了,由于协程中的栈表明了会话容器,当执行序列切换到某个协程中的时候,栈上的局部变量正是以前的处理过程的内容结果。
[协程的代码特征]
为了解决异步编程的回调这种复杂的操做,业界还发明了不少其余的手段,好比lamda表达式、闭包、promise模型等等,这些都是但愿咱们,能从代码的表面组织上,把在多个不一样时间段上运行的代码,以业务逻辑的形式组织到一块儿。
最后我想说说函数式编程,在多线程的模型下,并行代码带来最大的复杂性,就是对堆内存的同时操做。因此咱们才弄出来锁的机制,以及一大批对付死锁的策略。而函数式编程,因为根本不使用堆内存,因此就无需处理什么锁,反而让整个事情变得很是简单。惟一须要改变的,就是咱们习惯于把状态放到堆里面的编程思路。函数式编程的语言,好比LISP或者Erlang,其核心数据结果是链表——一种能够表示任何数据结构的结构。咱们能够把全部的状态,都放到链表这个数据列车中,而后让一个个函数去处理这串数据,这样一样也能够传递程序的状态。这是一种用栈来代替堆的编程思路,在多线程并发的环境下,很是的有价值。
分布式程序的编写,一直都伴随着大量的复杂性,影响咱们对代码的阅读和维护,因此咱们才有各类各样的技术和概念,试图简化这种复杂性。也许咱们没法找到任何一个通用的解决方案,可是咱们能够经过理解各类方案的目标,来选择最适合咱们的场景:
分布式数据通讯
分布式的编程中,对于CPU时间片的切分自己不是难点,最困难的地方在于并行的多个代码片断,如何进行通讯。由于任何一个代码段,都不可能彻底单独的运做,都须要和其余代码产生必定的依赖。在动态多进程中,咱们每每只能经过父进程的内存提供共享的初始数据,运行中则只能经过操做系统间的通信方式了:Socket、信号、共享内存、管道等等。不管那种作法,这些都带来了一堆复杂的编码。这些方式大部分都相似于文件操做:一个进程写入、另一个进程读出。因此不少人设计了一种叫“消息队列”的模型,提供“放入”消息和“取出”消息的接口,底层则是能够用Socket、共享内存、甚至是文件来实现。这种作法几乎可以处理任何情况下的数据通信,并且有些还能保存消息。可是缺点是每一个通讯消息,都必须通过编码、解码、收包、发包这些过程,对处理延迟有必定的消耗。
若是咱们在多线程中进行通讯,那么咱们能够直接对某个堆里面的变量直接进行读写,这样的性能是最高的,使用也很是方便。可是缺点是可能出现几个线程同时使用变量,产生了不可预期的结果,为了对付这个问题,咱们设计了对变量的“锁”机制,而如何使用锁又成为另一个问题,由于可能出现所谓的“死锁”问题。因此咱们通常会用一些“线程安全”的容器,用来做为多线程间通信的方案。为了协调多个线程之间的执行顺序,还可使用不少种类型的“工具锁”。
在单线程异步并发的状况下,多个会话间的通讯,也是能够经过直接对变量进行读写操做,并且不会出现“锁”的问题,由于本质上每一个时刻都只有一个段代码会操做这个变量。然而,咱们仍是须要对这些变量进行必定规划和整理,不然各类指针或全局变量在代码中散布,也是很出现BUG的。因此咱们通常会把“会话”的概念变成一个数据容器,每段代码均可以把这个会话容器做为一个“收件箱”,其余的并发任务若是须要在这个任务中通信,就把数据放入这个“收件箱”便可。在WEB开发领域,和cookie对应的服务器端Session机制,就是这种概念的典型实现。
分布式缓存策略
在分布式程序架构中,若是咱们须要整个体系有更高的稳定性,可以对进程容灾或者动态扩容提供支持,那么最难解决的问题,就是每一个进程中的内存状态。由于进程一旦毁灭,内存中的状态会消失,这就很难不影响提供的服务。因此咱们须要一种方法,让进程的内存状态,不太影响总体服务,甚至最好能变成“无状态”的服务。固然“状态”若是不写入磁盘,始终仍是须要某些进程来承载的。在如今流行的WEB开发模式中,不少人会使用PHP+Memcached+MySQL这种模型,在这里,PHP就是无状态的,由于状态都是放在Memcached里面。这种作法对于PHP来讲,是能够随时动态的毁灭或者新建,可是Memcached进程就要保证稳定才行;并且Memcached做为一个额外的进程,和它通讯自己也会消耗更多的延迟时间。所以咱们须要一种更灵活和通用的进程状态保存方案,咱们把这种任务叫作“分布式缓存”的策略。咱们但愿进程在读取数据的时候,能有最高的性能,最好能和在堆内存中读写相似,又但愿这些缓存数据,能被放在多个进程内,以分布式的形态提供高吞吐的服务,其中最关键的问题,就是缓存数据的同步。
[PHP经常使用Memached作缓存]
为了解决这个问题,咱们须要先一步步来分解这个问题:
首先,咱们的缓存应该是某种特定形式的对象,而不该该是任意类型的变量。由于咱们须要对这些缓存进行标准化的管理,尽管C++语言提供了运算重载,咱们能够对“=”号的写变量操做进行从新定义,可是如今基本已经没有人推荐去作这样的事。而咱们手头就有最多见的一种模型,适合缓存这种概念的使用,它就是——哈希表。全部的哈希表(或者是Map接口),都是把数据的存放,分为key和value两个部分,咱们能够把想要缓存的数据,做为value存放到“表”当中,同时咱们也能够用key把对应的数据取出来,而“表”对象就表明了缓存。
其次咱们须要让这个“表”能在多个进程中都存在。若是每一个进程中的数据都毫无关联,那问题其实就很是简单,可是若是咱们可能从A进程把数据写入缓存,而后在B进程把数据读取出来,那么就比较复杂了。咱们的“表”要有能把数据在A、B两个进程间同步的能力。所以咱们通常会用三种策略:租约清理、租约转发、修改广播
l 租约清理,通常是指,咱们把存放某个key的缓存的进程,称为持有这个key的数据的“租约”,这个租约要登记到一个全部进程都能访问到的地方,好比是ZooKeeper集群进程。那么在读、写发生的时候,若是本进程没有对应的缓存,就先去查询一下对应的租约,若是被其余进程持有,则通知对方“清理”,所谓“清理”,每每是指删除用来读的数据,回写用来写的数据到数据库等持久化设备,等清理完成后,在进行正常的读写操做,这些操做可能会从新在新的进程上创建缓存。这种策略在缓存命中率比较高的状况下,性能是最好的,由于通常无需查询租约状况,就能够直接操做;但若是缓存命中率低,那么就会出现缓存反复在不一样进程间“移动”,会严重下降系统的处理性能。
租约转发。一样,咱们把存放某个KEY的缓存的进程,称为持有这个KEY数据的“租约”,同时也要登记到集群的共享数据进程中。和上面租约清理不一样的地方在于,若是发现持有租约的进程不是本次操做的进程,就会把整个数据的读、写请求,都经过网络“转发”个持有租约的进程,而后等待他的操做结果返回。这种作法因为每次操做都须要查询租约,因此性能会稍微低一些;但若是缓存命中率不高,这种作法能把缓存的操做分担到多个进程上,并且也无需清理缓存,这比租约清理的策略适应性更好。
l 修改广播。上面两种策略,都须要维护一份缓存数据的租约,可是自己对于租约的操做,就是一种比较耗费性能的事情。因此有时候能够采用一些更简单,但可能承受一些不一致性的策略:对于读操做,每一个节点的读都创建缓存,每次读都判断是否超过预设的读冷却时间x,超过则清理缓存从持久化重建;对于写操做,么个节点上都判断是否超过预设的写冷却时间y,超过则展开清理操做。清理操做也分两种,若是数据量小就广播修改数据;若是数据量大就广播清理通知回写到持久化中。这样虽然可能会有必定的不一致风险,可是若是数据不是那种要求过高的,并且缓存命中率又能比较有保障的话(好比根据KEY来进行一致性哈希访问缓存进程),那么真正由于写操做广播不及时,致使数据不一致的状况仍是会比较少的。这种策略实现起来很是简单,无需一个中心节点进程维护数据租约,也无需复杂的判断逻辑进行同步,只要有广播的能力,加上对于写操做的一些配置,就能实现高效的缓存服务。因此“修改广播”策略是在大多数须要实时同步,但数据一致性要求不高的领域最多见的手段。著名的DNS系统的缓存就是接近这种策略:咱们要修改某个域名对应的IP,并非马上在全球全部的DNS服务器上生效,而是须要必定时间广播修改给其余服务区。而咱们每一个DSN服务器,都具有了大量的其余域名的缓存数据。
总结
在高性能的服务器架构中,经常使用的缓存和分布两种策略,每每是结合到一块儿使用的。虽然这两种策略,都有无数种不一样的表现形式,成为各类各样的技术流派,可是只有清楚的理解这些技术的原理,而且和实际的业务场景结合起来,才能真正的作出知足应用要求的高性能架构。