事故描述是老规矩,先来看看事故过程:某天,从6点32分开始有少量用户访问app,会出现首页访问异常,到7点20点,首页服务大规模不可用,7点36分问题解决。事故全过程如下:6点58分发现报警,同时在群内反馈首页发现网络繁忙。考虑到商店列表服务是前几天晚上上线的,所以考虑回滚代码,紧急处理问题。7点07分,先后联系XXX,检查解决问题。7点36分,代码回滚,服务恢复正常。事故根源事故代码模拟如下:publicstaticvoidtest()throwsInterruptedException,ExecutionException{Executorexecutor=Executors.newFixedThreadPool(3);CompletionServiceservice=newExecutorCompletionService<>(executor);service.submit(newCallable(){@OverridepublicStringcall()throwsException{return"HelloWorld--"+Thread.currentThread().getName();}});}首先抛出问题,我们稍后会详细说明。问题的根源在于ExecutorCompletionService没有调用take和poll方法。正确的写法如下:publicstaticvoidtest()throwsInterruptedException,ExecutionException{Executorexecutor=Executors.newFixedThreadPool(3);CompletionServiceservice=newExecutorCompletionService<>(executor);service.submit(newCallable(){@OverridepublicStringcall()throwsException{return"HelloWorld--"+Thread.currentThread().getName();}});service.take().get();}一行代码抛出Bloodycase,而且不容易被发现,因为OOM是一个内存增长缓慢的过程,稍有不慎就会被忽略.如果这个代码块的调用次数少,很可能几天甚至几个月就会被雷。运维人员回滚或者重启服务器确实是最快的方式,但是如果事后不快速分析OOM代码,而且很不幸回滚的版本也有OOM代码,那就悲催了。刚才说了,如果流量小,可以通过回滚或者重启的方式释放内存;但是在流量大的情况下,除非你回滚到正常版本,否则GG会失败。讨论问题的根源接下来,让我们讨论问题的根源。为了更好的理解ExecutorCompletionService的“套路”,我们以ExecutorService作为对比,可以让我们更好的理解在哪些场景下使用ExecutorCompletionService。先看ExecutorService代码:(建议往下跑,下面的代码建议吃饭的时候不要看,味道有点重!不过好理解orz)publicstaticvoidtest1()throws异常{ExecutorServiceexecutorService=Executors.newCachedThreadPool();ArrayList>futureArrayList=newArrayList<>();System.out.println("公司要你通知大家你开车去接人");Futurefuture10=executorService.submit(()->{System.out.println("总裁:我在家吹大号,最近拉肚子,要蹲一个小时才能出来.");TimeUnit.SECONDS.sleep(10);System.out.println("社长:1个小时了,我吹完大号了,过来拿");return"总统吹完大号了";});futureArrayList.add(future10);Futurefuture3=executorService.submit(()->{System.out.println("研发:我在家吹大号,蹲3分钟,等会儿出来,你可以待会儿来接我");TimeUnit.SECONDS.sleep(3);System.out.println("R&D:我在3分钟内完成了大号。快来领取吧");return"大号研发完成";});futureArrayList.add(future3);Futurefuture6=executorService.submit(()->{System.out.println("中层管理:我要在家蹲10分钟,等会儿出来,你等会儿来接我吧");TimeUnit.SECONDS.sleep(6);System.out.println("Middle-中层管理:10分钟我的大号就完成了来,来接”);return“中层管理上传完毕”;});futureArrayList.add(future6);TimeUnit.SECONDS.sleep(1);System.out.println("所有通知都结束了,稍等。");try{for(Futurefuture:futureArrayList){StringreturnStr=future.get();System.out.println(returnStr+",你去接他");}Thread.currentThread().join();}catch(Exceptione){e.printStackTrace();}}三个任务,每个任务执行时间为10s,3s,分别为6s.通过JDK线程poo的submit提交这三个任务l可调用任务:step1:主线程将三个任务提交到线程池,将对应返回的Future存放到List中,然后执行“所有通知都结束了,等着吧。》这一行输出语句。Step2:循环执行future.get()操作,阻塞等待。最后的结果是:先通知会长,也先接会长。等了一个小时,研发和中层接到总裁后就被接走了。虽然他们已经做完了工作,但是还要等总裁做完~~最长-10s的异步任务先进入链表执行,所以在循环中获取到10s的任务结果时,get操作会被阻塞直到执行完10s的异步任务。即使3s和5s的任务早就执行完了,也要阻塞等待10s的任务执行完。看到这里,尤其是做网关业务的同学可能会有共鸣。一般来说,网关RPC会在下游调用N个以上的接口,如下图所示:如果都走ExecutorService方法,正好前面任务调用的接口消耗时间长,比较悲催同时阻塞和等待。于是ExecutorCompletionService应运而生。作为任务线程的合理控制者,“任务规划器”的称号名副其实。同场景ExecutorCompletionService代码:publicstaticvoidtest2()throwsException{ExecutorServiceexecutorService=Executors.newCachedThreadPool();ExecutorCompletionServicecompletionService=newExecutorCompletionService<>(executorService);你开车去接人");completionService.submit(()->{System.out.println("社长:我家里放了个大号,最近有点慢拉,之前要蹲一个小时我可以出来了,等会儿你来接我。");TimeUnit.SECONDS.sleep(10);System.out.println("社长:1个小时了,大号吹完了。快来拿吧");return"总裁拿完大号了";});completionService.submit(()->{System.out.println("R&D:我要在家拿大号了我马上要蹲3分钟你可以出来接我");TimeUnit.SECONDS.sleep(3);System.out.println("R&D:我3分钟就完成了大号。来来接我");return"R&DisonFinishedthetuba";});completionService.submit(()->{System.out.println("中层管理:我要在家蹲10分钟拿个大号,等会儿出来,等会来接我");TimeUnit.SECONDS.sleep(6);System.out.println("中层管理:我10分钟就把大号弄完了,来并捡起来");return"中层管完大号";});TimeUnit.SECONDS.sleep(1);System.out.println("所有通知结束,稍等。");//提交了3个异步任务)for(inti=0;i<3;i++){StringreturnStr=completionService.take().get();System.out.println(returnStr+",你去接他");}Thread.currentThread().join();}运行后的结果如下:这个时候相对效率更高一些。虽然先通知会长,但根据大家取大号的速度,谁先完成就先去接另一个,不用等大号时间最长的会长(现实生活中,建议使用第一种方法,不要等总裁的后果emmm哈哈哈)。放在一起比较输出结果:两段代码差别很小。ExecutorCompletionService在获取结果时使用:completionService.take().get();为什么先用take()再用get()????我们看源码:CompletionService接口及接口的实现类ExecutorCompletionService是CompletionService接口的实现类:然后按照ExecutorCompletionService的构造方法,可以看到入参需要传递一个线程池对象,默认队列是LinkedBlockingQueue,但是还有另外一种构造方法可以指定队列类型,如下两张图,两种构造方法。默认LinkedBlockingQueue的构造方法:可选队列类型的构造方法:submit两种任务提交方式都有返回值。我们的示例中使用了Callable类型的第一个方法。比较ExecutorService和ExecutorCompletionService的提交方法,可以看出区别。ExecutorService:ExecutorCompletionService:区别在于QueueingFuture。这是什么功能?我们继续跟进:QueueingFuture继承自FutureTask,红线标记的位置重写了done()方法。将任务放入completionQueue队列。当任务执行完成后,任务会被放入队列中。此时completionQueue队列中的任务都是done()已经完成的任务,而这个任务就是我们一一拿到的future结果。如果调用completionQueue的task方法,等待的task会被阻塞。我们等待的一定是一个完成的future,我们可以通过调用.get()方法立即得到结果。看到这里,相信大家应该有点明白了:我们需要关注每个任务在使用ExecutorServicesubmit提交任务后返回的future,但是CompletionService跟踪这些future,重写了done方法让你等到一定要完成CompletionQueue队列中的任务。作为网关RPC层,我们不需要因为某个接口响应慢而拖累所有请求,可以在处理响应最快的业务场景中使用CompletionService。但是,注意,注意,注意也是这次事故的核心。只有调用ExecutorCompletionService下面三个方法中的任意一个,才会将阻塞队列中的任务执行结果从队列中移除,释放堆内存。由于业务不需要使用任务的返回值,所以没有调用take和poll方法。导致堆内存没有释放,堆内存会随着调用次数的增加而不断增长。因此,如果业务场景中不需要使用任务返回值,就不要使用CompletionService。如果使用,记得将任务执行结果从阻塞队列中移除,避免OOM!总结知道了事故原因,我们来总结一下方法论。毕竟孔子说:反省自己,反省自己的错误,修身养性!上线前:严格的codereview习惯,一定要交给后面的人看,毕竟自己写的代码是看不出问题的,相信每个程序员都有这个信心(这个后续意外可能是反复提到,很重要)上线记录——上线前记下最后一个可以回滚的包版本(给自己留个后路),确认回滚后业务是否可以降级,如果不能降级,必须严格加长线上版本上线后监控周期:继续关注内存的增长(这部分很容易被忽略,大家对内存的关注比对CPU使用的关注要少),继续关注内存的增长CPU使用率、GC情况、线程数是否增加、是否频繁FullGC等。关注服务性能告警,tp99、999、max是否有明显增加