转载设计反模式之架构设计

2018-5-8 迪迪

背景

 

下图所示线上故障,你的产品线是否曾经中招或者正在中招?同样的问题总是在不同产品线甚至相同产品线不同系统重复上演,这些故障有个共同特点,就是线下常规测试很难发现,即便线上验证也不易暴露。但是总是在“无变更安全日”悄然爆发,严重影响系统稳定性指标。



面对这些看似并无规律的故障,Case by case的分析无疑是低效而且不系统的,无法全面扫除稳定性测试盲区,也无法阻止悲剧在其他产品线再一次发生。为此笔者把问题聚类,根据问题特点寻求通用测试手段,并在产品线各个系统落地验证,效果显著,现把个人经验融合前辈经验产出,供大家参考,有则改之,无则加勉。

首先,为了让大家更好了解这些故障对业务系统稳定性的影响程度,需了解下何为稳定性,衡量指标就是系统可用性= MTBF / (MTBF + MTTR) , 其中MTBF, Mean Time Between Failure, 是平均无故障时间, 而MTTR, Mean Time To Repair,是平均修复时间,参考下表更加直观。

从如上数字看,5个9的故障时间月故障时间只有25s,3个9的可用性月故障时间也只有40多分钟,回想我们平时处理过的线上问题,开发和测试质量把控不过关,然后再把期望寄托在半人肉处理故障的运维团队,显然无法达到线上产品稳定性要求。

为了保障系统稳定性,提前消除风险势在必行。产品质量风险类型很多,产品研发流程的各个阶段都可能引入和存在风险,每个阶段的风险的类型和发现手段都不尽相同,为此产出如下风险模型。按照风险发生的阶段及原因,风险类型可分为:架构设计风险、编码风险、安全风险、流程规范风险、运维风险和监控风险。


本文主要讲解架构设计风险,接下来介绍的每个风险都会说明风险定义,影响,以及通过什么技术手段来进行风险识别,最后总结风险消除方案。另外每个风险都会有具体的例子来讲解,这些例子都是发生在百度内部的真实故事。


架构设计风险


架构设计风险是QA最容易忽略的,该类风险出现在研发阶段的早期,我们都知道缺陷越早的暴露后期研发的维护成本越低,而且一旦架构设计上出现了问题,影响面是涉及整个模块甚至系统的,修复代价必然非常高,因此对于架构设计的风险更要提前了解和避免。

根据既往经验,架构设计风险大概可以分为以下几个维度:交互、依赖、耦合。

交互类常见风险:重复交互、高频交互、冗余/无用交互、接口不可重用、超时重试设置不合理、IP直连、跨机房等。

依赖类常见风险:不合理强弱依赖、无效依赖、忽略第三方依赖、缓存依赖失效等。

耦合类常见风险:架构耦合不合理、缓存耦合不合理等。


一、重复交互

1、风险定义

在一次业务请求中,系统内部发起了多次完全相同的网络交互,即存在重复交互风险。从交互层级和上下游来说,重复交互有两类,整个业务请求范围内的重复和同层重复,其中同层重复交互的上下游也是相同的,本文更多关注的是整个业务请求范围内的重复交互。通常在一次业务请求中,为了提升性能和负载,尽量避免或者降低重复交互次数。

2、风险影响

重复交互增加接口耗时,降低接口性能,当重复的是跨机房交互会使得性能急剧下降影响系统稳定性,增加对下游服务的压力(模块压力增加一倍,下游服务压力增加几倍)。

3、风险识别

如果两个交互具有完全相同的请求服务对象(尤其是mysql、redis、memcache这类数据存储服务)、请求数据、返回数据,那么这两个交互就判定为重复交互;对于获取不到交互数据时也可以通过数据包size进行初判。这里可以借助开源trace系统,采集业务测试时的调用链信息,根据上面的判断规则进行风险自动识别。

4、风险消除

在对实时性要求可控的前提下,将第一次查询信息缓存下来。


  • 真实案例一:系统间重复交互。11次重复请求session,对于前端一次请求就要对session模块产生几十倍的流量冲击,所有这些交互都是完全重复的,极大的降低的了接口性能和session的负载能力。


  • 真实案例二:mysql/redis重复交互。mysql/redis作为系统中性能瓶颈,这样的重复请求无疑加速了其性能瓶颈的到达。


二、高频交互

1、风险定义

一次用户发起的请求,如果在模块之间的交互次数完全依赖于后端返回的数据条数,会给下游造成极大压力的同时,也降低了系统的稳定性。相同业务请求的模块交互次数多少不一,原因通常是代码中循环操作内部存在网络交互,总交互次数受到循环迭代的次数影响。这样的情况在模块上线初期,可能因为数据量比较小、pv比较小很容易被人忽视,当某天上线一些大数据、大客户,将会给予致命一击。

2、风险影响

循环请求次数过多会导致下游压力倍增(前端pv增加一倍,后端pv增加几十倍),接口性能不稳定,降低系统处理能力。系统稳定性完全依赖于数据的代码逻辑非常脆弱,当遇到某一个大数据时将会出现模块假死、系统雪崩、功能失败。

3、风险识别

基于上游传来的数据或某个子请求返回的数据量(通常是一个数组),针对每个数组元素进行网络请求,遍历并没有错,但是要对这个遍历的数组元素个数有限制,否则循环遍历的次数就完全依赖于数据。这里也可以借助开源trace系统,采集业务测试时的调用链信息,根据上面的判断规则进行风险自动识别。

4、风险消除

数据量要可控,结合产品业务需求,比如请求返回结果要有上限;批量请求替代逐个请求。


  • 真实案例:查询某商户物料详情,当该商户拥有大量物料,就出现了如下场景,用户的一次查询就造成服务与db之间156次交互,那该接口的性能就可想而知了,平均耗时都在3s+,用户体验极差。



三、冗余/无用交互

1、风险定义

交互依赖的数据已出现异常,还继续执行后续交互,使得后续的交互是没有任何意义的冗余交互。这些依赖的数据,可能是上游传递而来,也可能是与下游模块请求得来。

2、风险影响

冗余交互会占用系统资源,降低接口性能,从而影响系统稳定性和性能。

3、风险识别

如果交互A依赖数据B(比如交互A的请求数据中需要传入B),在B异常(比如数据为空、null、false等)情况下,还是发生了交互A,那么就认为A是冗余交互;如果操作A依赖于操作B的成功执行,当B异常时,还是发生了操作A,那么A也认为是冗余交互。可以借助开源trace系统,采集业务测试时的调用链信息,根据上面的判断规则进行风险自动识别。

4、风险消除

代码中增加异常逻辑判断:当交互依赖的数据异常时不进行该交互。


  • 真实案例:如下调用链正常场景是先查询团单list,然后用团单list去查询每个团单的优惠。但是当查询团单列表为空时,就没有必要再调用marketing查询团单的优惠信息了,应该立即返回错误码。这里增加无效交互无疑降低了接口性能。



四、接口不可重入

1、风险定义

相同请求发给模块再次处理,不能保证结果一致,符合预期。

2、风险识别

相同请求,模块返回结果不一致亦或重复写操作产生脏数据。这里可以利用录制工具,重放请求,验证结果正确性。

3、风险消除

对于防重入可总结三点,前端加入防重复点击设置,接口层加入锁机制,db层需要加入唯一键设置。



  • 真实案例

在商家会员卡充值购买的流程中,nmq故障情况下,购买结果页显示充值失败,但是卡中余额却一直在直线增加,原因是充值接口没有做到可重入,这个case幸好在线下及时发现,否则后果不堪设想。

商家会员卡涉及到的购买流程如右下图所示:



用户提交订单并且钱包处理完成后,钱包回调交易模块的payresult接口,交易模块验证通过之后,会调用商家会员卡的rechargemoney接口给商家会员卡充值。为了提高充值接口的可用性,与交易模块有个约定了一个机制:若调用rechargemoney返回的errno不为0 ,则投入nmq重试三分钟,三分钟之内的重试均没有成功,才触发自动退款。商家会员卡模块的充值接口rechargemoney的流程图如下图所示:



在rechargemoney接口处理过程中,有一个防频繁重入的判定redis锁过程,expireTime设置时间为10s,10s内会拦截过来的重复请求,直接返回。

上述过程可以看到,前端是有无限重试策略的,因此可以认为前端无防重入,那么看接口层锁机制,重试时间3min明显大于锁有效时间10s,因此相同请求10s后锁机制也失效,再看db层,插入order_id和其他营销信息,数据库中并没有设置order_id为唯一键,因此该接口彻底失守,没有做到可重入,相同订单可以重复插入成功,从而导致业务表现为同一订单多次重复充值。

对于该案例,改进方案是首先将锁有效时间设置大于一切来源的重试时间,其次在db充值记录表中将orderid设置为主键,双重保护该接口做到可重入。


五、超时设置不合理

1、风险定义

顾名思义,就是超时并没有根据系统真实表现科学的设置。

2、风险影响

就像下图化学反应一样,不合理的超时实际设置并不会产生真正影响,但是遇到网络故障,依赖超时时,后果不堪设想。



模块交互必设超时,这是基本要求,但是超时设置过长、过短可能会适得其反。不合理超时设置主要表现为①交互超时时间设置过长,比如5s甚至10s的超时②下游超时时间大于上游超时时间。

交互超时重试时间过长,在下游偶尔出现网络抖动时连接被hang住,接口耗时增加,并且降级模块处理能力。下游超时>上游超时,上游超时后断开连接引发重试,下游还在继续上次运算(此时已经没有意义),下游负载增加N倍(取决于重试次数设置和发生重试的层数),使得系统性能急剧下降甚至雪崩。

3、风险识别

①超时时间设置过长(比如数据库connect超时1s,模块读写超时5s)

②下游超时时间大于上游超时时间。

4、风险消除

从系统整体考虑,并且结合重试和本模块计算时间的影响。下游超时<上游超时;超时时间不宜过长,根据下游接口性能设置;对于弱依赖的服务交互,超时时间更不能过长,以免弱依赖阻塞主流程。


  • 真实案例:如下图,该接口调用redis超时时间超过2s,然而Redis性能极好,单线程阻塞性server,这种长耗时会阻塞其他请求,很容易引起系统雪崩,应该把redis连接超时时间修改适当小。



六、重置不合理

1、风险定义

顾名思义,就是重试并没有根据系统真实表现科学的设置。

2、风险影响

任何网络交互都可能失败,为了保证最终交互成功,通常交互失败/超时、数据错误后再次与该模块交互,即发生了重试。重试的次数设置不当,轻者交互成功率不达标,业务失败率增高,严重者引发系统雪崩。

3、风险识别

查看框架配置文件中重试次数配置,是否简单粗暴的经验值设定重试次数,比如一律重试3次,查看代码中逻辑控制的重试限制(这种很隐蔽)。

4、风险消除

相对于固定的重试序列,随机重试序列也可能给系统带来风险,例如可能会降低下游模块的cache命中率,降低系统性能,甚至引起雪崩。

评估重试机制:

1) 真的需要在每一层都努力重试吗?

2) 真的需要这么多次重试吗?

3) 真的需要在连接,写,读这三者失败后都重试吗?

  • 按照业务需求和模块性能设置重试次数

  • 弱依赖不用重试也可以

  • 下游模块性能好,基本不会超时,也可以不重试

  • 大部分情况下,重试次数为1已经足够


  • 真实案例

如图为某产品线的架构,整个系统中,上游模块对下游模块所有的交互,重试次数都是设成3次,交互失败包括连接失败,写失败,读失败这三种情形。如果是写和读失败,那么要关闭当前连接,再重新发起连接。



如果一台bs假死,到该bs的请求会超时。(注意区分模块假死和真死,假死情况下,模块端口打开,能够接收上游连接,但是由于各种原因(如连接队列满,工作线程耗尽,陷入死循环等),不会返回任何应答,上游模块必须等待超时才知道失败,连接超时,写超时和读超时都有可能。而在真死情况下,模块端口关闭,或者干脆程序退出,上游模块连接它会很快得到失败返回码,这个返回码由下游模块的操作系统协议栈返回的,如ECONNREFUSED错误码代表端口不存在,连接被拒绝。) 

那么as有1/3的概率需要重试,as重试的过程中,ui可能早就认为as已经超时了,所以ui也开始重试,ui重试的过程中,webserver可能认为ui已经超时了,所以webserver也开始重试……就这样,整个系统的负载急剧增加,到达bs的qps会是平时的27倍,直到系统崩溃为止。


七、IP直连服务方式

1、风险定义

A,B两个系统交互,B系统分布式部署,A-B连接是通过配置B系统所有IP方式。

2、风险影响

当B系统分布式服务中某一台挂掉时,不能做到failover,导致故障影响扩大。

3、风险消除

通过bns或者组的方式进行连接。


  • 真实案例

某产品线依赖服务redis调用均采用ip列表的方式,如果redis proxy出现单机故障,需要人工介入进行切流量止损。单机发单重启修复周期有时会达小时级别,因此线上服务在故障期间会长时间处于切流量状态,高峰期单机房容量会存在风险。如同时有其他机房服务异常,则无法执行既定预案止损。并且如想下掉故障proxy,只能采用发上线单修改线上配置的方式。止损操作复杂,周期长,效率低下,具体case如下:

(1)用户中心redisproxy单机故障,人工切流量止损,恢复服务花费2小时,期间线上处于切流量状态。

(2)商品中心redis proxy单机故障,会存在扣除库存失败的风险。恢复服务花费半小时,后续又再次发生宕机,发单下掉故障proxy。

如上对应前面讲的故障时间,该服务sla月可用性已不足3个9。


八、跨机房请求

1、风险定义

交互的两个模块分别部署在不同机房。

2、风险影响

跨机房交互由于存在网络延时,严重影响接口性能、请求成功率,极大的降低了系统稳定性。

3、风险识别

①配置错误(ODP框架)ral-service中配置的服务后端IP的Tag不能为空(在ral中,会将Tag为空的也认为是本机房)②上游传入idc错误,Idc是完全匹配,nj和nj02就不相同,因此如果上游传入nj02,当前模块的idc是nj,就会找不到对应的Tag而只能使用default。

4、风险消除

主要关注配置是否合理,由于线上配置很难在线下验证正确性,肉眼排查难免遗漏,因此可通过线上机房流量切换演练验证。


九、不合理强/弱依赖

1、风险定义

所谓强依赖就是,请求链路中某个服务失败/结果异常/无结果后,核心逻辑必失败,否则就认为是弱依赖。不合理的强弱依赖有两类,本应该是弱依赖的设置为强依赖,本应该是强依赖的设置为弱依赖。

2、风险影响

系统稳定性取决于调用链中所有依赖稳定性最差的依赖,如果将稳定性较差的服务作为强依赖将严重影响稳定性

3、风险识别

强弱依赖的合理性是需要结合业务判断的,如果业务返回结果不可或缺该依赖,那么就该设置强依赖;如何判断该依赖是否为强依赖可以通过故障模拟验证,如果模拟该依赖异常时导致调用异常,则判断其为强依赖。

4、风险消除

①调整不合理的强弱依赖关系,将业务非强依赖服务降级;②通过系统优化及运维优化等手段提高强依赖的稳定性。③对强依赖结果进行全面校验,保证强依赖故障能够及时被发现。


  • 真实案例

用户下单请求到trade模块,是通过消息队列nmq保证下单后的商户通知功能,通知商户是借助公共服务云推送,这里云推送被实现成了强依赖,也就是当云推送如果失败,返回给本次请求失败。

某次下单高峰期时,云推送出现故障,无法给ios用户推送消息,nmq收到请求失败后,会持续不断的重发,nmq的通道堵塞之后也影响了trade模块向nmq的请求故障不断往上层蔓延,最后用户无法下单。



对于如上案例,工程师最后去掉对云推送强依赖代码,服务才慢慢恢复,但已造成非常大的损失。


十、无效依赖干扰

1、风险定义

服务启动流程中与该依赖建立了连接,但是整个逻辑处理过程中无需依赖该服务,无任何业务关联性。

2、风险影响

其实该风险是不合理依赖的一个特例,无业务关联性的依赖应该及时去除,否则会影响整体服务稳定性。

3、风险识别

与依赖服务只有一次链接交互,无其他交互,就可以初步判断该依赖为无效依赖,为了准确评估可再结合代码排查。


  • 真实案例

某产品线由于配置管理较乱,有个服务每次启动都会判断多个与业务完全不依赖的服务启动情况,这几个依赖服务处于无人维护状态,非常不稳定,从而导致该服务启动失败率非常高。


十一、第三方依赖

1、风险定义

请求的完成,需要依赖产品外的其他服务,都称之为第三方(tp)依赖,按照公司又分为公司外第三方,比如糯米酒店依赖携程服务;公司内第三方,比如passport相对于手百。

2、风险影响

第三方服务的性能,正确性,稳定性直接影响自身服务,尤其是第三方强依赖,当第三方依赖出现异常,很可能导致自身产品受到损失;公司外第三方依赖有些是小型公司,技术和运维能力有限,其服务的性能,正确性、稳定性不是很高。

3、风险识别

第三方依赖的可靠性是不可控的也是我们系统建设中不可避免的,那么只能尽量降低第三方依赖不稳定对自身的影响。

4、风险消除:

  • 尽量避免第三方强依赖;

  • 超时设置,重试设置结合第三方容量,平均响应时间,部署情况;

  • 增加第三方依赖挂掉,假死,接口变更的校验及容错降级处理,从架构和云微商做到各个TP方与自身业务的解耦;

  • 运维上,提高第三方依赖可靠性,使用内网bns,vip请求,且避免跨机房交互。


  • 真实案例

某产品线依赖A,B,C三个tp方数据进行汇总展示,每次都需要调用三方都有结果时再进行聚合,否则认为整个流程失败,而三个tp方稳定性不尽相同,其中B是个小公司,经常出现故障,导致自身服务经常故障。

对此工程师对各个TP方加上了全面校验,当验证故障后自动调用降级操作,去掉该tp依赖。从此服务稳定性大大提升。


十二、缓存穿透

1、风险定义

前端请求一个肯定不存在的key,导致每次请求都会请求后端原始数据,使得缓存被“穿透”,当该类请求高并发时,那么后端压力凸显。

2、风险影响

缓存穿透后,每个请求都会到达后端服务,对后端服务压力突增;当缓存穿透的并发较高(尤其是恶意攻击),后端服务很可能被压垮,导致整个系统瘫痪。

3、风险原因

一种可能是对于主从分离系统,缓存失效时间小于主从延迟时间,尤其是跨机房的主从分离,主从延迟在某些时候会达到数秒甚至数十秒,这是如果缓存时间设置过小,就会导致所有缓存读写记过均为失效结果,进而请求后端服务获取新的数据。另一种可能是查询结果为空的情况。

4、风险消除

  • 对于查询结果为空的情况也进行缓存,缓存时间设置短一点,或者该key对应的数据insert了之后清理缓存;

  • 对于一定不存在的key进行过滤,把这些key放到一个大的bitmap上;

  • 设计的时候考虑,当缓存失效时,系统服务的情况及应对措施。


十三、缓存失效/缓存雪崩

1、风险定义

大量缓存同时过期失效,前端请求同时到达后端服务。

2、风险影响

当并发量足够大(比如秒杀,抢购),后端服务很可能被压垮,导致整个系统雪崩。

3、风险识别

缓存设置时间相同,失效周期也相同,导致多个缓存同时失效。

4、风险消除

  • 不同的key,设置不同的过期时间,让缓存失效的时间点尽量散列均匀;

  • 在缓存试下后,通过加锁或者队列来控制读数据库读写缓存的线程数量(比如对某个key只允许一个线程查询和写缓存,其他线程等待);

  • 做二级缓存,A为原始缓存,A2位拷贝缓存,A1失效时,可以访问A2A1缓存失效设置为较短,A2设置为长期。


  • 真实案例

某产品线监控发现机器A机器的8688端口挂掉了,经追查发现一个广告配置下发的接口(/api/v1/ipid)挂掉了,据统计,前一天23点到当日9点之间,该接口被访问了400+次,正常来讲,这种广告配置下发的接口一天最多几百个请求量。

经查,客户端有一个零点定时触发策略,零点会同时启动很多服务,平时并发请求会命中缓存,不会造成太大压力,可是当时正赶上缓存时间到期,大量请求将服务接口压死,端口挂掉。

对此临时方案是在接入层nginx配置文件中加入了流量控制机制,用lua脚本来将零点的请求屏蔽掉,长期方案是避免这种缓存集体失效的情况。


十四、 架构耦合不合理

1、风险定义

系统架构和设计上存在着耦合,包括模块耦合、接口耦合、消息队列耦合。具体体现在,主次不分的功能在一个模块或者接口中实现,nmq中不同重要性的命令耦合在同一个module中。

2、风险影响

整个系统稳定性<最不稳定的功能稳定性,不重要的功能可能拖垮重要功能

3、风险消除

整体思路就是,重要与不重要拆分,实时与非实时拆分,在线与离线拆分,根本上解决就是架构解耦,但是系统发展到一定阶段再拆分代码成本很高,这里可以通过运维方法控制解耦,具体见如下案例。


  • 真实案例

某产品线的一级服务和二级服务共同依赖一个基础服务,由于二级服务的一个bug拖垮基础服务,从而导致一级服务不可用,对此解决方案是通过运维将不同上游流量分开。



十五、缓存耦合不合理

思想同2.1.14这里不再赘述。


总结


本文给出了常见的15种架构设计风险,希望大家能够在实际工作中参考审视自己系统是否也存在同样的风险,尽早消除,提高稳定性!




Powered by emlog 京ICP备12006971号-1 sitemap