查看原文
其他

如何健壮你的后端服务?

程序猿DD 2019-07-13

作者: Ali猿来如此  

来源:服务端思维


对每一个程序员而言,故障都是悬在头上的达摩克利斯之剑,都唯恐避之不及,如何避免故障是每一个程序员都在苦苦追寻希望解决的问题。对于这一问题,大家都可以从需求分析、架构设计、代码编写、测试、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 设计清单》。


下面简单谈谈我的经验:

  • 遵循接口最少暴露原则。使用方用多少接口我们就提供多少,因为提供的接口越多越容易出现乱用现象,言多必失嘛。此外接口暴露越多自己维护成本就越高。

  • 不要让使用方做接口可以做的事情。如果使用方需要调用我们接口多次才能进行一个完整的操作,那么这个接口设计就可能有问题。比如获取数据的接口,如果仅仅提供 getData(int id) 接口,那么使用方如果要一次性获取 20 个数据,它就需要循环遍历调用我们接口 20 次,不仅使用方性能很差,也无端增加了我们服务的压力,这时提供 getDataList(List idList) 接口显然是必要的。

  • 避免长时间执行的接口。还是以获取数据方法为例:getDataList(List idList)。假设一个用户一次传 1w 个 id 进来,我们的服务估计没个几秒出不来结果,而且往往是超时的结果,用户怎么调用结果都是超时异常,那怎么办?限制长度,比如限制长度为 100,即每次最多只能传 100 个 id,这样就能避免长时间执行,如果用户传的 id 列表长度超过100就报异常。加了这样限制后,必须要让使用方清晰地知道这个方法有此限制。之前就遇到误用的情况,某用户一个订单买了超过 100 个商品,该订单服务需要调用商品中心接口获取该订单下所有商品的信息,但是怎么调用都失败,而且异常也没打出什么有价值的信息,后来排查好久才得知是商品中心接口做了长度限制。怎么才能做到加了限制,又不让用户误用呢?两种思路:1)接口帮用户做了分割调用操作,比如用户传了 1w 个 id,接口内部分割成 100 个 id 列表(每个长度 100),然后循环调用,这样对使用方屏蔽了内部机制,对使用方透明;2)让用户自己做分割,自己写循环显示调用,这样需要让用户知道我们方法做了限制,具体方法有:1)改变方法名,比如getDataListWithLimitLength(List idList); 2)增加注释;3)如果长度超过 100,很明确地抛出异常,很直白地进行告知。

  • 参数易用原则。避免参数长度太长,一般超过 3 个后就较难使用,那有人说了我参数就是这么多,那怎么办?写个参数类嘛!此外,避免连续的同类型的参数,不然很容易误用。能用其它类型如 int 等的尽量不要用 String 类型,这也是避免误用的方法。

  • 异常。接口应当最真实的反应出执行中的问题,更不能用聪明的代码做某些特别处理。经常看到一些同学接口代码里一个 try catch,不管内部抛了什么异常,捕获后返回空集合。这让使用方很无奈,很多时候不知道是自己参数传的问题,还是服务方内部的问题,而一旦未知就可能误用了。


  1. public List<Integer> test() {

  2.        try {

  3.            ...

  4.        } catch (Exception e) {

  5.            return Collections.emptyList();

  6.        }

  7. }


2.2 流量控制,按服务分配流量,避免滥用


相信很多做过高并发服务的同学都碰到类似事件:某天 A 君突然发现自己的接口请求量突然涨到之前的 10 倍,没多久该接口几乎不可使用,并引发连锁反应导致整个系统崩溃。为什么会涨 10 倍,难道是接口被外人攻击了,以我的经验看一般内部人“作案”可能性更大。之前还见过有同学 mapreduce job 调用线上服务,分分钟把服务搞死。


如何应对这种情况?

生活给了我们答案:比如老式电闸都安装了保险丝,一旦有人使用超大功率的设备,保险丝就会烧断以保护各个电器不被强电流给烧坏。同理我们的接口也需要安装上“保险丝”,以防止非预期的请求对系统压力过大而引起的系统瘫痪,当流量过大时,可以采取拒绝或者引流等机制。具体限流算法参见《接口限流实践》一文。


2.3 做好自己


做好自己是个非常大的话题,从需求分析、架构设计 、代码编写、测试、code review、上线、线上服务运维等阶段都可以重点展开介绍,这次简单分享下架构设计、代码编写上的几条经验原则。


2.3.1 单一职责原则


对于工作了两年以上的同学来说,设计模式应该好好看看,我觉得各种具体的设计模式其实并不重要,重要的是背后体现的原则。比如单一职责原则,在我们的需求分析、架构设计、编码等各个阶段都非常有指导意义。在需求分析阶段,单一职责原则可以界定我们服务的边界,如果服务边界如果没界定清楚,各种合理的不合理的需求都接,最后导致服务出现不可维护、不可扩展、故障不断的悲哀结局。


对于架构来讲,单一职责也非常重要。比如读写模块放置在一起,导致读服务抖动非常厉害,如果读写分离那将大大提高读服务的稳定性(读写分离);比如一个服务上同时包含了订单、搜索、推荐的接口,那么如果推荐出了问题可能影响订单的功能,那这个时候就可以将不同接口拆分为独立服务,并独立部署,这样一个出问题也不会影响其他服务(资源隔离);又比如我们的图片服务使用独立域名、并放置到cdn上,与其它服务独立(动静分离)。从代码角度上讲,一个类只干一件事情,如果你的类干了多个事情,就要考虑将他分开。这样做的好处是非常清晰,以后修改起来非常方便,对其它代码的影响就很小。再细粒度看类里的方法,一个方法也只干一个事情,即只有一个功能,如果干两件事情,那就把它分开,因为修改一个功能可能会影响到另一个功能。


2.3.2 控制资源的使用


写代码脑子一定要绷紧一根弦,认知到我们所在的机器资源是有限的。机器资源有哪些?CPU、内存、网络、磁盘等,如果不做好保护控制工作,一旦某一资源满负荷,很容易导致出现线上问题。


2.3.2.1 CPU 资源怎么限制


  • 计算算法优化。如果服务需要进行大量的计算,比如推荐排序服务,那么务必对你的计算算法进行优化,比如笔者曾经对地理空间距离计算这一重度使用的算法进行了优化,取得了较好的效果,详见《地理空间距离计算优化》一文。

  • 锁。对于很多服务而言,没有那么多耗费计算资源的算法,但 CPU 使用率也很高,这个时候需要看看锁的使用情况,我的建议是如无必要,尽量不用显式使用锁。

  • 习惯问题。比如写循环的时候,千万要检查看看是否能正确退出,有些时候一不小心,在某些条件下就成为死循环,很著名的案例就是《多线程下HashMap的死循环问题》。比如集合遍历时候使用性能较差的遍历方式、String + 检查,如果有超过多个 String 相加,是否使用 StringBuffer.append?

  • 尽量使用线程池。通过线程池来限制线程的数目,避免线程过多造成的线程上下文切换的开销。

  • JVM 参数调优。JVM 参数也会影响 CPU 的使用,如《发布或重启线上服务时抖动问题解决方案》。


2.3.2.2 内存资源怎么限制


  • JVM 参数设置。通过 JVM 参数的设置来限制内存使用,JVM 参数调优比较靠经验,有一篇朋友写的好文可以参考《Linux 与 JVM 的内存关系分析》。

  • 初始化 Java 集合类大小。使用 Java 集合类的时候尽量初始化大小,在长连接服务等耗费内存资源的服务中这种优化非常重要。

  • 使用内存池/对象池

  • 使用线程池的时候一定要设置队列的最大长度。之前看过好多起故障都是由于队列最大长度没有限制最后导致内存溢出。

  • 如果数据较大避免使用本地缓存。如果数据量较大,可以考虑放置到分布式缓存如 Redis、Tair 等,不然 gc 都可能把自己服务卡死。

  • 对缓存数据进行压缩。比如之前做推荐相关服务时,需要保存用户偏好数据,如果直接保存可能有 12G,后来采用短文本压缩算法直接压缩到 6G,不过这时一定要考虑好压缩解压缩算法的 cpu 使用率、效率与压缩率的平衡,一些压缩率很高但是性能很差的算法,也不适合线上实时调用。有些时候直接使用 probuf 来序列化之后保存,这样也能节省内存空间。

  • 清楚第三方软件实现细节,精确调优。在使用第三方软件时,只有清楚细节后才知道怎么节约内存,这点我在实际工作中深有体会,比如之前在阅读过lucene的源码后发现我们的索引文件原来是可以压缩的,而这在说明文档中都找不到,具体参考《lucene索引文件大小优化小结》一文。


2.3.2.3 网络资源怎么限制


  • 减少调用的次数。经常看到有同学在循环里用 redis/tair 的 get,如果意识到这里面的网络开销的话就应该使用批量处理;又如在推荐服务中经常遇到要去多个地方去取数据,一般采用多线程并行去取数据,这个时候不仅耗费cpu资源,也耗费网络资源,一种在实际中常常采用的方法就是先将很多数据离线存储到一块 ,这时候线上服务只要一个请求就能将所有数据获取。

  • 减少传输的数据量。一种方法是压缩后传输,还有一种就是按需传输,比如经常遇到的 getData(int id),如果我们返回该 id 对应的 Data 所有信息,一来人家不需要,二来数据量传输太大,这个时候可以改为 getData(int id, Listfields),使用方传输相应的字段过来,服务端只返回使用方需要的字段即可。


2.3.2.4 磁盘资源怎么限制


打日志要控制量,并定期清理。

1)只打印关键的异常日志;

2)对日志大小进行监控报警。我有一次就遇到了第三方服务挂了,然后我这边就不断打印调用该第三方服务异常的日志,本来我的服务有降级方案,如果第三方服务挂了会自动使用其它服务,但是突然收到报警说我服务挂了,登上机器一看才知道是磁盘不够导致的崩溃;

3)定期对日志进行清理,比如用 crontab,每隔几天对日志进行清理;

4)打印日志到远端,对于一些比较重要的日志可以直接将日志打印到远端HDFS文件系统里;


2.3.3 避免单点


不要把鸡蛋放在一个篮子上!从大层次上讲服务可以多机房部署、异地多活;从自己设计角度上讲,服务应该能做到水平扩展。对于很多无状态的服务,通过 nginx、zookeeper 能轻松实现水平扩展;对一些 job 类型的服务,怎么避免单点呢,毕竟只能在一个节点上运行,可以参考《Quartz应用与集群原理分析》一文;对数据服务来说,怎么避免单点呢?简而言之、可以通过分片、分层等方式来实现,后面会有个博文总结。


小结


如何避免故障,我的经验浓缩为一句:“怀疑第三方,防备使用方,做好自己”,大家也可以思考、总结并分享下自己的经验。


-END-

 近期热文:

关注我

点击“阅读原文”,看本号其他精彩内容

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存