互联网服务的特点就是面向海量级的用户,面向海量级的用户如何提供稳定的服务呢?这里,对这几年的一些经验积累和平时接触的一些理念做一个总结。
一、原则
1.Web服务的CAP原理
CAP指的是三个要素:一致性(Consistency)、可用性(Availability)、分区容忍性(Partition tolerance)。CAP原理指的是这三个要素最多只能同时实现两点,不可能三者兼顾,对于海量级服务,一般这是一条常记心中的基准准则。
如下是《Web服务的CAP 》关于CAP的定义:
- 一致性:可以参考数据库的一致性。每次信息的读取都需要反映最新更新后的数据。
- 可用性:高可用性意味着每一次请求都可以成功完成并受到响应数据
- 分区宽容度:这个是容错机制的要求。一个服务需要在局部出错的情况下,没有出错的那部分被复制的数据分区仍然可以支持部分服务的操作,可以简单的理解为可以很容易的在线增减机器以达到更高的扩展性,即所谓的横向扩展能力。
面向海量级的分布式服务设计,基本上分区容忍性(Partition tolerance)是第一要素,因此根据业务的情况,我们需要在一致性(Consistency)和可用性(Availability)之间做取舍。对于一些业务,譬如支付宝或财付通,一致性会是第一考虑要素,即使是延迟的不一致也是不可接受的,这种时候只能牺牲可用性以保证一致性。对于一些应用,譬如淘宝或拍拍交易中的评价信息,一般用户是可以接受延迟的一致性,这种时候可以优先考虑可用性,而用最终一致性来保证数据一致,譬如通过某种对帐机制。对于一些应用,甚至一致性都并非要求,只需要保证差不多一致性即可,譬如Q-zone中的农场游戏中的偷菜。
根据我们应用的业务需求,选择合适的一致性级别,以更好地保证系统的分区容忍性和可用性。
2.柔性可用
面向海量级的分布式服务设计,我们要意识到,一切都是不可靠的,在不可靠的环境的环境中构建可靠的应用,其中最重要的一点就是保持系统的柔性。
1)不可靠的环境
我们可能已经见惯一个远程服务提供不了服务了,运行一段时间后WebServer突然不响应了,数据库随着负载的不断增加再放上一条SQL语句就会垮掉。但是,硬盘坏掉、电源断掉、光纤中断,听起来似乎多么不可思议,然而,当一个海量服务需要成千上万台服务器、需要部署全国各地的数十个数据中心、需要横跨电信网通教育网三大网络的时候,一切听起来不可思议的事情会变成常态。一切都是不可靠的,唯一可靠的就是不可靠本身。
2)划分服务级别
我们应该意识到,在这种不可靠的环境中提供完美的服务,本身就是一个神话,即使不是说完全不可能,但至少是代价高昂的,因此,当某些问题发生(环境变地不可靠的时候),我们必须做出取舍,选择为用户提供用户最关心的服务,这种服务虽然听起来是有损的(至少是不完美的),但却能在一定程度上满足用户大部分的需求。譬如,当网络带宽无法为用户提供最好的体验而扩容又不是短期可以达到的时候,选择降低一些非重要服务的体验是一个比较好的选择。
在面向海量互联网的设计当中,对服务进行分级,当系统变地不可靠的时候,优先提供重要优先级的服务。
3)尽快响应
互联网用户的耐心是非常有限的,如果一个页面需要3秒以上才能看到,也许大部分用户的第一选择就是关掉浏览器。在构建柔性可用的互联网服务的时候,响应时间大部分情况下都是需要最优先考虑。还是一句话,环境是不可靠的,当我们无法尽快从远程服务获得数据、当数据库已经慢如蜗牛,也许当后台还在吭哧吭哧干活的时候,用户老早已经关闭了页面,处理返回的数据也只是在浪费表情,面向互联网用户,响应就是生命。
二、策略
如何让我们的应用提供更高质量的服务呢,这里是一些在日常开发使用到或者观察到的一些策略的总结:
1.数据sharding
海量服务相应也意味着海量的用户和海量的用户数据,大家都知道,即使是再强大的数据库、再强大服务器,在单表上亿规模的数据足够让一条简单的SQL语句慢如蜗牛(甚至于在百万、千万级别上,如果没有采取合适的策略,都无法满足服务要求),一般处理这种千万上亿级数据的大家基本上都会想到的就是数据sharding,将数据切割成多个数据集,分散到多个数据库的多个表中(譬如将用户数据按用户ID切割成4个数据库每个数据库100个表共400个表),由于每个表数据足够小可以让我们的SQL语句快速地执行。而至于如何切割,实际上跟具体的业务策略有关系。
当然,我们要认识到,这种数据sharding并非全无代价的,这也意味着我们需要做出一些折中,譬如可能很难进行跨表数据集的查询、联表和排序也变地非常困难、同时数据库client程序编写也会变地更加复杂、保证数据一致性在某些情况下会变地困难重重。sharding并非万能药,选择是否sharding、如何sharding、为sharding如何换用一个近似的业务描述方式,这是业务设计需要仔细考虑的问题。
2.Cache
经验会告诉我们,基本上大部分系统的瓶颈会集中在IO/数据库上,常识也告诉我们,网络和内存的速度比IO/数据库会提升甚至不止一个数量级。面向海量服务,Cache基本上是一个必选项,分布式Cache更是一个不二选择,根据我们的需要,我们可以选择memcached(非持久化)、memcachedb/Tokyo Tyrant(持久化),甚至构建自己的cache平台。
在使用Cache上,下面是需要仔细考虑的点:
- 选择合适的Cache分布算法,基本上我们会发现使用取模方式决定Cache位置是不可靠的,因为坏节点的摘除或者节点扩量会让我们的Cache命中率在短时间内下降到冰点,甚至会导致系统在短期内的负载迅速增长甚至于崩溃,选择一种合适的分布算法非常重要,譬如稳定的一致性哈希
- Cache管理:为每个应用配置独立的Cache通常不是一个好主意,我们可以选择在大量的机器上,只要有空闲内存,则运行Cache实例,将Cache实例分成多个组,每个组就是一个完整的Cache池,而多个应用共享一个Cache池
- 合理的序列化格式:使用紧凑的序列化方案存储Cache数据,尽量少存储冗余数据,一方面可以最大能力地榨取Cache的存储利用率,另一方面,可以更方便地进行容量预估。此外,不可避免地,随着业务的升级,存储的数据的格式有可能会变更,序列化也需要注意向上兼容的问题,让新格式的客户端能够支持旧的数据格式。
- 容量估算:在开始运行之前,先为自己的应用可能使用到的容量做一个容量预估,以合理地分配合适的Cache池,同时为可能的容量扩充提供参考。
- 容量监控:Cache命中率怎么样,Cache的存储饱和度怎么样,Client的Socket连接数等等,对这些数据的采集和监控,将为业务的调整和容量的扩充提供了数据支持。
- 选择在哪层上进行Cache,譬如数据层Cache、应用层Cache和Web层Cache,越靠近数据,Cache的通用性越高,越容易保持Cache数据的一致性,但相应的处理流程也越长,而越靠近用户,Cache的通用性越差,越难保证Cache数据的一致性,但是响应也越快,根据业务的特点,选择合适的Cache层是非常重要的。一般而言,我们会选择将粗粒度、极少变更、数据对用户不敏感(即可以一定程度上接受误差)、并且非针对用户级的数据,在最靠近用户的层面上Cache,譬如图片Cache、TOP X等数据;而将一些细粒度、变更相对频繁、用户相对敏感的数据或者是针对用户级的数据放在靠近数据的一段,譬如用户的Profile、关系链等。
3.服务集群
面向海量服务,系统的横向扩展基本上是第一要素,在我的经验和经历中,服务集群需要考虑如下因素:
- 分层:合理地对系统进行分层,将系统资源要求不同的部分进行合理地逻辑/物理分层,一般对于简单业务,Client层、WebServer层和DB层已经足够,对于更复杂业务,可能要切分成Client层、WebServer层、业务层、数据访问层(业务层和数据访问层一般倾向于在物理上处于同一层)、数据存储层(DB),太多的分层会导致处理流程变长,但相应系统地灵活性和部署性会更强。
- 功能细粒度化:将功能进行细粒度的划分,并使用独立的进程部署,一方面能更有利于错误的隔离,另一方面在功能变更的时候避免一个功能对其他功能产生影响
- 快慢分离/按优先级分离部署:不同的服务具备不同的特点,有些服务访问速度快有些访问速度慢,访问慢的服务可能会阻塞住导致整个服务不可用,有些服务优先级别比较高(譬如打款之类的用户比较关心的服务),有些服务优先级别低(譬如日志记录、发邮件之类),优先级别低的服务可能会阻塞住优先级别高的服务,在部署上将不同特点的应用分离部署避免相互影响是一个常用的做法
- 按数据集部署:如果每一层都允许对下一层所有的服务接口进行访问,将存在几个严重的缺陷,一是随着部署服务的增长,会发现下一层必须允许数量非常庞大的Socket连接进来,二是我们可能必须把不同的服务部署在不同的数据中心(DC)的不同机房上,即便是上G的光纤专线,机房间的穿梭流量也会变地不可接受,三是每个服务节点都是全数据容量接入,并不利于做一些有效的内部优化机制,四是只能采用代码级控制的灰度发布和部署。当部署规模达到一定数量级的时候,按数据集横向切割成多组服务集合,每组服务集合只为特定的数据集服务,在部署上,每组服务集合可以部署在独立的相同的数据中心(DC)上。
- 无状态:状态将为系统的横向扩容带来无穷尽的烦恼。对于状态信息比少的情况,可以选择将全部状态信息放在请求发器端,对于状态信息比较多的情况,可以考虑维持一个统一的Session中心。
- 选择合适的负载均衡器和负载均衡策略:譬如在L4上负载均衡的LVS、L7上负载均衡的Nginx、甚至是专用的负载均衡硬件F5(L4),对于在L7上工作的负载均衡器,选择合适的负载均衡策略也非常重要,一般让用户总是负载均衡到同一台后端Server是一个很好的方式
4.灰度发布
当系统的用户增长到一定的规模,一个小小功能的发布也会产生非常大的影响,这个时候,将功能先对一小部分用户开放,并慢慢扩展到全量用户是一个稳妥的做法,使用灰度化的发布将避免功能的BUG产生大面积的错误。如下是一些常见的灰度控制策略:
- 白名单控制:只有白名单上的用户才允许访问,一般用于全新功能Alpha阶段,只向被邀请的用户开放功能
- 准入门槛控制:常见的譬如gmail出来之初的邀请码、QQ农场开始阶段的X级的黄钻准入,同样一般用于新功能的Beta阶段,慢慢通过一步一步降低门槛,避免在开始之处由于系统可能潜在的问题或者容量无法支撑导致整个系统的崩溃。
- 按数据集开放:一般常用于成熟的功能的新功能开发,避免新功能的错误产生大面积的影响
5.设计自己的通信协议:二进制协议、向上/下兼容
随着系统的稳定运行访问量的上涨,慢慢会发现,一些看起来工作良好的协议性能变地不可接受,譬如基于xml的协议xml-rpc,将会发现xml解析和包体的增大变地不可接受,即便是接近于二进制的hessian协议,多出来的字段描述信息(按我的理解,hessian协议是类似于map结构的,包含字段的名称信息)和基于文本的http头将会使协议效率变地低下。也许,在开始合适的时候而不是到最后不得已的时候,去设计一个良好的基于二进制的高效的内部通信协议是一个好的方式。按我的经验,设计自己的通信协议需要注意如下几点:
- 协议紧凑性,否则早晚你会为你浪费的空间痛心疾首
- 协议扩展性,早晚会发现旧的协议格式不能适应新的业务需求,而在早期预留变地非常地重要,基本上,参见一些常用的规范,魔术数(对于无效果的请求可以迅速丢弃)、协议版本信息、协议头、协议Body、每个部分(包括结构体信息中的对象)的长度这些信息是不能省的
- 向下兼容和向上兼容:但功能被大规模地调用的时候,发布一个新的版本,让所有的client同时升级基本上是不可接受的,因此在设计之处就需要考虑好兼容性的问题
6.设计自己的Application Server
事情进行到需要自己设计通信协议,自己构建Application Server也变地顺理成章,下面是在自己开发Application Server的时候需要处理的常见的问题:
- 过载保护:当系统的某个部件出现问题的时候,最常见的情况是整个系统的负载出现爆炸性的增长而导致雪崩效应,在设计application server的时候,必须注意进行系统的过载保护,当请求可以预期无法处理的时候(譬如排队满载或者排队时间过长),丢弃是一个明智的选择,TCP的backlog参数是一个典型的范例。
- 频率控制:即便是同一系统中的其他应用在调用,一个糟糕的程序可能会将服务的所有资源占完,因此,应用端必须对此做防范措施,频率控制是其中比较重要的一个
- 异步化/无响应返回:对于一些业务,只需要保证请求会被处理即可,客户端并不关心什么时候处理完,只要最终保证处理就行,甚至最终没有处理也不是很严重的事情,譬如邮件,对于这种应用,应快速响应避免占着宝贵的连接资源,而将请求进入异步处理队列慢慢处理。
- 自我监控:Application Server本身应该具备自我监控的功能,譬如性能数据采集、为外部提供内部状态的查询(譬如排队情况、处理线程、等待线程)等
- 预警:譬如当处理变慢、排队太多、发生请求丢弃的情况、并发请求太多的时候,Application Server应该具备预警的能力以快速地对问题进行处理
- 模块化、模块间松耦合、机制和策略分离:如果不想一下子面对所有的复杂性或不希望在修改小部分而不得不对所有的测试进行回归的话,模块化是一个很好的选择,将问题进行模块切割,每个模块保持合理的复杂度,譬如对于这里的Application Server,可以切分成请求接收/管理/响应、协议解析、业务处理、数据采集、监控和预警等等模块。这里同时要注意块间使用松耦合的方式交互,譬如,请求接收和业务处理之间则可以使用阻塞队列通信的方式降低耦合。另外还需要注意的是机制和策略的分离,譬如协议可能会变更、性能采集和告警的方式可能会变化等等,事先的机制和策略分离,策略变更的处理将变地更加简单。
7.Client
很多应用会作为服务的Client,去调用其他的服务,如下是在做为Client应该注意的一些问题:
- 服务不可靠:作为Client永远要记住的一点就是,远程服务永远是不可靠的,因此作为Client自己要注意做自我保护,当远程服务如果无法访问时,做折中处理
- 超时保护:还是上面所说的,远程服务永远都是不可靠的,永远也无法预测到远程什么时候会响应,甚至可能不会响应(譬如远程主机宕机),请求方要做好超时保护,譬如对于主机不可达的情况,在linux环境下,有时会让客户端等上几分钟TCP层才会最终告诉你服务不可到达。
- 并发/异步:为了提速响应,对于很多可以并行获取的数据,我们总是应该并行地去获取,对于一些我们无法控制的同步接口——譬如读数据库或同步读cache——虽然不是很完美,但多线程并行去获取是一个可用的选择,而对于服务端都是使用自构建的Application Server,使用异步Client接口至关重要,将请求全部发送出去,使用异步IO设置超时等待返回即可,甚至于更进一步异步anywhere,在将client与application server整合到一块的时候,请求发送出去之后立即返回,将线程/进程资源归还,而在请求响应回来符合条件的时候,触发回调做后续处理。
8.监控和预警
基本上我们会见惯了各种网络设备或服务器的监控,譬如网络流量、IO、CPU、内存等监控数据,然而除了这些总体的运行数据,应用的细粒度化的数据也需要被监控,服务的访问压力怎么样、处理速度怎么样、性能瓶颈在哪里、带宽主要是被什么应用占、Java虚拟机的CPU占用情况怎么样、各内存区的内存占用情况如何,这些数据将有利于我们更好的了解系统的运行情况,并对系统的优化和扩容提供数据指导。
除了应用总体监控,特定业务的监控也是一个可选项,譬如定时检查每个业务的每个具体功能点(url)访问是否正常、访问速度如何、页面访问速度如何(用户角度,包括服务响应时间、页面渲染时间等,即网页测速)、每个页面的PV、每个页面(特别是图片)每天占用的总带宽等等。这些数据将为系统预警和优化提供数据上的支持,例如对于图片,如果我们知道哪些图片占用的带宽非常大(不一定是图片本身比较大,而可能是访问比较大),则一个小小的优化会节省大量的网络带宽开销,当然,这些事情对于小规模的访问是没有意义的,网络带宽开销节省的成本可能都没有人力成本高。
除了监控,有效的预警机制也是必不可少,应用是否在很好地提供服务、响应时间是否能够达到要求、系统容量是否达到一个阀值。有效的预警机制将让我们尽快地对问题进行处理。
9.配置中心化
当系统错误的时候,我们如何尽快地恢复呢,当新增服务节点的时候,如何尽快地让真个系统感知到呢?当系统膨胀之后,如果每次摘除服务节点或者新增节点都需要修改每台应用配置,那么配置和系统的维护将变地越来越困难。
配置中心化是一个很好的处理这个问题的方案,将所有配置进行统一地存储,而当发生变更的时候(摘除问题节点或者扩量增加服务节点或新增服务),使用一些通知机制让各应用刷新配置。甚至于,我们可以自动地检测出问题节点并进行智能化的切换。
三、最后
构建面向海量用户的服务,可以说是困难重重挑战重重,一些原则和前人的设计思路可以让我们获得一些帮助,但是更大的挑战会来源于细节部分,按我们技术老大的说法,原则和思路只要看几本书是个技术人员都会,但决定一个系统架构师能力的,往往却是对细节的处理能力。因此,在掌握原则和前人的设计思路的基础上,更深入地挖掘技术的细节,才是面向海量用户的服务的制胜之道。