很多人在面试的时候都会被问到这样的问题:遇到过哪些系统故障?如何解决?图片来自Pexels。.相信可以帮助你从容应对面试官的问题!本文图片不多,但是内容很干货!理解为主,面试为辅,学以致用!故障一:JVM频繁FULLGC快速排查在分享这个案例之前,先聊聊哪些场景会导致频繁FullGC:内存泄漏(代码有问题,对象引用没有及时释放,导致对象没有被及时回收)死循环大对象,尤其是大对象,占80%以上的情况,那么大对象从哪里来呢?它从哪里来的?数据库(包括MySQL、MongoDB等NoSQL数据库),结果集过大。第三方接口传输的大??对象消息队列,消息过大。根据多年一线互联网经验,大部分情况都是数据库结果集大导致的。好了,现在开始介绍这个线上的故障:在没有任何release的情况下,POP服务(对接第三方商家的服务)突然开始疯狂FullGC,在堆内存监控中观察到没有内存泄漏,并且回滚到之前的版本,问题依旧,尴尬!!!按照常规做法,先用jmap导出堆内存快照(jmap-dump:format=b,file=filename[pid]),然后用mat等工具分析哪些对象占用空间大,然后查看相关参考资料,找到问题代码。这种方法定位问题的时间会比较长。如果是关键业务,长期无法定位解决问题,影响太大。来看看我们的做法:先按照惯例分析堆内存快照,同时其他同学查看数据库服务器网络IO监控。如果数据库服务器的网络IO明显增加,并且时间点匹配,基本可以确定是数据库的大结果集导致了FullGC。找DBA快速定位大SQL(对DBA来说很简单,分分钟搞定,如果DBA不知道怎么定位,那他就被炒鱿鱼了,哈哈),很简单定位SQL后定位代码。这样我们很快就定位到了问题所在。原来是一个接口必须传的参数没有传进去,也没有加校验,导致where的SQL语句后面漏了两个条件,一次查了几万条记录。真是个陷阱!这种方法快得多吗?哈哈,5分钟搞定。当时DAO层是基于Mybatis实现的,有问题的SQL语句如下:select*fromuserwhere1=1andorder_id=#{orderID}anduser_id=#{userID}andcreate_time>=#{createTime}andcreate_time<=#{userID}上述SQL语句的意思是根据orderID查询一个订单,或者查询一个订单的所有订单user根据userID,两个参数传至少一个。但是两个参数都不传,只传了startTime和endTime。于是一次Select就查到了几万条记录。所以我们在使用Mybatis的时候一定要慎用if测试,否则会带来灾难。后来我们把上面的SQL拆成两条。根据订单ID查询订单:select*fromuserwhereorder_id=#{orderID}根据userID查询订单:select*fromuserwhereuser_id=#{userID}andcreate_time>=#{createTime}andcreate_time<=#{userID}故障二:内存泄漏在介绍案例之前,先了解一下内存泄漏和内存溢出的区别。内存溢出:当程序没有足够的内存可以使用时,就会发生内存溢出。内存溢出后,程序基本无法正常运行。内存泄漏:当程序不能及时释放内存,导致内存使用量逐渐增加时,就是内存泄漏。内存泄漏一般不会导致程序无法运行。但是,当连续的内存泄漏累积到内存上限时,就会发生内存溢出。在Java中,如果发生内存泄漏,GC回收是不完全的,每次GC后堆内存使用量会逐渐增加。下图是JVM内存泄漏的监控图。我们可以看到每次GC后堆内存使用率都比之前高。图片来自网络。当时内存泄漏的场景是商品数据存放在本地缓存(公司基础架构团队开发的框架)。产品数量不算多,看着也就几十万。如果只存储热销产品,内存占用不会太大,但如果存储所有产品,内存就不够用了。前期我们给每条缓存记录都加上了7天的过期时间,这样可以保证大部分缓存都是热点。不过后来重构了本地缓存框架,去掉了过期时间。没有过期时间,随着时间的推移,本地缓存越来越大,大量的冷数据也被加载到缓存中。直到有一天收到一条警告短信,提示堆内存过高。我通过jmap(jmap-dump:format=b,file=filename[pid])快速下载了堆内存快照,然后用eclipse的mat工具分析快照,发现里面有大量的商品记录本地缓存。定位到问题后,赶紧让架构组加个过期时间,然后逐个节点重启服务。得益于服务器内存和JVM堆内存监控的加入,及时发现了内存泄漏。不然随着泄漏问题的积累,哪天真OOM了就惨了。所以,除了CPU和内存的运维监控之外,JVM监控对于技术团队来说也是非常重要的。故障三:幂等性多年前,笔者在一家大型电商公司做Java程序员,当时开发了一个积分服务。当时的业务逻辑是,用户下单完成后,订单系统向消息队列发送消息。积分服务收到消息后,给用户积分,并将新产生的积分加到用户已有的积分上。由于网络等原因,消息可能会重复发送,导致消息重复消费。当时笔者还是职场菜鸟,并没有考虑这种情况。因此,上线后偶尔会出现重复积分,即一次订单完成后,用户会被加两点或更多点。后来,我们加了一个点记录表。每条消费消息给用户加分前,根据订单号查询积分记录表。如果没有积分记录,给用户加分。这就是所谓的“幂等性”,即重复操作不影响最终结果。在实际开发中,很多需要重试或者重复消费的场景,必须做到幂等,才能保证结果的正确性。例如,为了避免重复支付,支付接口也应该是幂等的。故障四:缓存雪崩我们经常会遇到需要初始化缓存的情况。比如我们经历过用户系统重构,用户系统的表结构变了,缓存信息也变了。重构完成后上线前,需要初始化缓存,将用户信息批量存储在Reids中。每条用户信息缓存记录的有效期为1天。记录过期后,从数据库中查询最新的数据拉入Redis。Grayscale上线时一切顺利,所以很快就会完整发布。整个上线过程非常顺利,码农们也很开心。然而,第二天,灾难降临了!某一刻,各种告警传来,用户的系统反应突然变得很慢,甚至一时半会儿没有反应。查看监控,用户服务CPU突然飙升(IO等待非常高),MySQL访问量激增,MySQL服务器压力也急剧上升,Reids缓存命中率也跌至极点。依托于我们强大的监控系统(运维监控、数据库监控、APM全链路性能监控),问题很快被定位。原因是Reids中大量用户记录集中失效,获取用户信息的请求在Redis中找不到用户记录,导致大量请求穿透到数据库,瞬间给数据库造成巨大压力。同时,用户服务和其他相关服务也受到影响。这种集中式缓存失效导致大量请求同时渗透到数据库,也就是所谓的“缓存雪崩”。如果没有到达缓存失效时间点,性能测试将检测不到问题。所以我们必须引起大家的注意。因此,当需要初始化缓存数据时,必须保证每条缓存记录的过期时间是离散的。比如我们对这些用户信息设置过期时间,可以采用较大的固定值加上较小的随机值。例如,过期时间可以是24小时+0到3600秒之间的一个随机值。故障五:磁盘IO导致线程阻塞。问题发生在2017年下半年,有一段时间,地理网格服务响应偶尔会变慢,每次持续几秒到几十秒后自动恢复。如果慢反应是连续的,那就好办了。可以直接用jstack抓取线程栈,基本可以快速定位问题。密钥持续时间最多只有几十秒,而且是零星的。一天只发生一两次,有时几天才发生一次,发生的时间不定。人家盯着看,用jstack去手动抓线程栈,显然是不现实的。好吧,既然手动的方法不现实,那还是自动吧,写一个shell脚本,定时自动执行jstack,每5秒执行一次jstack,每次执行的结果放到不同的日志文件中,只保存20000个日志文件。shell脚本如下:#!/bin/bashnum=0log="/tmp/jstack_thread_log/thread_info"cd/tmpif[!-d"jstack_thread_log"];thenmkdirjstack_thread_logfiwhile((num<=10000));doID=`ps-ef|grepjava|grepgaea|grep-v"grep"|awk'{print$2}'`if[-n"$ID"];thenjstack$ID>>${log}finum=$(($num+1))mod=$(($num%100))if[$mod-eq0];thenback=$log$nummv$log$backfisleep5done下次响应变慢的时候,我们找到时间点对应的jstack日志文件,发现有很多线程在logback输出日志的过程中被阻塞。后来我们简化了日志,把日志输出改成异步的。问题解决了。这个脚本真的好用!建议大家保留,以后遇到类似问题时可以使用!故障六:案例分析数据库死锁问题在这之前,我们先来看一下MySQLINNODB。在MySQLINNODB引擎中,主键采用聚簇索引的形式,即索引值和数据记录都存储在B树的叶子节点中,即数据记录和主键键索引一起存在。而普通索引的叶子节点只存储主键索引的值。查询找到普通索引的叶子节点后??,需要根据叶子节点中的主键索引找到聚簇索引的叶子节点,得到其中的具体数据记录。该过程也称为“背表”。发生故障的场景是关于我们商城的订单系统。有一个定时任务,每小时运行一次,取消一小时前所有未支付的订单。客服后台还可以批量取消订单。订单表t_order的结构如下:id是表的主键,created_time字段是一个普通的索引。聚簇索引(主键id):普通索引(created_time字段):定时任务每小时运行一次,每次一小时前两小时内取消所有未支付的订单。例如,上午11:00至10:00未支付的订单将被取消。SQL语句如下:updatet_ordersetstatus='CANCELLED'wherecreated_time>'2020-01-0108:00:00'andcreated_time<'2020-01-0110:00:00'andstatus='UNPAID'客服批量取消订单SQL如下:updatet_ordersetstatus='CANCELLED'whereidin(2,3,5)andstatus='UNPAID'如果同时执行以上两条语句,可能会出现死锁。我们来分析一下原因。第一个定时任务的SQL会先找到created_time普通索引并加锁,然后再找到主键索引并加锁。第一步锁定created_time普通索引:第二步锁定主键索引:第二个客服批取消订单SQL,直接使用主键索引,直接锁定主键索引。我们可以看到定时任务SQL按照5、4、3、2的顺序加锁主键,客服批量取消订单SQL按照2、3、5的顺序加锁主键,当第一个SQL加锁3,准备加锁2,发现2已经被第二条SQL加锁了,所以第一条SQL要等2的锁释放。这时候第二条SQL准备给3加锁,却发现3已经被第一条SQL加锁了,只好等待3的锁释放。两个SQL互相等待对方的锁,就出现了“死锁”。解决方法是从SQL语句中保证加锁顺序一致。或者把客服批量取消订单SQL改成一个SQL操作只取消一个订单,然后在程序中多次执行SQL。如果批量操作的数量不大,这种笨办法也是可行的。故障七:域名劫持先来看看DNS解析是怎么回事。我们在访问www.baidu.com时,首先会根据www.baidu.com去DNS域名解析服务器查询百度服务器对应的IP地址,然后通过HTTP协议访问对应的网站IP地址。DNS劫持是一种互联网攻击方式。通过攻击域名解析服务器(DNS)或伪造域名解析服务器,将目标网站的域名解析为其他IP。导致请求无法访问目标网站,也无法跳转到其他网站。下图:下图是我们经历过的一个DNS劫持案例。看图中红色方框,上图应该是商品图,却显示为广告图。图片有错吗?不,域名(DNS)已被劫持。本来是显示存储在CDN的产品图片,被劫持后显示的是其他网站的广告链接图片。由于当时的CDN图片链接使用的是不安全的HTTP协议,很容易被劫持。后来改成HTTPS,问题就解决了。当然,域名劫持的方式有很多种,HTTPS也不能避免所有的问题。因此,除了一些安全保护措施外,很多企业都有自己的备份域名,一旦发生域名劫持,可以随时切换到备份域名。故障八:带宽资源耗尽因带宽资源耗尽而无法访问系统的情况很少见,但也应该引起大家的重视。让我们来看看之前发生的一起事故。场景是这样的:社交电商分享的每张产品图片都有一个唯一的二维码,用来区分产品和分享者。因此,二维码应该由程序生成。最初,我们使用Java在服务器端生成二维码。前期由于系统访问量小,一直没有出现系统问题。可有一天,运营突然推出了前所未有的优惠大促,系统瞬间访问量翻了数十倍。问题也随之而来,网络带宽直接被占满,并且由于带宽资源被耗尽,导致很多页面请求响应很慢甚至根本没有响应。原因是生成的二维码数量瞬间增加了数十倍。每个二维码都是一张图片,对带宽的压力很大。如何解决?如果服务器无法处理,请考虑客户端。将生成的二维码放入客户端APP进行处理,充分利用用户终端手机。目前Andriod、iOS或React都有生成二维码的相关SDK。这样既解决了带宽问题,又释放了服务器在生成二维码时消耗的CPU资源(生成二维码的过程需要一定的计算量,CPU消耗比较明显)。外网带宽很贵,还是要节约!本文分享的案例均来自作者的亲身经历,希望对读者有所帮助。作者:尔玛读书简介:曾就职于阿里巴巴、每日优鲜等互联网公司,担任技术总监,拥有15年电子商务和互联网经验。编辑:陶家龙来源:转载自微信公众号建筑师的进阶之路