对每一个程序员而言,故障都是悬在头上的达摩克利斯之剑,都唯恐避之不及,如何避免故障是每一个程序员都在苦苦追寻希望解决的问题。对于这一问题,大家都可以从需求分析、架构设计、代码编写、测试、code review、上线、线上服务运维等各个视角给出自己的答案。
我们大部分服务都是如下的结构,既要给使用方使用,又依赖于他人提供的第三方服务,中间又穿插了各种业务、算法、数据等逻辑,这里面每一块都可能是故障的来源。如何避免故障?我用一句话概括 : 怀疑第三方,防备使用方,做好自己。
1. 怀疑第三方
坚持一条信念:“所有第三方服务都不可靠”,不管第三方什么天花乱坠的承诺。基于这样的信念,我们需要有以下行动。
1.1 有兜底,制定好业务降级方案
如果第三方服务挂掉怎么办?我们业务也跟着挂掉?显然这不是我们希望看到的结果,如果能制定好降级方案,那将大大提高服务的可靠性。举几个例子以便大家更好的理解。比如我们做个性化推荐服务时,需要从用户中心获取用户的个性化数据,以便代入到模型里进行打分排序,但如果用户中心服务挂掉,我们获取不到数据了,那么就不推荐了?显然不行,我们可以在 cache 里放置一份热门商品以便兜底。又比如做一个数据同步的服务,这个服务需要从第三方获取最新的数据并更新到 mysql 中,恰好第三方提供了两种方式:1)一种是消息通知服务,只发送变更后的数据;2)一种是 HTTP 服务,需要我们自己主动调用获取数据。我们一开始选择消息同步的方式,因为实时性更高,但是之后就遭遇到消息迟迟发送不过来的问题,而且也没什么异常,等我们发现一天时间已过去,问题已然升级为故障。合理的方式应该两个同步方案都使用,消息方式用于实时更新,HTTP 主动同步方式定时触发(比如1小时)用于兜底,即使消息出了问题,通过主动同步也能保证一小时一更新。
有些时候第三方服务表面看起来正常,但是返回的数据是被污染的,这时还有什么方法兜底吗?有人说这个时候除了通知第三方快速恢复数据,基本只能干等了。举个例子,我们做移动端的检索服务,其中需要调用第三方接口获取数据来构建倒排索引,如果第三方数据出错,我们的索引也将出错,继而导致我们的检索服务筛选出错误的内容。第三方服务恢复数据最快要半小时,我们构建索引也需要半小时,即可能有超过 1 个多小时的时间检索服务将不能正常使用,这是不可接受的。如何兜底呢?我们采取的方法是每隔一段时间保存全量索引文件快照,一旦第三方数据源出现数据污染问题,我们先按下停止索引构建的开关,并快速回滚到早期正常的索引文件快照,这样尽管数据不是很新(可能1小时之前),但是至少能保证检索有结果,不至于对交易产生特别大的影响。
1.2 遵循快速失败原则,一定要设置超时时间
某服务调用的一个第三方接口正常响应时间是 50 ms,某天该第三方接口出现问题,大约有 15% 的请求响应时间超过 2s,没过多久服务 load 飙高到 10 以上,响应时间也非常缓慢,即第三方服务将我们服务拖垮了。为什么会被拖垮?没设置超时!我们采用的是同步调用方式,使用了一个线程池,该线程池里最大线程数设置了 50,如果所有线程都在忙,多余的请求就放置在队列里中。如果第三方接口响应时间都是 50 ms 左右,那么线程都能很快处理完自己手中的活,并接着处理下一个请求,但是不幸的是如果有一定比例的第三方接口响应时间为 2 s,那么最后这 50 个线程都将被拖住,队列将会堆积大量的请求,从而导致整体服务能力极大下降。
正确的做法是和第三方商量确定个较短的超时时间比如 200 ms,这样即使他们服务出现问题也不会对我们服务产生很大影响。
1.3 适当保护第三方,慎重选择重试机制
需要结合自己的业务以及异常来仔细斟酌是否使用重试机制。比如调用某第三方服务,报了个异常,有些同学就不管三七二十一就直接重试,这样是不对的,比如有些业务返回的异常表示业务逻辑出错,那么你怎么重试结果都是异常;又如有些异常是接口处理超时异常,这个时候就需要结合业务来判断了,有些时候重试往往会给后方服务造成更大压力,启到雪上加霜的效果。
2. 防备使用方
这里又要坚持一条信念:“所有的使用方都不靠谱”,不管使用方什么天花乱坠的保证。基于这样的信念,我们需要有以下行动。
2.1 设计一个好的 API(RPC、Restful),避免误用
过去两年间看过不少故障,直接或间接原因来自于糟糕的接口。如果你的接口让很多人误用,那要好好反思自己的接口设计了,接口设计虽然看着简单,但是学问很深,建议大家好好看看 Joshua Bloch 的演讲《How to Design a Good API & Why it Matters(如何设计一个好的 API 及为什么这很重要)》以及《Java API 设计清单》。
下面简单谈谈我的经验:
public List<Integer> test() {
try {
...
} catch (Exception e) {
return Collections.emptyList();
}
}
2.2 流量控制,按服务分配流量,避免滥用
相信很多做过高并发服务的同学都碰到类似事件:某天 A 君突然发现自己的接口请求量突然涨到之前的 10 倍,没多久该接口几乎不可使用,并引发连锁反应导致整个系统崩溃。为什么会涨 10 倍,难道是接口被外人攻击了,以我的经验看一般内部人“作案”可能性更大。之前还见过有同学 mapreduce job 调用线上服务,分分钟把服务搞死。如何应对这种情况?生活给了我们答案:比如老式电闸都安装了保险丝,一旦有人使用超大功率的设备,保险丝就会烧断以保护各个电器不被强电流给烧坏。同理我们的接口也需要安装上“保险丝”,以防止非预期的请求对系统压力过大而引起的系统瘫痪,当流量过大时,可以采取拒绝或者引流等机制。具体限流算法参见《接口限流实践》一文。
2.3 做好自己
做好自己是个非常大的话题,从需求分析、架构设计 、代码编写、测试、code review、上线、线上服务运维等阶段都可以重点展开介绍,这次简单分享下架构设计、代码编写上的几条经验原则。
2.3.1 单一职责原则
对于工作了两年以上的同学来说,设计模式应该好好看看,我觉得各种具体的设计模式其实并不重要,重要的是背后体现的原则。比如单一职责原则,在我们的需求分析、架构设计、编码等各个阶段都非常有指导意义。在需求分析阶段,单一职责原则可以界定我们服务的边界,如果服务边界如果没界定清楚,各种合理的不合理的需求都接,最后导致服务出现不可维护、不可扩展、故障不断的悲哀结局。对于架构来讲,单一职责也非常重要。比如读写模块放置在一起,导致读服务抖动非常厉害,如果读写分离那将大大提高读服务的稳定性(读写分离);比如一个服务上同时包含了订单、搜索、推荐的接口,那么如果推荐出了问题可能影响订单的功能,那这个时候就可以将不同接口拆分为独立服务,并独立部署,这样一个出问题也不会影响其他服务(资源隔离);又比如我们的图片服务使用独立域名、并放置到cdn上,与其它服务独立(动静分离)。从代码角度上讲,一个类只干一件事情,如果你的类干了多个事情,就要考虑将他分开。这样做的好处是非常清晰,以后修改起来非常方便,对其它代码的影响就很小。再细粒度看类里的方法,一个方法也只干一个事情,即只有一个功能,如果干两件事情,那就把它分开,因为修改一个功能可能会影响到另一个功能。
2.3.2 控制资源的使用
写代码脑子一定要绷紧一根弦,认知到我们所在的机器资源是有限的。机器资源有哪些?CPU、内存、网络、磁盘等,如果不做好保护控制工作,一旦某一资源满负荷,很容易导致出现线上问题。
2.3.2.1 CPU 资源怎么限制
2.3.2.2 内存资源怎么限制