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

关于请求重用的狗屎

时间:2023-04-01 23:16:00 Java

大家好,我是伟伟。之前不是发过这篇文章吗:《千万不要把Request传递到异步线程里面!有坑!》说因为在tomcat中Request是多路复用的,如果在一个Request的生命周期结束后在异步线程中调用相关方法,会导致RequestContaminated,然后观察下一个请求中的一些令人难以置信的场景。但是文章评论区出现了问题,有人问我:由于我的文章主要关注将Request传递给异步线程的操作,所以并没有特别关注Request是如何复用的。.刚刚通过打印日志观察了多路复用的现象:启动项目后,分别访问testRequest和testRequest1。从控制台的输出来看,Request对象确实是一个对象。但是从前面的线程名称来看,这是线程池中两个完全不同的线程。所以,虽然我还没有分析什么,但是根据日志,我至少可以看到这个问题的答案:Aremultiplexedrequestsboundtothreads?不,不存在约束关系。如果没有绑定到一个线程,那么问题就来了:如何决定每次哪个请求重用哪个线程呢?这是个好问题,但我不知道答案,所以我决定试一试。但是在设置之前,我们先想一个问题:假设Request和请求线程绑定在一起,这样设计合理吗?当然不是。线程应该是纯线程,不应“绑定”到请求。这种绑定让线程不简单,线程和请求耦合在一起。更好的设计应该是把Request放在一个“pool”中,当有线程来的时候,会从pool中取出可用的Request。这样可以达到线程和请求解耦的效果。当然,这只是我探索之前的一个假设,我先放在这里,最后看看这个猜想是否正确。阅读本文不需要您对Tomcat了解太多,只需要知道如何使用它即可。根据源代码可以推导出很多东西。顺便说一下Tomcat源码版本:9.0.58。要想找到第一个断点处问题的答案,必须要到源码中去,但是从哪里入手呢?或者换个问题:第一个断点在哪里?遇到这个问题,我的第一反应就是看看能不能从日志中找到相关的线索,从而找到第一个断点的位置。但是我把日志分别调到了DEBUG级别和TRACE级别,没有发现有价值的信息,所以感觉日志路径不可达,怎么办?不要惊慌,是时候冷静分析了。悄悄问问自己:我能不能在方法的入口处下个断点?当然,这是一个很常规的可以想到的方法:但是如果在这里打断点,就相当于从业务代码的第一行开始反推源码,绕的路又远了一点。那么断点还能打到哪里呢?我这里不是输出了Request对象的完整类名吗:http-nio-8080-exec-2:testRequest1=org.apache.catalina.connector.RequestFacade@5db48dd3RequestFacade,这个类可以用,必须有一个new放置它和新建它,必须调用它的构造函数。那么我只需要在它对应的构造方法上打个断点就可以了。程序创建这个类的时候,不就是我要找的源吗?所以,我把第一个断点放在RequestFacade的构造函数上。从构造方法入手,这也是我的调试秘诀之一,对你有用,不客气。有朋友就要问了:如果一个类有多个构造函数怎么办?很简单,努力创造奇迹,每一个构造方法都标有断点,一定有触发的地方。调试源码,找到第一个断点的位置。下一步是重新启动项目并发起调用。我连续发起了两次调用,从程序的性能中我知道断点被正确击中了。先给大家动图,大家就知道我为什么这么说了:项目启动后,第一次调用停在断点处,然后第二次调用没有停在断点处。说明第二次请求并没有创建新的RequestFacade对象,而是复用了第一次调用时生成的RequestFacade对象。确认断点位置没有问题后,就可以开始慢慢调试了。首先我们要注意创建RequestFacade对象的地方:有两个if判断。第一个是判断facade是否为null,不为null则为new。第二种是将门面赋值给applicationRequest对象,然后返回applicationRequest对象。第二个如果其实很有趣。想了想,这里直接返回门面就好了。为什么要用applicationRequest来接管呢?这是一个很好的问题。这两个if的关键在于facade和applicationRequest是否为空。第一次访问时肯定是空的。那么什么时候又会变空呢?就是一个request结束,执行recycle方法:org.apache.catalina.connector.Request#recycle从源码中我们可以看到直接将applicationRequest设置为null。但是有个前提是这个facade设置为null,getDiscardFacades方法返回true。这是什么?一看就知道:意思是RECYCLE_FACADES参数控制是否回收门面对象。如果设置为true,会提高安全性,该参数默认为false。也就是说,如果我在这个地方修改这个参数为true,每次调用完成后,门面对象就会被回收。可以通过启动参数JAVA_OPTS进行配置:-Dorg.apache.catalina.connector.RECYCLE_FACADES=true从前面的源码我们可以知道,默认情况下,每次请求完成后,applicationRequest都会被设置为null,而立面将保持向下。所以下次请求来的时候,facede不为空,直接复用facade。将外观分配给applicationRequest。所以我们在日志中观察到的是,两次请求输出的门面对象是一样的。接下来,我们继续看调用栈。查看是谁在调用创建门面的getRequest请求:发现是一个Request对象在调用getRequest方法。因此,接下来要查找的是最初将Request对象作为输入参数传递的方法。在调用堆栈之后,您可以找到以下位置:org.apache.coyote.http11.Http11Processor#service这是Request对象最初作为输入参数传递的地方。那么这个Request对象是如何生成的呢?我也不知道。所以,要知道这道题的答案,第二个断点的位置就准备好了:重启项目,发起请求,发现Debug停在AbstractProcessor类的构造方法处,也就是请求的地方第一次生成。同时,我们另一个调用栈:org.apache.coyote.AbstractProcessor#AbstractProcessor(org.apache.coyote.Adapter,org.apache.coyote.Request,org.apache.coyote.Response)这个Request是怎么来的?new:为什么要执行这个新方法?因为这个地方在createProcessor:里面,而我们要找的问题的答案就隐藏在上面的截图中。准确的说是隐藏在上面截图中五角星标注处:processor=recycledProcessors.pop();从代码片段来看,如果从recycledProcessors弹出的处理器对象不为空,则不会调用createProcessor方法。从调试的角度来看,如果不调用createProcessor方法,则不会创建RequestFacade对象。所以,recycledProcessors,这个东西是华电真正的突破口。本节主要分享一下找到这个突破口的过程。基于以上考虑,设置了两个关键断点。其实仔细想想,这是很自然的事情,调试有问题的源码也比较简单。别懦弱,翻身就好。recycledProcessors,看这个对象的名字,recycled+Processors,一看就知道里面有故事,有对象复用的故事。org.apache.coyote.AbstractProtocol.RecycledProcessors类的方法也很简单,只有三个方法:push、pop、clear。继承了SynchronizedStack对象,是一个标准的栈结构,但是用Synchronized修饰了相应的方法:在SynchronizedStack类的注释中提到这是一个对象池,这个对象池不需要收缩,目的是Reducegarbageobjects,释放GC压力。现在我们找到了这个对象池,也找到了调用这个对象池pop的地方。那么什么时候推送到这个对象池呢?我也不知道。于是第三个断点来了,可以打在push方法上:然后发起调用,发现当request处理完成,释放当前processor时,将processor放入recycledProcessors,等待下一次请求使用:至此我们就掌握了这样一个闭环:当请求到来时,先检查recycledProcessors的栈结构中是否有可用的处理器,如果没有,则调用createProcessor方法新建一个,然后放入进入栈结构。当调用createProcessor方法时,会构造一个新的Request对象,最后将Request对象封装为一个RequestFacade对象。所以现在我想验证Processor、Request和RequestFacade之间是否存在这样的对应关系。如何验证?打印日志。请注意,下一步是另一个调试技巧。我想在选择处理器后添加一行输出语句:如何添加?在自己的项目中创建一个和源码一样的包路径,然后直接贴上对应的类:因为是在自己的项目中,所以可以随便改:比如我把这个输出语句添加到打印出处理器和内部请求。发起请求后,你会发现确实生效了,但是reuqest的输出是这样的:why?因为在源码中,这个类的toString方法被重写了:怎么办?改源码,我只是教你:修改后,发起调用,你可以在控制台看到相应的预期输出:你看,处理器中有一个请求。现在我要找的是request和RequestFacade之间的关系。很简单,在getRequest方法这里输出一行:发起调用后,发现完了:这两个Request根本不是一回事:org.apache.coyote.Request@667cbb30org.apache.catalina.connector.request@9ffc697不要惊慌,静下心来好好谈谈。虽然这是两个不同的Request,但它们一定有着千丝万缕的联系。我们来看看org.apache.catalina.connector.Request是怎么来的。老规矩,断点在构造方法上:基于这个调用栈,往前看一点,可以看到一个值得注意的地方:org.apache.catalina.connector.CoyoteAdapter#service在上面截图的方法中,有一行代码是这样的:request.setCoyoteRequest(req);其中request是org.apache.catalina.connector.Request对象。req是一个org.apache.coyote.Request对象。也就是说,我这里的输出语句应该是这样的:修改后,再次发起调用,输出日志是这样的:如果你还没有看到什么,让我给你处理一下:它的意思是Processor和RequestFacade确实是一一对应的。回到文章开头的截图,为什么我发起了两次请求,RequestFacade对象是一样的?因为两个请求使用同一个Processor。你看,我又发起了两个请求,这两个请求都是由Http11Processor@26807016处理的:所以,表面上看,好像是同一个RequestFacade,但本质上,用的是同一个Processor。换句话说:如果两个请求使用不同的Processor,就不会出现重用。如何验证?我想到了如下验证方式:我可以先请求sleepTenSeconds,然后在10s内请求testRequest。这样,我就可以观察到两个不同的Processor:为了更直观的看到这个现象。我决定在操作recycledProcessors的pop方法之前和push方法之后输出recycledProcessors的内容:org.apache.coyote.AbstractProtocol.RecycledProcessors但是当你像我这样写的时候,你会发现:RecycledProcessors的父类,也就是SynchronizedStack类没有提供print方法,怎么办?很简单,弄到源码,加个方法,不就是手到擒来吗?然后,我还是按照先访问sleepTenSeconds再访问testRequest方法的顺序发起请求。日志是这样的:单独拿出来,testRequest整个请求完成后,对应的日志是这样的,==========beforepopStart】PrintallcurrentProcessors=================beforepop【结束】打印所有当前处理器========1.processor=org.apache.coyote.http11.Http11Processor@6720055f,request=org.apache.coyote.Request@69e7f7cb2.coyoteRequest=org.apache.coyote.Request@69e7f7cb,facade=org.apache.catalina.connector.RequestFacade@6dd86e2f3.http-nio-8080-exec-1:testRequest=org.apache.catalina.connector.RequestFacade@6dd86e2f=========push[start]打印所有当前处理器========org.apache.coyote.http11.Http11Processor@6720055f=========push[End]PrintallcurrentProcessor========andsleepTenSeconds整个请求完成后,对应的log是这样的:========pop【Start之前】打印所有当前Processor==================beforepop【结束】打印当前所有Pros处理器========1.processor=org.apache.coyote.http11.Http11Processor@7ba33829,request=org.apache.coyote.Request@1334fe582.coyoteRequest=org.apache.coyote.Request@1334fe58,门面=org.apache.catalina.connector.RequestFacade@2a0231eb3.http-nio-8080-exec-2:sleepTenSeconds=org.apache.catalina.connector.RequestFacade@2a0231eb========在[开始]打印后推送所有当前处理器========org.apache.coyote.http11.Http11Processor@6720055forg.apache.coyote.http11.Http11Processor@7ba33829=========推送后[结束]打印所有当前处理器========也就是说此时recycledProcessors中有两个Processor:=========[start]printallcurrentProcessorsafterpush========org.apache.coyoteAfter.http11.Http11Processor@6720055forg.apache.coyote.http11.Http11Processor@7ba33829========push[End]PrintallcurrentProcessors=========那么问题来了:你说我接下来,再次发起请求。哪个处理器将接受此请求?虽然还没有发起请求,但是我知道肯定是Http11Processor@7ba33829进行处理,因为我知道它会是下一个被pop出来的Processor对象。不信你看这个动画:在上面的动画中,我首先发起了请求testRequest。如果先访问sleepTenSeconds再访问testRequest呢?虽然我还没有发起请求,但是我知道必须有这样一个对应关系来处理这两个请求:sleepTenSeconds->Http11Processor@7ba33829testRequest->Http11Processor@6720055f因为当sleepTenSeconds请求来的时候,Processor@7ba33829会弹出的recycledProcessors这个对象,用来处理请求。所以在10秒内,也就是sleepTenSeconds请求没有完成的时候,访问了testRequest请求,对象Http11Processor@6720055f从recycledProcessors中弹出。如果你不相信我,再看看这个动画:那么,我们现在是否找到了这个问题的答案:如何决定哪个线程每次都会重用那个请求?请求线程和请求之间没有关联。每个请求使用哪个请求取决于使用哪个处理器。每个请求使用哪个Processor取决于在recycledProcessors类中缓存了哪些Processor。当请求到来时,以pop中出现的那个为准。由于recycledProcessors是一个缓存,它的大小在一定程度上决定了项目的性能。它的默认值为200:为什么是200?因为tomcat线程池的最大线程数默认是200:这个你能看懂吗?虽然threads和Processors之间没有绑定关系,但是从逻辑上讲,一个thread对应一个Processor。因此,最好使线程数与处理器数保持一致。如果我把参数processorCache改成1:server.tomcat.processor-cache=1你说高并发会怎么样?当push请求很多的时候,push不会进去,从而进入handler.unregister(processor):的逻辑,而这个unregister方法对应一个register方法。一起给大家展示一下:他们持有相同的Pen同步锁,说明他们之间存在竞争。我们知道RecycledProcessors的push方法在一个request结束后会被调用,而push的时候会调用unregister方法。那么问题来了:什么时候调用register?其实之前已经出现过:一个request来了,processor创建之后。所以,当我设置processorCache为1时,在高并发的情况下,不断调用register和unregister,频繁的锁竞争,性能下降。这个结论是我翻源码得出的结论,不是在其他书或视频中得到的现成结论。这就是阅读源码的乐趣和意义。写到这里,不由得想起自己在这篇文章踩过的坑《千万不要把Request传递到异步线程里面!有坑!》。再看这个动画,主要关注两次调用时console的相应输出:是因为在Request的生命周期之外使用,导致复用时出现问题。我当时给出的正确方案是使用Request的异步编程,也就是startAsync和AsyncContext.complete方法的集合。但是写完这篇文章,又想到了两个秀操作。第一种方法隐藏在我前面提到的RECYCLE_FACADES配置中。从官方文档中的描述来看,这个参数如果设置为true,会提高安全性,但默认为false。它如何提高安全性?即RequestFacade每次也是被回收的。那我改成true试试看效果:-Dorg.apache.catalina.connector.RECYCLE_FACADES=true启动项目,发起调用:抛出异常。看到这个异常,我立马明白了官方文档中的“security”是什么意思:你的用法不对,我给你抛出一个异常,并提醒你需要修改完善。安全。而第二个是这样的:server.tomcat.processor-cache=0你明白我的意思吗?不让你复用,每次都用一个新的,绕过复用的“坑”:不管好用不好用,有没有性能问题,理解透了就说的底层逻辑,这个操作羞耻与否。