为什么有这篇文章

外卖业务发展一年半有余,我们的业务量经历了一个突飞猛进式的增长,高速的业务成长对我们系统的稳定性和性能提出了一个越来越高的挑战。而且,高速的业务成长必然会带来更快更多的业务需求,需要我们的系统进行持续快速给力的响应。快速的开发和发版节奏,进一步加大了系统稳定性的挑战。

我们的团队当时还很年轻,在这个过程中,不可避免地犯过一些错误、踩过一些坑,造成了一些事故,也确实伤害了一部分用户和商户。 从中,我们接受到了一些血和泪的经验和教训。这些经验教训,我觉得对我们来说,无疑是最重要的财富。作为这一年半载大部分事故的亲历者,我觉得有必要把这些宝贵的财富进行系统地总结和沉淀,力求达到如下目的:

  1. 抽象成几类共性问题,针对某一类问题形成可参考、可借鉴的理论分析、解决之道以及最佳实践,并且配合真实的案例。提前来这看,可以防患于未然;遇到问题时来看,可以辅助快速定位并解决问题
  2. 帮助大家开阔视野,提供一些常见问题的分析思路、方法、技巧、工具与实践,供大家参考

常见事故问题分类及其快速应对

DB

通过对历次CaseStudy的回顾,我们发现和DB有关的问题占了很大一部分。经过分析,这一类问题基本可分为以下三大类:慢查询、主从延迟、DB连接池问题

慢查询

  • 现象:2014年外卖业务发展初期,由于团队对DB的SQL性能相关问题重视程度不够,导致这段时间由于慢查询引发的系统故障较多。慢查询可引发sql响应变慢,DB的thread running数变大,从而导致DB性能恶化,进一步使得应用服务响应时间变长。在高并发的场景下,严重的甚至可能导致应用服务雪崩,从而整个系统不可用。
  • 原因分析:仔细分析以后,发现基本上都是由一个或者几个性能很差的SQL引起的。这些SQL可能是一些统计性的SQL,可能是一些新人写的没有经过review的线上服务的SQL。
  • 解决之道:
    1. 把线上服务与统计服务严格从物理上隔离(包括DB与应用服务器)
    2. 引入sql review机制,对所有人的sql在上线前作严格的review,确保上线后的系统稳定
    3. 加强对新人的sql优化方面的培训(包括索引、explain、profile、慢查询系统、慢查询报表等),制定相关制度和责任人,定期回顾慢查询相关的情况

主从延迟

  • 现象:印象中好几次事故,就是主库的写入QPS由于各种原因,瞬间打的太高,造成主库压力陡增,thread runnings数升高,响应时间下降;同时造成主从延迟,一定时间内主从数据不一致。
  • 原因分析:经过多次事故的原因分析,大部分原因都可以归为以下两类:
    • 代码问题,有一些代码会在瞬间发出大量写入sql,有的时候还会配合多机多线程来发
    • 流量正常增长:比如订单的持续增长,这一部分主要是没有提前根据监控报警,做好容量规划,没有提前作好拆库的计划
  • 解决之道:
    • 设定有效的代码规范和执行的流程机制,确保代码里尽量不要出现瞬时并发发出大量写请求的sql,如果实在有,可以在多条sql之间适当的sleep,减小并发度;
    • 提前部署好监控方案和报警机制,设定合理的报警阈值,提前做好容量规划,确保可以在写qps达到瓶颈之前就已经做好了集群拆分

DB连接池问题

  • 现象:最近很多次事故都和这个有关,尤其是上了atlas以后没有及时对连接池配置进行相应的调整,导致了高峰期获取不到连接,响应时间急剧恶化,服务的运行的线程数飙升,load变高,机器整体性能急剧恶化,甚至导致最终服务的雪崩乃至不可用。
  • 原因分析:
    • 缺乏有效的连接池监控和预警机制,这样不仅没有调优的依据,而且也无法及时感知到业务的变化对连接池的影响。当发生问题时,也无法快速定位到问题
    • 之前各项目没有安排人对连接池的底层和源码进行深入研究,不了解各个参数的内在细节,导致一些参数值设置的不好
    • 当外界发生相关变化(比如响应时间变长、DB请求并发度增加)时,没有及时对连接池参数作出调整
  • 解决之道:
    • 制定有效的连接池监控方案【目前已有,可以监控各种连接数以及从连接池获取连接的时间】,并且根据各自业务场景设置合理的报警阈值
    • 根据监控数据,结合连接池的内部原理和源码分析,制定一套统一的连接池方案和最优连接池配置(某些配置值根据不同的项目略有差异)
    • 当外界发生变化时(比如引入atlas后响应时间略有变长),及时根据监控数据对连接池参数作出调整

并发

经过对历次事故总结,并发问题主要分为:滥用线程池问题和锁等待超时的问题

滥用线程池导致的恶果—“无界队列”的多次血案

  • 现象:很多次事故都和线程池的滥用或者应用不合理,存在直接的关系。典型的case是,在进行一些计算量大的离线job时,为了使得计算更快,于是使用了多线程和线程池。但是线程池一旦使用不合理,在遇到某些突发的外界故障时(比如依赖的底层数据服务不可用、网络不通等),就有可能导致整体服务性能急剧恶化,甚至导致服务整体不可用。
  • 原因分析:大家使用线程池,最常见的一种是喜欢用Jdk自带的FixedThreadPool,这种线程池有一个隐含的细节,就是它里面有一个无界的阻塞队列,用来装任务的。当处理任务的逻辑由于一些外界故障,处理的不够好,就有可能导致线程一直阻塞,并且线程池的线程立即被打满,那么源源不断的任务陆续进来就会很快把队列撑的越来越大,最终内存不堪重负,频繁地爆发full gc,stop the world时间急剧变长,严重影响线上服务的性能。事故当时用jmap dump出了当时现场的堆内存状况,然后倒入到MAT分析,结果发现大部分对象确实就是线程池中任务队列的任务对象。
  • 解决之道:
    1. 根据业务使用场景,合理设置ThreadPool的各项参数(包括corepoolsize、maxpoolsize、task queue的选择、keepAliveTime、队列满时的拒绝策略),这个过程可能需要多次调试。不要一股脑地就用FixedThreadPool,最好是对最大的线程数有一个基本的限制
    2. 在具体的任务处理逻辑中,针对于外界的依赖或者请求的部分,一定要设计好松耦合的策略(比如超时时间)或者提前设计好容错和降级的策略,避免依赖的服务出问题导致自身也一直完全不可用的状态
    3. 做好实时地监控报警方案,包括对响应时间、JVM线程数、GC次数和时间等

锁竞争与锁等待时间过长导致超时的问题

  • 现象:有的时候使用一些锁(包括DB的X锁、线程锁、分布式锁等)不合理,导致会一直在等待获取锁,响应时间急剧恶化。有些情况,还会发现死锁的情况,线程一直等待不返回。
  • 原因分析:为什么会一直等待获取锁?当然是因为之前获取锁的线程,处理逻辑太重或者自身性能变差或者依赖第三方发生故障,一直占有这把锁不释放。比如在用redis或者tair设计分布式锁的时候,没有考虑到当redis或者tair本身不可用的情况。这样一旦redis或者tair出现不可用,这个时候已经获取了锁的线程在释放锁的时候就会出现异常,就可能永远释放不了这把锁,导致下一个需要锁的线程一直获取不到锁或者发生锁等待超时,这样就可能会造成业务异常,严重的可能会导致服务整体性能恶化。
  • 解决之道:
    1. 在设计或者使用各种锁时,一定要考虑各种容错的措施,比如锁本身依赖的第三方服务不可用这个时候怎么办、获取锁的线程运行时间过长导致占用锁的时间过长时怎么办,针对能想到的所有情况考虑进行挼搓或者降级,做不到自动可以先做成手动;
    2. 不要滥用锁,要对锁解决的问题以及带来的影响,再结合具体的业务场景,一起综合考虑得出一个适合业务的方案(线程锁/分布式锁、悲观/乐观锁、获取不到锁时候的策略:重试还是提示)

JVM

load过高问题

  • 现象:有一段时间,外卖poi服务,经常发现load打的很高,一直报警。对线上服务的性能也造成了一定影响。
  • 原因分析:排查了业务量的增长和业务本身逻辑的变动的原因之后,于是开始怀疑是GC的问题。经过对JVM的一系列监控(包括监控系统、GC Log、jstat等)的分析,发现JVM的Young GC异常频繁(每分钟超过30次)。通过jstack dump当时的现场堆栈来分析,发现GC的线程也占了很多,并且都是running状态。于是初步定位,是由于Young GC过于频繁导致了这个问题。
  • 解决之道:调大新生代与老生代的比率,适当增大新生代的大小可以减少Young GC的频率,但是不能调的过大,过大会导致GC时间增大,STW的时间也就会增大。这里面有一个平衡值,可以结合经验值、GC监控以及机器性能的监控,调出一个最优值。

STW时间过长导致请求响应时间变长问题

  • 现象:记得最开始,外卖所有的服务还没有使用CMS GC收集器。这个时候经常发现高峰期,线上某些页面需要等很久才能刷出来,体验很不好。
  • 原因分析:高峰期,访问请求密集,经常会短时间创建和释放很多对象。这样就容易造成老年代频繁被打满,触发老年代的GC。老年代默认用的是并行GC,这种GC全程都是stop the world,阻塞用户线程,造成用户线程持续时间久,响应慢,页面很久才刷出来。
  • 解决之道:
    1. 使用CMS GC(目前大部分线上服务应用都已使用),默认会对年老代使用,配置上对永久代也使用
    2. 根据应用情况,优化GC相关参数(比如新老年代大小比率、MTT值等),使得老年代的回收频率(通常几个小时一次)和单次回收时间达到一个平衡,尽量减少STW的时间

OOM问题

  • 现象:使用了CMS一段时间以后,我们又发现了新的问题。对于一些频繁产生大量对象,然后短时间内释放的业务场合,会容易报out of memory的异常,这个时候服务响应很慢,甚至达到不可用的状态。
  • 原因分析:当出现这种场合时,首先新生代的eden区迅速被打满,触发young gc,这个时候会把eden和from survivor区的存活对象,复制到to survivor区。这个时候发现to survivor区的大小不足以容纳这么多对象,这个时候装不下的对象会晋升到老年代。这个时候可能会发生两种情况:promotion faild和concurrent mode failure,这两种情况都会触发真正的full gc(同时对新生、老年、持久代进行回收),并且这个时候老年代的GC算法会退化成串行GC,整个时间会耗时很长,并且会全程stop the world。这个时候用户线程不能和GC线程并发,被阻塞很长一段时间。最终阻塞的用户线程越来越多,可能导致应用和服务完全不可用。
  • 解决之道:
    1. 优化代码,比如引入一定生命周期的本地缓存,尽量减少瞬间释放大量对象的频率;
    2. 合理调整CMSInitiatingOccupancyFraction、年老代大小等参数的值,尽量避免出现promotion faild和concurrent mode failure两种异常

服务化

超时问题(超时时间没有设置或者设置不合理、超时处理策略选择不合理)

  • 现象:review历次case study,发现这个原因导致的事故出现了好多次。这个事故发生时,会引发一系列连锁反应。比如造成线程阻塞,页面长时间得不到返回;load升高报警,机器性能恶化;MQ消费者线程消费变慢,消息堆积,引发MQ的整体流控,造成其他消息处理也不及时。
  • 原因:直接原因分为两个:
    • 超时时间没有设置或者设置不合理,没有设置或者设置过长可能导致线程阻塞时间过长或者一直阻塞,严重拖慢响应时间,影响用户体验;还可能引起load升高,拖慢机器整体性能,严重时可能发生应用雪崩。超时时间设置过小,可能依赖的第三方服务达不到这个性能的要求,可能会造成用户经常完成不了正常的操作,是一种有损服务,并且有损服务的频率过高,这也是有问题的。
    • 超时处理策略选择不合理,常见的策略有fail fast、fail retry、backup request等。在对超时的比率有预期并且业务可以允许一定低频次的由于超时导致的有损服务的情况下,可以选择fail fast,这个策略可以保证超时请求不会影响其他正常的请求,不会造成系统整体的性能恶化,防止应用雪崩。在这里需要额外注意retry这种策略,retry需要精细化设置retry的总次数和时间间隔。并且需要防止总的超时时间翻倍的情况,针对这种情况,常见的策略是根绝重试的次数设置每一次重试的超时时间占总的超时时间的百分比,这样才能防止总的超时时间变得很长的情况。另外,最近比较流行的backup request方式(介绍:服务器程序中如何设计backup task功能),也是一种比较好的方式,它和retry的区别是,在于它在收到第一个响应以后,会立即给其他所有发过请求的server发cancel request,进一步降低server的负载,防止server性能急剧恶化。
  • 解决之道:
    • 一定要设置超时时间,并且根据具体业务的容忍度和依赖三方服务的性能,设置一个可以接受的合理值;
    • 根据具体业务场景,选择合适的超时策略。特别注意的是retry这种策略,可能造成总超时时间增加,所以要设置每一次超时请求的超时时间占总超时时间的百分比;可能会造成请求风暴,导致服务器性能急剧恶化,加速雪崩,这个时候可以考虑对服务设置过载保护,保证服务在极限压力下的稳定输出

缺乏容错与降级

  • 现象:经事后review,发现有很多次事故发生后,不能在短时间内有效的解决。这个和之前很多应用服务缺乏有效的容错与降级策略存在着直接的关系,如果没有这些策略,一旦发生事故,可能会迅速导致整个应用服务雪崩,服务整体呈现出不可用的状态。
  • 原因:直接原因就是缺乏有效的容错和降级的策略。比如上节说的出现超时以后的fail fast策略,其实也是一种容错的策略,在出现超时以后,调用端捕获异常,开始应用预先设置好的容错策略(比如从本地cache中取值或者给出一个默认值)。类似的容错和降级的策略有很多,我们首选容错策略,没有容错我们考虑降级。通过预先对核心服务和其他服务的梳理,在出现事故时,优先保证核心最短路径服务的可达,然后选择性牺牲部分非核心服务。通过预先设置好的开关,手动或者根据一些预设的条件自动切断一些非核心服务,减轻服务的整体压力,避免非核心服务对核心服务的影响。
  • 解决之道:
    1. 在非核心服务的调用连接处,都设置好容错或者降级的开关。再进一步的话,可以预先设置好一些条件阈值,达到这些阈值后,自动启用这些开关
    2. 新上了一个解决方案或者服务,比如订单高级查询新上了ElasticSearch的解决方案,在刚开始还不成熟的一段时间内,最好保留原有方案,并且设置上容错的开关,一有问题还能切换到原有方案;度过了成熟期以后,服务逐步稳定下来,可以把原有方案去掉,容错开关可以考虑变成降级开关(如果这个业务不算最核心的业务的话),避免出问题时影响整体

缺乏过载保护和资源隔离

  • 现象:我们很多次服务化导致的事故,都是由于一两个调用方调用的一两个接口性能很差,导致整体服务化的性能恶化,最终甚至导致服务雪崩的情况,然后调用端缺乏有效降级,最终导致调用端也整体不可用的情况
  • 原因:直接原因就是很多服务化项目的服务端缺乏有效的过载保护和资源隔离策略,导致一两个接口性能有问题或者调用量突增,迅速导致整体机器的性能恶化,甚至雪崩,极大地影响了本来正常的服务接口。
  • 解决之道:
    1. 针对不同的接口,设置每一个调用来源App的阈值(这些阈值可以分为QPS、流量、最大线程数等),一旦达到这个阈值,我们启用预先设计好的报警策略和过载保护策略。过载保护策略可以根据业务从多种策略中选择,比如客户端丢弃、服务端丢弃、客户端排队、服务端排队、客户端/服务端捕获异常自定义处理等。过载保护的功能,OCTO已经提供支持。
    2. 针对不同的调用来源App,对服务端的集群进行分组,避免不同的调用来源App之间互相影响(如果没有分组,则如果调用来源App1发了一批瞬时调用量很大的请求,导致服务性能恶化,这个时候可能会影响调用来源App2的请求)。自定义分组的功能,OCTO已经提供支持。

缓存

缓存不及时更新

  • 现象:造成数据一定时间内不一致,改动后不能立即生效,可能会影响部分业务
  • 原因:缓存更新的策略选择不合理,比如对一些实时性要求很高的场合,采用过期时间的策略,这样肯定会造成更新不及时;缓存更新时候,如果发生异常,这个时候没有进行针对性的处理(比如重试)
  • 解决之道:
    1. 针对实时性要求很高的场合,我们采用缓存的准实时更新策略,比如使用databus【首选】或者MQ【备用】
    2. 针对更新失败的场景,作针对性的处理,比如用MQ更新的时候,如果更新失败,回复一个ACK失败的响应给MQ中间件Server,MQ Server这个时候会把消息重新投递给消费者,消费者重新处理这个更新逻辑。如果一直失败怎么办?这个时候可能是更新程序出问题了,我们针对MQ Server的同一个消息的重新投递次数设置一个报警阈值,接收到报警,就立即来修复更新程序

缓存写入QPS过高导致性能恶化

  • 现象:有一次事故,关于poi数据缓存的。当时现象是Tair的响应时间急剧拉长,影响了Tair的整体服务性能,造成了应用端服务响应时间急剧恶化,最终导致了应用的雪崩(那个时候也缺乏有效的降级和容错的措施)。
  • 原因:通过tair监控发现,tair当时的写QPS相比于以前,冲到了很高。当时Tair还没有作读和写的线程池隔离,对读写组合的QPS压测和容量规划也做得不够充分,最终一个写的QPS突增就造成了Tair整体性能的恶化。那么Tair的写QPS为什么会冲到很高呢?通过定位代码,发现poi服务在读取缓存数据的时候,如果发生任何异常,都会从DB load数据回设到缓存。这个策略太粗放,不应该什么异常都回设缓存的。
  • 解决之道:
    1. 修复poi缓存的回设逻辑,只是在检测到缓存中无数据的时候,才回设缓存,发生异常时不回设
    2. 推动Tair开发团队,做好压测、提前预警、容量规划和流量控制;并且把读线程池和写线程池隔离,避免互相影响

缓存被击穿

  • 现象:目前因为外卖的业务中,暂时还没有瞬时访问QPS巨大的超热点数据,所以还没有发现过缓存被击穿的事故。但是我觉得目前使用过期时间策略的缓存数据,都有发生这个问题的风险。一旦发生这个问题,会把后端的DB压垮,造成DB的整体性能急剧恶化,进而影响一些其他的核心业务。如果降级策略做的不是非常好,严重时可能导致系统雪崩。
  • 原因:原因很简单,在缓存过期的一瞬间,如果对缓存的瞬间访问QPS过高,这个时候后端的DB可能就无法抗住这个压力。
  • 解决之道:目前业界最常见的方式,是引入一个互斥因子mutex的方式来解决这个问题,互斥因子mutex可以基于redis或者tair之类的分布式锁的方案。具体方案设计和代码例子,详见wiki:常见性能优化策略的总结中的缓存—设计关键点部分。

MQ

消息堆积(可能触发流控导致MQ服务器整体处理消息不及时)

  • 现象:MQ最常见的问题就是消息堆积。一旦消息堆积,不仅会造成堆积队列对应的消费者消费不及时。而且严重的可能会触发流控,造成MQ服务器整体的处理消息不及时,这样可能会影响某些对时间很敏感的关键业务。
  • 原因:造成消息堆积的原因有很多种,最直接的原因就是消费者处理消息慢了。为什么会慢?可能是消费者机器和线程不够;可能是消费者处理逻辑依赖的一些第三方服务出问题了,而调用那块没设置超时;可能是消费者机器本身性能恶化(比如由于频繁full gc导致)
  • 解决之道:
    1. 队列堆积的数量加上监控,并设置上报警阈值,达到阈值及时报警,然后提前定位并解决问题【目前已实现】
    2. 消费者的处理逻辑,需要精细化梳理,对于一些第三方服务或者底层服务的调用处,设置好对应的超时、容错或者降级策略
    3. 如果真是由于没有预料的消息量的瞬时突增,可能这个时候报警了也无法那么快及时处理。为了防止消息迅速堆积到一定水平,导致流控。这个时候可以在消费端,开发一些动态增加消费者线程的逻辑,可以达到某些阈值的时候,自动增加消费者线程,并且投入到消费中。【目前已实现第一版】

常用诊断平台与工具

ps:本篇wiki限于篇幅所限,不会一一详细介绍下面每一个平台和工具。详细知识还望大家自行学习,我会指明一些地址和学习的资料。对于外卖M端的同学,我会定期组织相关的培训,其他的同学感兴趣的也可以参加,可以提前@我。

DB相关:


分为两块,分别可以看slowlog和general log信息,如下图所示:



目前可以用来辅助我们定位问题的,主要是“Processlist”和“执行状态查询”,其中“执行状态查询”主要是在表自助变更执行没成功的时候,可以通过里面的详细日志信息定位问题。

此外,“服务查询管理”功能可以帮你快速查询DB的业务组、服务组、主机、主从身份、端口、联系人、状态、机器配置等信息。首页如下图所示:


每日DB慢查询统计报表与表变更统计报表邮件



explain、profile、processlist、TABLE_STATISTICS表、show index from table_name等常用工具

详情请参阅Mysql官方文档,或者参加我最近的培训。

服务相关:


JVM相关:

jstack

jmap + MAT

jstat  + gc log

OS相关:

性能相关:top(各种参数)、vmstat、free等

磁盘相关:df、dh

文本、查日志相关:grep、vi/emacs、sed、awk、结合管道等

网络相关:lsof -i:port、netstat -anlp | grep port/pid、tcpdump/tcpflow(抓包工具)

  • 无标签