当前位置: 首页 > 后端技术 > Java

高并发系统谨防被一行日志压垮

时间:2023-04-01 16:53:56 Java

一、同步打印日志的陷阱1.1高并发场景下,logback导致线程泄漏。调用logback打印日志时,会加锁。锁的位置是://ch.qos.logback.core.OutputStreamAppender#writeBytesprivatevoidwriteBytes(byte[]byteArray)throwsIOException{if(byteArray!=null&&byteArray.length!=0){this.lock.锁();尝试{this.outputStream.write(byteArray);如果(this.immediateFlush){this.outputStream.flush();}}最后{this.lock.unlock();}}}这意味着同一个appender的日志写入是串行的,在高并发场景下,因为锁的争用,看似简单的一行日志会耗费大量的时间。接下来我们在本地简单模拟一个高并发场景,记录打印一行日志需要多少时间publicstaticvoidmain(String[]args){ExecutorServicethreadPool=newThreadPoolExecutor(500,750,20,TimeUnit.MINUTES,newArrayBlockingQueue<>(1),newThreadFactoryBuilder().setNameFormat("test-log-thread").build(),newThreadPoolExecutor.CallerRunsPolicy());for(inti=0;i<750;i++){LoggerExecutorcommonExecutor=newLoggerExecutor();threadPool.submit(commonExecutor);}}staticclassLoggerExecutorimplementsRunnable{@SneakyThrows@Overridepublicvoidrun(){while(true){longstart=System.currentTimeMillis();记录器。info("在{}处记录信息消息",System.currentTimeMillis());长端=System.currentTimeMillis();长时间=结束-开始;System.out.println(时间);}}}需要说明的是现实中高并发请求应该从一个线程池重复提交到另一个线程池任务来模拟,这里我们简化流程。上图是我记录的logger.info的耗时曲线。从这个统计图我们可以看到,当并发增加时,锁竞争加剧,仅仅打印一行info日志可能需要20-40ms(对比一下,单机打印一行日志一般需要1-2ms)threadonmymachine),并且图中有明显的故障。打印日志耗时超过100ms。原因是OutputStream在缓冲区满后需要进行磁盘刷新,但是这种毛刺在真正的大流量应用中是致命的,可能会导致RPC框架的线程池被吃光,成功正常业务服务的速率会下降。所以在高并发、大流量的场景下打印info日志一定要慎重。1.2大量异常导致性能故障。上一节讲到,在高并发场景下,需要谨慎打印info日志。一般我们只记录系统异常日志。我们对刚才的代码片段做一个小的修改,调用logger.error打印日志,然后统计这行代码的耗时//驱动代码和1.1节一样,这里省略static类LoggerExecutor实现Runnable{@SneakyThrows@Overridepublicvoidrun(){while(true){longstart=System.currentTimeMillis();//logger.info("在{}处记录信息消息",System.currentTimeMillis());logger.error("loginfomessageoccurserror:{}",newRuntimeException());长端=System.currentTimeMillis();长时间=结束-开始;System.out.println(时间);}}}与1.1节相比,一个明显的变化是errorlog的执行平均耗时40-50ms,比1.1节的infolog慢很多。为什么是这样?原因是调用logger.error(String,Throwable)时,为了打印栈,加载了每个调用节点的类。代码位于://ch.qos.logback.classic.spi.PackagingDataCalculator#computeBySTEPprivateClassPackagingDatacomputeBySTEP(StackTraceElementProxystep,ClassLoaderlastExactClassLoader){StringclassName=step.ste.getClassName();ClassPackagingDatacpd=缓存.get(类名);if(cpd!=null){返回cpd;}//注意这个代码Classtype=bestEffortLoadClass(lastExactClassLoader,className);字符串版本=getImplementationVersion(类型);字符串代码位置=getCodeLocation(类型);cpd=newClassPackagingData(codeLocation,version,false);cache.put(className,cpd);returncpd;}在bestEffortLoadClass中则试类加载:privateClassbestEffortLoadClass(ClassLoaderlastGuaranteedClassLoader,StringclassName){Classresult=loadClass(lastGuaranteedClassLoader,className);如果(结果!=null){返回结果;}ClassLoadertccl=Thread.currentThread().getContextClassLoader();如果(tccl!=lastGuaranteedClassLoader){结果=loadClass(tccl,className);}if(result!=null){返回结果;}try{returnClass.forName(className);}catch(ClassNotFoundExceptione1){返回null;}赶上(NoClassDefFoundErrore1){返回空值;}catch(Exceptione){e.printStackTrace();//这是意外的returnnull;}}//ch.qos.logback.classic.spi.PackagingDataCalculator#loadClassprivate类loadClass(ClassLoadercl,StringclassName){if(cl==null){returnnull;}尝试{返回cl。加载类(类名);}catch(ClassNotFoundExceptione1){返回null;}catch(NoClassDefFoundErrore1){返回空值;}catch(Exceptione){e.printStackTrace();//这是意外的returnnull;}}java.lang.ClassLoader#loadClass(java.lang.String)这个方法是众所周知的加载类的接口。其实会锁在className维度这里你应该猜到为什么logger.error比logger.info慢很多。logger.error将打印出异常堆栈。在高并发场景下,如果某个接口频繁抛出异常,则需要每个线程打印错误日志。需要先在异常栈上加载各个类的信息,导致锁竞争,然后在appender维度排队。一个常见的现实场景是下游服务受限或者直接宕机,很容易因为logback导致系统故障。2、异步日志配置不当导致线程泄漏。其实在高并发场景下,一般都会配置日志异步打印。原理大致如下图所示:AsyncAppender将LoggingEvents丢到一个队列中,然后会有一个单独的线程从队列中消费LoggingEvent,分发给需要工作的Appender。因为它避免了直接调用writeBytes,所以性能应该会有很大的提升。我们对logback配置稍做改动,异步打印日志:文件><编码器><模式>%logger{35}-%msg%n以及每次采样的耗时打印日志的效果如下图所示:令人震惊的一幕出现了,在并发量比较大的场景下,异步打印日志的性能比同步差了10倍!为什么是这样?上面说了logback异步日志的实现原理是生产者消费者模型。问题是在大流量的场景下,单线程分配线程的消费能力跟不上生产能力,最后所有的线程都在log打印在阻塞队列上排队。这时候还可以通过arthas看到线程排队的情况。如果这是在线业务系统,业务线程早就被日志的阻塞队列吃掉了,会导致业务响应异常[arthas@9341]$threadThreadsTotal:780,NEW:0,RUNNABLE:13,BLOCKED:745,等待:4,TIMED_WAITING:3,终止:0,内部线程:15IDNAMEGROUPPRIORISTATE%CPUDELTA_TIMEINTERDAEMON10AsyncAppender-Workemain5RUNNAB43.01fse0.true77arthas-command-execsystem5RUNNAB4.90.0100:0.02falsetrue39test-log-threadmain5BLOCKE0.750.0010:0.14falsefalse71test-log-threadmain5BLOCKE0.710.0010:0.137falsetest-truethreadmain5BLOCKE0.710.0010:0.14falsefalse74test-log-threadmain5BLOCKE0.690.0010:0.15falsefalse67test-log-threadmain5BLOCKE0.690.0010:0.14falsefalse-1C2-Compiler-read0.0010:1.68falsetrue69test-log-threadmain5BLOCKE0.660.0010:0.15falsefalse55测试日志线程main5BLOCKE0.660.0010:0.14falsefalse38测试日志线程main5BLOCKE0.650.0010:0.15falsefalse36??测试日志线程main5BLOCKE0.650.014falsefalse28test-log-threadmain5BLOCKE0.650.0010:0.15falsefalse50test-log-threadmain5BLOCKE0.650.0010:0.14falsefalse36??test-log-threadmain5BLOCKE0.640.0010:0.14falsefalse除了上面说了几个高并发大流量场景下特有的坑,这里还有一些其他的坑,因为网上的博客很多,这里不再赘述https://www.elietio。xyz/posts...《低版本 bug 导致 totalSizeCap 参数不生效》logback版本太低,导致SizeAndTimeBasedRollingPolicy不生效。4.最佳实践应该遵循以下原则:[最佳实践1]日志工具对象的logger应该声明为privatestaticfinaldeclarationasprivate是出于安全考虑,防止logger被其他类非法使用.声明为static和final是因为在类的生命周期内不需要改变logger,占用内存少。【最佳实践2】日志字符串通过“+”拼接,占用额外内存,不直观。应使用占位符。【最佳实践3】日志内容和日志级别匹配。debug和trace一般是开发者用来调试程序的,在线应该关闭这类日志。infolog应记录重要且无风险的信息,例如上下文初始化、计划任务执行或远程连接建立。warn日志应该记录可能存在风险但不会影响系统继续运行的错误,例如系统参数配置错误,用户请求参数不正确,或者在一些耗时异常的场景下,比如请求超时,sql执行超过2秒等错误日志用于程序错误打印堆栈,以及程序以外的其他信息问题不应该输出【最BestPractice4】高并发系统应该少记录或者不记录info日志,配置为异步日志。当阻塞队列满时,应采用日志丢失策略,保证业务系统的正常运行