关于后端:千万不要把Request传递到异步线程里面有坑

2次阅读

共计 10096 个字符,预计需要花费 26 分钟才能阅读完成。

前几天在网上冲浪的时候看到一篇技术文章,讲的是他把一个 request 申请传递到了线程池外面,而后遇到了一个匪夷所思的状况。他写了这篇文章,把本人针对这个问题的摸索过程分享了进去:《springboot 中如何正确的在异步线程中应用 request》www.cnblogs.com/mysgk/p/164…文章还是挺不错的,把发现问题和解决问题都写的很明确了。然而,我感觉把摸索问题的局部写的太省略了,导致我看完之后都不晓得这个问题的根本原因是什么。而为什么我会对这篇文章特地感兴趣呢?因为这个“坑”我记得我刚刚入行没两年的也遇到过,我曾经不记得本人过后是怎么解决的了,然而我必定也没有深刻的去钻研。因为那个时候遇到问题,就去网上费尽心思的查,粘一个计划过去看能不能用。如果不能用的话,心里暗骂一句:小可 (S) 爱(B),而后接着找。直到找到一个能够用的。至于为什么能用?管它呢,钻研这玩意干啥。

次要是过后感觉摸索这个玩意到进入到源码外面去,一波及到源码心里就犯怵,所以就敬而远之。当初不一样了,当初我看到源码我就感觉兴奋,心里想着:多好的素材啊。既然这次又让我遇到了,所以我决定把几年前的坑填上,盘一盘它。

搞个 Demo 因为这个景象太过匪夷所思,所以写文章的那个老哥认为这个是一个 BUG,还在 Spring 的 github 上提了一个 issues:github.com/spring-proj…这外面他附上了一个能够复现的 Demo,所以我就间接拿来用了。的确是能够复现,然而其实他提供的这个 Demo 还是有点臃肿,具备一点点的迷惑性,间接给我迷晕了,让我在这下面略微花了工夫。先给你看一下他的 Demo 是怎么样的。次要是两个 Controller 接口。第一个接口是 get 申请类型的 getParams,代码很简略,先放在这里,等下用:

第二个接口是 post 申请类型的 postTest,就这么几行代码:@PostMapping(“/postTest”)
public String postTest(HttpServletRequest request) {
    String age1 = request.getParameter(“age”);
    String name1 = request.getParameter(“name”);
    System.out.println(“age1=” + age1 + “,name1=” + name1);
    new Thread(new Runnable() {
        @Override
        public void run() {
            String age2 = request.getParameter(“age”);
            String name2 = request.getParameter(“name”);
            System.out.println(“age2=” + age2 + “,name2=” + name2);
            // 模仿业务申请
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            age2 = request.getParameter(“age”);
            name2 = request.getParameter(“name”);
        }
    }).start();
    return “post success”;
}
复制代码次要是外面启动了一个线程,在线程外面有从 request 外面获取参数的动作。这个办法拜访起来是这样的一个状况:

从 age2、name2 输入上看,尽管 request 传入到异步线程外面了,然而还是能从外面获取到对应的参数,没有看进去有什么故障。然而接下来,匪夷所思的事件就要呈现了。还记得咱们后面的 getParams 接口吗?我再把它拿过去给你看一眼:

你说,就这个接口,我用上面这个链接去拜访,在我的认知外面是齐全不可能有任何问题的,对吧?http://127.0.0.1:8080/getPara… 然而,这玩意还真的就突破了我的认知:

在拜访 postTest 办法之后,再次拜访 getParams 办法,getParams 办法竟然抛出异样了?抛出的异样是说我调用的时候没有传递 b 这个参数。然而我的链接外面明明就是有 b=2 的啊?这玩意上哪里说理去?

下面就是那位老哥提供的可复现的 Demo 的次要局部。然而我后面说了,这个 Demo 有点臃肿,具备一点点迷惑性。首先如果我再加一个输入语句,那么在一个短暂的 sleep 之后,age2 和 name2 就没了:

尽管还是感觉有点神奇吧,然而也没有刚刚那个操作让我感到震惊。因为从输入 null 这个后果,我至多能够晓得程序在这个中央就呈现问题了,把问题的范畴限定在了一次申请中。刚刚那个操作,好家伙,体现进去到状况是这样的:先发动一个 post 申请,看起来是失常的。而后再发动一个 get 申请,这个 get 申请挂了。然而这个 get 申请从发动的角度来看找不到任何故障。你要基于下面这个状况去剖析问题的话,就不好找问题了,毕竟要发动两个毫不相干的申请能力触发问题。

退出一行输入日志,相当于把问题简化了一点。然而你看到的是我就加了一行输入日志,实际上等我加这行日志的时候,我拿到这个 Demo 曾经过来了好几个小时了。在这期间我也始终认为必须要依照这个流程来操作,能力复现问题。所以我才说具备一点点迷惑性。好,当初不管怎么说吧。我先把 Demo 简化一点,便于持续剖析。我的 Demo 能够简化到这个水平:@GetMapping(“/getTest”)
public String getTest(HttpServletRequest request) {
    String age = request.getParameter(“age”);
    System.out.println(“age=” + age);
    new Thread(() -> {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        String age1 = request.getParameter(“age”);
        System.out.println(“age1=” + age1);
    }).start();
    return “success”;
}
复制代码 get 和 post 申请都能够,只是我为了不便抉择发动 get 申请。而后只须要传递一个参数就行,外围步骤是要把 request 传递到异步线程外面去,调用 getParameter 再次获取对应入参。你能够把下面的代码粘到你本地,把我的项目跑起来,而后调一次上面这个链接:http://127.0.0.1:8080/getTest… 从控制台你能够看到这样的输入:

到这里就复现了后面说的问题。然而你別焦急,你再次发动调用,你会看到控制台的输入是这样的:

怎么样,是不是很神奇,很懵逼?为了让你更加直观的懵逼,我给你上个动图,发动两次调用,次要关注控制台的输入:

好,当初,你就去泡杯茶,点根烟,缓缓去推敲,这玩意是不是属于超自然景象。

摸索其实我看到这个景象的时候并不是特地的震惊,毕竟写文章这几年,什么稀奇古怪的景象都遇到过。所以我只是轻蔑一笑,看向了我排查问题的武器库,很快就看到了一个比拟趁手的货色:开启 Debug 日志。如果是以前,对于这种没有抛出异样的问题跟着,因为没有异样堆栈,我必定是急不可待的正向的 Debug 跟了一下源码,扎到源码外面去一顿狂翻,左看右看。然而后果经常是一头扎进去之后,很快就迷失了,搞了好几个小时才从源码外面爬出来,进去的时候基本上满载而归。然而我当初不会这么猴急了,当初就成熟了很多。遇到这类问题不会先急着去卷源码会先多从日志外面开掘一点货色进去。所以我遇到这个问题的第一反馈就是调整日志级别到 Debug:logging.level.root=debug 察看日志这个小技巧我在之前的文章外面也分享过。当日志调整到 Debug 级别之后,再次发动两次调用,问题复现,同时把日志拿进去做比照。两次申请的 Debug 日志整体状况是这样的,右边是第一次申请,左边是第二次申请:

能够看到第一次申请比第二次申请的日志多。多阐明什么问题?是不是阐明第一次申请调用的办法更多一点?为什么多一点,到底是哪些办法只调用了一次?我也不晓得,然而我能从 Debug 日志外面梳理进去。比方上面这个图就是梳理进去的第一次申请多打印的日志:

很快我就从 Debug 日志外面看到了一个我感觉很可疑的中央:

Start processing with input [age=18]这一行日志,只有第一次申请的时候打印了,从日志表白的意思来看,是解决申请外面的 age=18。为什么第二次不打印呢?我也不晓得,然而我晓得了第一个要害断点打在什么地位了。全局搜寻关键字“Start processing with input”能够找到配置文件外面的“parameters.bytes”。而后全局搜寻“parameters.bytes”,就能找到是在 Parameters.java 文件外面输入的:

也就是这个中央:org.apache.tomcat.util.http.Parameters#processParameters(byte[], int, int, java.nio.charset.Charset)

找到第一个断点,就找到了突破口,只有好好的拿捏住,之后的事件就基本上就逆风逆水了。

首先,重启我的项目,发动调用,在断点处看调用堆栈:

接下来的思路是什么?就是我要从堆栈外面找到一个货色。你想啊,第一次申请走这个中央,第二次申请就不走这个中央了,所以肯定有个相似于这样的逻辑:if(满足某个条件){
    走 processParameters 办法
}
复制代码所以,只须要往回找五个调用栈,我就找到了这一个办法:org.apache.catalina.connector.Request#getParameter

这个时候你看旁边的 parametersParsed 参数是 true,按理来说 true 不应该走进 if 分支呀?因为这个中央咱们是从断点处的堆栈信息往回找,在从 parseParameters 办法到 processParameters 办法之间,必定有中央批改了 parametersParsed 参数的值为 true。

这一点,从 parametersParsed 的初始值是 false 也能看进去:

因而,我决定把第二个断点打在 getParameter 办法中:

再次重启服务,发动调用,parametersParsed 为 false,开始执行 parseParameters() 办法解析参数:

而解析参数的目标之一就是把我的 age=18 放到 paramHashValues 这个 Map 容器外面:org.apache.tomcat.util.http.Parameters#addParameter

parseParameters() 办法执行实现之后,接着从后面的 paramHashValues 容器外面把 age 对应的 18 返回回去:

然而,敌人们,留神下面的图片中有个标号为 ① 的中央:

这个办法,在 parseParameters 办法外面也会被调用:org.apache.tomcat.util.http.Parameters#handleQueryParameters

好,当初打起精神来听我说。handleQueryParameters 办法才是真正解析参数的办法,为了避免反复解析它退出了这样的逻辑:

didQueryParameters 初始为 false,随后被设置为 true。这个很好了解,入参解析一次就行了,解析的产物一个 Map,后续要拿参数对应的值,从 Map 外面获取即可。比方我把入参批改为这样:http://127.0.0.1:8080/getTest… 那么通过解析之后,这个 Map 就变成了这样:

通过了后面的这一顿折腾之后,当初找到了解析入参的办法。那么全文的关键点就在 didQueryParameters 这个参数的变动了。只有是 false 的时候才会去解析入参。那么我接下来的排查思路就是察看 didQueryParameters 参数的变动,所以在字段上打上断点,重启我的项目,持续调试:

第一次进入这个办法的时候 didQueryParameters 为 false,入参是 age=18:

而第一次进入这个办法的起因我后面也说了,是因为触发了 parseParameters 的逻辑:

第二次进入这个办法 didQueryParameters 变为 true 了,不必再次解析:

那么第二次进入这个办法的起因是什么?后面也说了,getParameter 办法的第一行就是触发解析的逻辑:

接下来,断点停在了这个中央:org.apache.tomcat.util.http.Parameters#recycle

办法叫做 recycle,表明是循环再利用,在这外面会把寄存参数的 Map 清空,把 didQueryParameters 再次设置为了 false。而当你用同样的伎俩去察看 parametersParsed 参数,也就是这个参数的时候:

会发现它也有一个 recycle 办法:org.apache.catalina.connector.Request#recycle

这个办法上的正文,也有一个特地刺眼的词:reuse。正文过去是这样的:开释所有的对象援用,并初始化实例变量,为从新应用这个对象做筹备。种种迹象表明 request 在 tomcat 外面是循环应用的。尽管在这之前我也晓得是循环应用的,然而百闻不如一见嘛。这次是我 Debug 的时候亲眼看到了。又拿捏一个小细节。

因为咱们在异步线程外面还触发了一次 getParameter 办法:

然而 getTest 办法曾经实现了响应,这个时候 Request 可能曾经实现了回收。留神我说的是“可能”,因为这个时候 Request 的回收动作和异步线程谁先谁后还不肯定。这也解释了这个景象:

尽管 request 传入到异步线程外面了,然而还是能从外面获取到对应的参数。因为此时 request 的回收动作还没做完,还能够持续获取参数。为了防止这个“可能”,我把 sleep 的工夫调整为 5s,保障 request 实现回收。而后这异步线程外面持续 Debug,接下来神奇的事件就要开始了。

再次触发 handleQueryParameters 的时候,didQueryParameters 因为被 recycle 了,所以变成了 false。而后执行解析的逻辑,把 didQueryParameters 设置为 true。然而,咱们能够看到,此时查问的内容却没有了,是个 null:

这个也好了解,必定是随着调用完结,被 recycle 了嘛:

所以,到这里我能解答为什么异步线程外面的输入是 null 了。queryMB 就是我调用的时候传入的 age=18。通过 Debug 发现异步线程外面调用 getParameter 的时候没有 queryMB,所以就不会解析出 Map。没有 Map,异步线程外面的输入必定是 null。为什么没有 queryMB 呢?因为以后这个申请曾经被返回了,执行了 recycle 相干操作,queryMB 就是在这个时候没有的。那么为什么再次发动调用,会呈现这个神奇的景象呢?

很简略,因为在异步线程外面调用 getParameter 的时候,把 didQueryParameters 设置为 true 了。然而异步线程外面的调用,超出了 request 的生命周期,所以并不会再次触发 request 的 recycle 相干操作,因而这个 request 拿来复用的时候 didQueryParameters 还是 true。所以,从 Debug 来看,尽管 queryMB 是有值的,然而没用啊,didQueryParameters 是 true,程序间接 return 了,不会去解析你的入参:

问题失去解答。此时,咱们再回到最开始的这个办法中:

你想想为什么这个办法调用的时候出现异常了?还是一样的情理呀,因为 request 是复用的,尽管你传入了参数 b,然而因为前一个申请在异步线程外面调用了 getParameter 办法,将 didQueryParameters 设置为了 true,导致程序不会去解析我传入的 a=1&b=2。从调用链接的角度来说,尽管咱们调用的是这个链接:http://127.0.0.1:8080/getPara… 然而对于程序来说,它等效于这个链接:http://127.0.0.1:8080/getParams 因为入参 b 是 int 类型的,那可不就是会抛出这个异样吗:

这个异样是说:哥们,你要么把 b 搞成 Integer 类型的,不传值我就给你赋为 null。要么给我传一个值。你当初用 int 来承受,又不给我值,我这没法解决啊?我能给你默认赋值一个 0 吗?必定不能啊,0 和 null 可不是一个含意,万一你程序出异样了,把锅甩给我怎么办?算了,我还是抛异样吧,最稳当了。所以你看,要是你从这个抛异样的中央去找答案,兴许能找到,然而路就走远了一点。因为这个中央并不是问题的根因。到这里,你应该分明这个 BUG 到底是怎么回事了。request 的生命周期在摸索这个问题的过程中,我也想到了另外一个问题:一个 request 申请的生命周期是怎么样的?这题我记得几年前我背过,当初我的确有点想不起来了,然而我晓得去哪里找答案。Java Servlet Specification,这是一份标准,答案就藏在这个标准外面:javaee.github.io/servlet-spe…在 3.13 大节外面,对于 request 这个 Object 的生命周期,标准是这样说的:

这寥寥数语,十分要害,所以我一句句的拆解给你看。Each request object is valid only within the scope of a servlet’s service method, or within the scope of a filter’s doFilter method,unless the asynchronous processing is enabled for the component and the startAsync method is invoked on the request object. 一上来就是一个长句,然而基本不要慌。你晓得的,我英语八级半,程度一贯是能够的。

先把长句拆短一点,我能够先只翻译 unless 之前的局部。后面这部分说:每个 request 对象只在 servlet 的服务办法的范畴内无效,或者在过滤器的 doFilter 办法的范畴内无效。接着它来了一个 unless,示意转折,和 but 差不多。咱们次要关注 unless 前面这句:the asynchronous processing is enabled for the component and the startAsync method is invoked on the request object. 组件的异步解决性能被启用,并且在 request 上调用了 startAsync 办法。也就是说,request 的生命周期在遇到异步的时候有点非凡,然而这个异步又不是我后面演示的那种异步。对于异步,标准中提到了 request 外面有个办法:startAsync。我去看了一眼,果然是有:

返回值是一个叫做 AsyncContext 的货色。然而我先按下不表,接着往下翻译。In the case where asynchronous processing occurs, the request object remains valid until complete is invoked on the AsyncContext. 在产生异步解决的状况下,request 对象的生命周期始终会连续到在 AsyncContext 上调用 complete 办法之前。这里又提到了一个 complete 办法,这个 complete 办法 invoked on the AsyncContext。AsyncContext 是什么玩意?不就是 request.startAsync() 办法的返回值吗?果然在 AsyncContext 外面有个 complete 办法:

不慌,持续按下不表,一会就回收,接着往下看。Containers commonly recycle request objects in order to avoid the performance overhead of request object creation. 容器通常会 recycle 申请对象,以防止创立申请对象的性能开销。看到这个 recycle 咱们就很眼生了,原来标准外面是倡议了容器外面实现 request 的时候尽量复用,而不是回收,目标是节约性能。这玩意,属于意外播种呀。最初一句话是这样的:The developer must be aware that maintaining references to request objects for which startAsync has not been called outside the scope described above is not recommended as it may have indeterminate results. 这句话是说:程序员敌人们必须要意识到,我不倡议在上述范畴之外保护 request 的援用,因为它可能会产生不确定的后果。看到这个“不确定的后果”时我很开心,因为我后面曾经演示过了,的确会产生莫名其妙的后果。然而标准外面在“scope”之前还加了一个限定词:startAsync has not been called。反过来说,意思就是如果你有一个调用了 startAsync 办法的 request,那么在上述范畴之外,你还能够操作这个 request,也不会有问题。这一整段话中,咱们提炼到了两个要害的办法:request 的 startAsync 办法 AsyncContext 的 complete 办法依据标准来说,这两个办法才是 request 异步编程的正确打开方式。正确打开方式在这之前,假如你齐全不晓得 startAsync 和 complete 办法。然而看了标准上的形容,猜也能猜出来代码应该这样写,而后发动屡次调用,没有任何故障:

这就是正确的打开方式。从景象上来说,就是 getTest 申请返回之后,request 线程并没有被调用 recycle 办法进行回收。为什么这样写就能实现 request 的异步化呢?用脚指头想也能想到,肯定有一个这样的判断逻辑存在:if(调用过 request 的 startAsync 办法){
    先不回收
}
复制代码所以,用之前的办法,在 recycle 办法上打断点,并往回找,很快就能找到这个办法:

而后,对于 AsyncContext 的 complete 办法我还留神到它有这样的一个形容:

也就是说在调用 complete 办法之后 response 流才会敞开,那么有意思的就来了:

我不仅在异步线程外面能够操作 request 还能够操作 response。然而转念一想,既然都是异步编程了,操作 response 的意义必定比操作 request 的意义更大。对于 Tomcat 对于异步申请的反对还有很多能够摸索的中央,本人缓缓去玩吧。写到这里的时候我发现题目说的也不对,题目是:千万不要把 Request 传递到异步线程外面!有坑!而正确的说法应该是:千万不要轻易把 Request 传递到异步线程外面!有坑!你拿捏不住,得用 startAsync 办法才行。好了,就这样吧,本文写到这里就差不多了。本文次要是分享了一下 request 放到异步线程之后的诡异景象和排查办法,最初也给出了正确的打开方式。心愿你能把握到这样的一个问题排查办法,不要害怕问题,要抽丝剥茧的干它。而后,其实和 BUG 排查比起来,对于 request 的异步编程相干的常识更加重要,本文只是做了一个小小的引子,如果这块常识对你是空白的,心愿你有趣味的话本人去钻研一下,很有价值。最初,我想说的是,对于之前文章的一个留言:

从看到这个景象,到写完这篇文章,我一直的调试程序,至多重启了近百次服务,发动了上百次申请。在源码外面也走了一些弯路,最初才抽丝剥茧的看到本问题的根因。所以,我排查问题的教训就一个字:

正文完
 0