亿级web系统容错搭建实践我24/7处理告警,经常周末和凌晨上网,累死了。后来当时的老领导对我说:你不能一直扮演“救火队长”的角色,你应该尝试从整体系统层面去思考问题的根源,然后推动解决.我恍然大悟,“火”是永远无法扑灭的,让系统自动“灭火”才是解决问题的正确方向。总之,系统的异常不能总是靠“人”来恢复,根本的解决办法是让系统本身具有“容错”能力。三年多过去了,这个系统还是我负责,从一个日请求数百万的小型Web系统,逐渐成长为日请求峰值8亿的平台级系统。难忘的技术之旅。容错性其实是系统健壮性的重要指标之一,本文将主要围绕“容错性”的实践展开,希望对技术类的同学有所启发和帮助。(备注:QQ会员活动运营平台,以下简称AMS)1、重试机制是人们想到的最简单、最简单的容错方法。当然是“失败重试”。总而言之,就是简单粗暴!简单是指它的实现通常很简单,而粗糙是指如果使用不当,可能会带来系统“雪崩”的风险,因为重试意味着对后端服务的双重请求。1.简单重试我们请求一个服务,如果服务请求失败,就重试。假设这个服务在正常状态下成功率为99.9%。因为一定的波动异常,成功率下降到95%。如果有重试机制,成功率大概可以保持在99.75%。简单重试的缺陷也很明显。如果服务出现问题,很可能会带来双倍流量,冲击服务体系,甚至可能直接破坏服务。然而,在实际的真实业务场景中,往往更为严重。如果某个功能不可用,往往更容易导致用户“重复点击”,反而造成更大规模的流量影响。与服务相对较低的成功率相比,直接冲击系统“挂掉”的后果显然更为严重。简单重试,适合场景使用。或者,主动计算服务成功率,如果成功率太低,不要直接重试,以免造成过多的流量影响。2、主备服务自动切换既然单次服务的重试可能会给服务带来双倍的流量冲击,最终导致更严重的后果,那我们不妨将场景改为自动重试或者主备切换服务。比如我们搭建了两套服务获取openid。如果服务A获取失败,我们会尝试从服务B获取。由于重试请求的压力放在了服务B上,服务A通常不会因为重试而造成双倍的流量影响。这种重试机制看似比较可用,但实际上存在一些问题:(1)通常存在“浪费资源”的问题。因为备份服务系统很可能长期处于闲置状态,只有当主服务出现异常时,其资源才会得到充分利用。但是,如果对核心服务业务(比如核心数据、营收相关)进行类似的部署,虽然会增加一些机器成本和预算,但这种付出通常是物有所值的。(2)触发重试机制必然会增加用户请求的耗时。如果主服务请求失败,再进行备份服务请求,这个环节的请求时间至少会增加一倍。如果主服务出现连接(connect)超时,耗时会明显增加。在正常状态下,一个服务获取数据可能只需要50ms,而服务的超时时间通常设置为500-1000ms,甚至更长。一旦出现超时重试场景,请求时间必然会大幅增加,很可能会相对严重地影响用户体验。(3)主备服务都异常卡死。如果主服务因为流量过大而出现异常,那么备份服务可能承受不住这种流量而挂掉。AMS中使用了重试容错机制,但是比较少见,因为我们认为主备服务还是不够可靠。2.动态移除或恢复异常机器在AMS中,我们的后台涉及到上百种不同的服务来支持整个操作系统的正常运行。所有的后端服务或者存储都是先部署,以无状态的方式提供服务(一个服务通常有很多台机器),然后通过公司内部的公共智能路由服务L5纳入AMS。(1)所有服务和存储,无状态路由。这样做的目的主要是为了避免单点风险,即防止某个服务节点挂掉,导致整个服务瘫痪。事实上,即使是一些主备性质的接入服务(主机挂了支持切换到备机)也不够可靠。毕竟只有两台机器,都挂掉的可能性还是有的。我们的后端服务通常以一组机器的形式提供服务,机器之间没有状态关系,支持请求的随机分配。(2)支持并行扩展。在大流量场景下,支持加机器扩容。(3)自动剔除异常机器。在我们的路由服务中,当发现某个服务机器异常(成功率低于50%),会自动移除该机器。稍后会发送临时请求,确认恢复正常后再重新添加。回到服务机器组。比如某组服务下有4台服务机器(ABCD),假设机器A的服务因为某种未知原因完全不可用,此时L5服务会自动将机器A从服务组中移除,并且只保留三台BCD机对外提供服务。后续如果A机异常恢复,那么L5会主动把A机加回来,最后还有四台机器ABCD对外提供服务。3年来,我们逐步将AMS中的服务从硬编码IP列表或主备服务升级优化为L5模式服务,逐步实现AMS后端服务的自我容错。至少,我们很少遇到因为某台机器的软件或硬件故障而不得不手动干预的情况。我们也慢慢从疲于应付告警的痛苦中解脱出来。三、超时时间1、为服务和存储设置合理的超时时间。调用任何服务或存储,合理的超时时间(超时时间是我们请求服务时等待的最长时间)非常重要。而这一点常常被忽视。通常,Web系统与后端服务之间的通信方式是同步等待方式。这种模式,会带来更多的问题。对于服务器来说,一个影响比较大的问题就是会严重影响系统的吞吐量。假设,在我们的一台服务机器上,启用了100个处理请求的worker,worker的超时时间设置为5秒,一个worker处理一个任务的平均处理时间为100ms。那么一个作品可以在5秒内处理50个用户请求。但是,一旦网络或服务偶尔出现异常,响应超时,那么在本次处理之后的5秒内,只处理1个等待请求。超时的失败任务。一旦出现这种比较大概率的超时异常,系统的吞吐率会大面积下降,所有worker都可能被耗尽(资源被占用,都处于等待状态,直到释放5秒超时),最终导致新的请求。没有worker可用,只能陷入异常状态。算上耗时的网络通信等环节,用户等了5秒多,最后却得到一个异常的结果,用户的心情通常是崩溃的。解决这个问题的方法是设置一个合理的超时时间。比如回到上面的例子,平均处理时间是100ms,那么我们不妨把timeout从5s减少到500ms。直观上,它解决了吞吐量下降和用户等待时间过长的问题。但是这样做本身也比较容易带来新的问题,就是会造成服务成功率的下降。因为平均耗时100ms,但是有的业务请求本身耗时很长,超过500ms。比如服务器处理某个请求需要600ms,此时客户端等了500多ms就认为已经断开了。这类耗时较长的业务请求的处理会受到很大影响。2.超时时间设置过短导致成功率下降如果超时时间设置过短,很多已经成功处理的请求会被当成服务超时,从而导致服务成功率下降。不建议对所有业务服务一刀切地设置超时时间。优化方法分为两个方向。(1)快慢分离根据实际业务维度,为每个业务服务差异化配置不同的超时时间。同时,最好将它们的部署服务分开。比如酷跑的查询服务一般需要100ms,所以我们设置超时时间为1s,一款新手游的查询服务一般需要700ms,所以我们设置为5s。这样的话,整个系统的成功率不会受到很大的影响。(2)解决同步阻塞等待的“快慢分离”,可以改善系统的同步等待问题。但是对于一些耗时较长的服务,系统的进程/线程资源还处于同步等待的过程中,无法响应其他新的请求只能阻塞等待,其资源依然被占用,整体吞吐量系统还是大大降低了。解决办法当然是使用I/O多路复用和异步回调来解决同步等待过程中的资源浪费问题。AMS的一些核心服务使用了“协程”(也叫“微线程”)。简单的说,常规的异步程序代码嵌套了较多的多层函数回调,写起来比较复杂。协程提供了一个类似于写同步代码,来写一个异步回调程序)来解决同步等待的问题。异步处理的简单描述就是,当一个进程遇到I/O网络拥塞时,它会保持原地,并立即切换到处理下一个业务请求。进程不会因为一定的网络等待而停止处理业务。在网络等待时间过长的场景下,通常可以保持在一个比较高的水平。值得补充的是,异步处理只是解决了系统的吞吐量问题,并没有提升用户体验,也不会减少用户的等待时间。3、防止再次入境,防止重复发货。前面我们说了,我们设置了一个比较“合理的超时时间”,简单来说就是一个比较短的超时时间。在写数据的场景下,又会引发新的问题。就我们的AMS系统而言,就是交付场景。如果发送请求超时,这个时候,我们需要考虑更多的问题。(1)投递等待超时,投递服务执行投递失败。在这种情况下,问题不大。后续用户再次点击收货按钮,触发下一次补货。(2)投递等待超时,投递服务实际执行投递的时间稍晚,我们称之为“超时成功”。比较麻烦的场景是每次发送都超时,但实际上发送成功了。如果系统设计不当,用户可能会无限领取礼包,最终导致活动运营事故。第二种情况给我们带来了一个更麻烦的问题。如果处理不当,用户再次点击,就会触发第二次“额外”投放。例如,我们假设一个外卖服务的超时时间设置为6s,用户点击了按钮。收到请求后,我们的AMS请求派送服务派送货物。等了6s没有反应,我们会提示用户“收货失败”,其实是快递服务在第8秒就派送成功,礼包到达了用户的账户。当用户看到“领取失败”时,再次点击该按钮,最终会向用户发送一个“额外”的礼包。例子的时序和流程图大致如下:这里是反重入,简单的说就是如何确认无论用户点击多少次认领按钮,我们保证只有一个预期结果,即即,只会给用户发送一次礼包,不会造成重复发货。我们的AMS活动运营平台每年推出活动4000多场,涉及各类礼包数万个,不同业务系统,业务沟通场景更加复杂。针对不同的业务场景,我们做了不同的解决方案:(1)业务级限制,设置礼包列表用户限制。派送服务器源头设置一个用户最多只能领取1个礼包,直接避免了重复派发。但是,这种业务限制并不适用于所有业务场景。仅限于具有该限制能力的内部业务传递系统。而且有些礼包本身是可以多次领取的,不适用。(2)序号机制。用户每次发出符合条件的发货请求,都会生成一个对应的订单号,用于保证订单号只有一个,且只能发货一次。这个方案虽然比较完善,但是“订单号发货状态更新”需要外卖服务商配合,而我们外卖业务方很多,并不是所有的都能支持“订单号更新”的场景。(3)自动重试的异步投递模式。用户点击领取礼包按钮后,Web端直接返回成功,并提示礼包将在30分钟内到达。对于后台,将delivery输入到deliveryqueue或者storage中,异步等待deliveryservice送货。因为是异步处理,所以可以多次进行投递重试操作,直到投递成功。同时,异步下发可以设置比较长的超时等待时间,通常不会出现“超时成功”的场景,对于前端响应来说,不需要等待后台下发状态的返回。但是这种模式会给用户带来一个比较不好的体验,就是没有实时反馈,无法第一时间告诉用户礼包是否到了。4.非订单号特殊反套现机制在一些特殊的合作场景下,我们不能使用双方约定订单号的方式,比如完全隔离独立的对外发货接口,不能约定订单号和我们。基于这种场景,我们的AMS专门开发了一个反爬虫机制,就是限制读超时的次数。不过这个方案并不能完美解决重复发货的问题,只能起到尽量减少避免被刷的作用。一次网络通信通常包括:建立连接(connect)、写入数据包(write)、等待并读回数据包(read)、断开连接(close)。通常,如果一个投递服务出现异常,多数情况下是connect步骤失败或者超时,如果一个请求在等待返回包(read)时超时,那么投递服务的另一端可能有“超时但成功交付”场景。这个时候我们记录下readtimeout发生的次数,然后提供配置限制次数的能力。如果设置为2次,那么当用户第一次收到礼包,遇到读取超时,我们会允许重试。认为可能发货成功,拒绝用户第三次理赔请求。这样,假设外卖真的有很多超时成功,用户最多只能刷2次礼包(次数可配置),避免刷礼包的场景没有限制。但是,此解决方案并不完全可靠,应谨慎使用。在交付场景中,还会涉及到分布式场景下的CAP(一致性、可用性、分区容忍度)问题。但是我们的系统不是电商服务,大部分的发货都没有很强的一致性要求。所以一般来说,我们弱化了一致性问题(核心服务,通过异步重试,达到最终一致性),以追求可用性和分区容错的保证。4.服务降级,自动屏蔽非核心分支异常对于一个礼包领取请求,我们的后台CGI会经过10多个环节和业务逻辑判断,包括礼包配置读取,礼包限额查询,登录状态验证,安全保护等等。在这些服务中,有不可跳过的核心环节,如阅读礼包配置服务,也有非核心环节,如数据上报。对于非核心链接,我们的做法是设置一个比较低的超时时间。比如我们的一个统计报表服务平均耗时3ms,那么我们设置超时时间为20ms,一旦超时就绕过,业务流程继续按照正常逻辑进行。5、服务解耦和物理隔离虽然大家都知道一个服务的设计要尽可能小,分开部署。这样,服务之间的耦合度会比较小。一旦某个模块出现问题,受影响的模块会比较小。越少,容错性就会越强。但是,从设计之初,每一个服务都被有序的划分成小的部分。这就需要设计者具有超前的意识,能够提前实现业务和系统的开发形态。事实上,业务的发展往往是比较缓慢的。不可预测,因为业务形态会随着产品策略的变化而变化。在业务发展初期,流量比较小的时候,通常没有足够的人力物力将服务细化。AMS从一个日请求数百万的Web系统,逐渐成长为一个十亿级别的系统。在这个过程中,流量规模增长了100倍,我们也经历了很多服务耦合带来的痛苦。1、服务分离,大服务变成多个小服务我们常说鸡蛋不能放在一个篮子里。AMS以前是一个比较小的系统(日请求百万,在腾讯是一个完全不起眼的小web系统),所以早期很多服务和存储部署在一起,查询和投递服务都放在一起,无论哪个出错,都会相互影响。后来,我们逐渐将这些核心服务和存储分离出来,细化划分,重新部署。在数据存储方面,我们逐步将原来的3-5个存储服务裁减为20多个独立部署的存储服务。比如在2015年下半年,我们将其中一个核心的存储数据从1分离到3,这带来了很多好处:(1)分流了原有主存的压力。(2)稳定性更高,不再是其中一个问题牵动整个大模块。(3)存储之间物理隔离,即使服务器硬件出现故障,也不会相互影响。2、轻重分离,物理隔离另一方面,我们对一些核心业务进行了“轻重分离”。例如,我们支持了2016年“手机QQ春节红包”活动项目的服务集群。负责信息查询和发红包的集群独立部署。信息查询业务相对次要,业务流程相对轻量级,而派发红包是非常核心的业务,业务流程相对繁重。轻重分离的部署方式可以给我们带来一些好处:(1)即使查询集群出现问题,也不会影响交付集群,保证了用户核心功能的正常使用。(2)双方的机器和部署的服务基本一致。在紧急情况下,两侧的集群可以相互支持和切换,达到容灾的效果。(3)每个集群中的机器跨机房部署。比如服务器分布在三个机房ABC。假设B机房整个网络出现故障,反向代理服务会将B机房无法接收服务的机器移除,AC机房剩余的服务器仍然可以正常对外提供服务。6.业务层面的容错如果我们在系统架构设计层面已经完全建立了“容错”,那么需要根据实际业务进行下一层的容错,因为不同的业务有不同的业务逻辑特性,也会导致业务层面出现各种问题。业务层面的容错,简而言之就是避免“人为错误”。一个人再谨慎小心,也总会有“手抖”、不经意“失误”的时候。AMS是一个活动运营平台,每月会推出400多场活动,涉及数千条活动配置信息(包括礼包、规则、活动参与逻辑等)。在我们的业务场景中,由于各种原因,会出现很多“人为错误”。比如一个运营同学误解了礼包的每日上限,将原本允许每天释放100个礼包的资源错误配置为每天200个。这种错误是测试生很难测试出来的。当活动真正启动,派发101大礼包时,会因为当天资源池没有资源而报错。虽然我们的业务告警系统可以快速捕捉到这个异常(以10分钟为一个周期,从十多个维度监控计算各种活动的成功率、流量波动等数据),但是,对于腾讯的用户量在高级别,即使只影响十几分钟,也能影响上千用户,而对于大规模的流量推广活动,甚至可以影响数十万用户。在这种情况下,很容易造成严重的“现场网络事故”。完善的监控系统可以及时发现问题,防止影响范围进一步扩大和失控。但是,这并不能阻止现网出现问题。真正的治愈,当然是从源头上杜绝这种情况的发生。回到上面“涨停板配置错误”的示例场景,当用户在内部管理端发布活动配置时,会直接提示操作同学,这个配置规则是错误的。在业界,因配置参数错误导致现网重大事故的例子不胜枚举。“配置参数问题”几乎可以说是业界的一道难题。一种普适的方法,更多的是需要根据具体的业务和系统场景,逐步构建配套的检查机制程序或脚本。为此,我们构建了强大的智能配置检测系统,汇集了数十条业务搭配检测规则,并且检测规则的数量还在不断增加。这里的规则包括检查礼包每日限额等比较简单的规则,也包括比较复杂的检查各种关联配置参数的业务逻辑规则。另一方面,流程的执行不能是“口头约定”,也应该固化为平台程序的一部分。例如,在活动上线前,我们要求负责活动的同事对“礼包领取逻辑”进行验证,即实际领取一个礼包。然而,这只是“口头协议”,实际上并不能强制执行。如果这位同事因为活动礼包太多而错过了其中一个礼包的验证过程,这种事情确实时有发生。也算是又一幕“人为失误”。为了解决这个问题,这个过程是通过我们AMS内部管理中的流程来保证的,确保这个同事的QQ号确实收到了所有的礼包。方法其实很简单,就是让负责活动的同事设置一个验证活动的QQ号,然后程序在投放活动的时候,程序会自动检查是否有活动该QQ号在每个子活动项目中的参与记录。如果有全部参与记录,则表示该同事已完整收到所有礼包。同时,我们也通过程序和平台来保证其他模块的验证和测试,而不是通过“口头约定”。通过流程和制度对业务逻辑和流程的保障,尽可能杜绝“人为失误”。除了减少问题的发生之外,这个业务配置检查程序实际上减少了测试和验证活动的工作量,可以节省人力。但是业务配置检查规则的构建并不简单,逻辑往往比较复杂,因为要防止误杀。7.总结无论是人还是机器,都会出现“错误”,但对于单个个体来说,出现的概率通常并不高。但是,如果一个系统有上百台服务器,或者一个工作涉及上百人,出现这种“错误”的概率就大大增加,错误很可能成为一种常态。机器故障尽量由系统自行兼容和恢复,尽量通过程序和系统流程避免人为错误。容错的核心价值,除了增强系统的健壮性,我觉得是为了解放技术人员,让我们不用凌晨起床处理告警,或者享受一个相对普通的休闲周末.对我们来说,要完全实现这一点还有很长的路要走,我们会鼓励你。
