一、同步打印日志的陷阱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编码器>
