资深运维司机未必都明白线上常见故障的最优解决方案你是怎么解决的?以下是笔者根据15年互联网研发经验总结的多个上线失败真实案例。本文图片不多,但是内容很干货!先了解,学以致用!故障一:速查JVM频繁FULLGC在分享这个案例之前,先说说哪些场景会导致频繁FullGC:内存泄漏(代码有问题,对象引用没有及时释放,导致对象没有被及时回收)。无限循环。大物体。尤其是大目标,百分之八十以上的案件都是他。那么大对象从何而来?数据库(包括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后堆内存使用率都比之前高:图片来自网络。framework)存储商品数据,商品数量不算多,几十万。如果只存储热销产品,内存占用不会太大,但如果存储所有产品,内存就不够用了。前期我们给每条缓存记录都加上了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订单id,主键状态订单状态created_time订单创建时间id是表的主键,created_time字段是普通索引。聚簇索引(主键id)。id(索引)状态created_time1UNPAID2020-01-0107:30:002UNPAID2020-01-0108:33:003UNPAID2020-01-0109:30:004UNPAID2020-01-0109:39:005UNPAID2020-01-0109:50:00正常索引(created_time字段)。created_time(索引)id(主键)2020-01-0109:50:0052020-01-0109:39:0042020-01-0109:30:0032020-01-0108:33:0022020-01-0107:30:001定时任务每小时运行一次,每次取消一小时前两小时内所有未支付的订单。例如,上午11:00,上午8: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消耗比较明显)。外网带宽很贵,我们还是要节俭使用!本文分享的案例均来自作者的亲身经历,希望对读者有所帮助。