当前位置 博文首页 > 少年时未觉悟 ,觉悟时不再年少,还危机四伏!:HttpClient下载

    少年时未觉悟 ,觉悟时不再年少,还危机四伏!:HttpClient下载

    作者:[db:作者] 时间:2021-07-16 15:32

    背景

    CVE 的英文全称是“Common Vulnerabilities & Exposures” 通用漏洞披露,官网会定期纰漏最新漏洞信息,有一个 Java 定时任务会定期下载最新漏洞文件 allitems.csv.Z

    偶发的问题是:压缩文件不大,但是国外网站加网络不稳定,任务运行时偶尔能够下载成功,其他时候就卡在读取响应流方法那里。

    这个技术债一直拖着,昨天下定决心跟踪,抓到了堆栈日志,果然是数据读取的问题。定时任务卡在文件下载的地方,文件不是很大,是一个压缩文件,三十多兆。

    问题跟踪

    首先,打印堆栈日志。 Java 提供了很多查看线程堆栈信息的工具,这里使用 jcmd ,操作步骤如下:

    第一步,定位 Tomcat 进程编号 15617 :jps  grep Bootstrap
    第二步,打印堆栈信息:jcmd 15617 Thread.print > /opt/jcmdstack.log
    

    查看 jcmdstack.log 文件内容,抓到了文件下载操作所处的线程信息是这段:

    "quartzScheduler_Worker-2" #19 prio=5 os_prio=0 tid=0x00007f45f9200800 nid=0x3d33 runnable [0x00007f46212bb000]
       java.lang.Thread.State: RUNNABLE
    	at java.net.SocketInputStream.socketRead0(Native Method)
    	at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
    	at java.net.SocketInputStream.read(SocketInputStream.java:170)
    	at java.net.SocketInputStream.read(SocketInputStream.java:141)
    	at org.apache.http.impl.io.SessionInputBufferImpl.streamRead(SessionInputBufferImpl.java:139)
    	at org.apache.http.impl.io.SessionInputBufferImpl.read(SessionInputBufferImpl.java:200)
    	at org.apache.http.impl.io.ContentLengthInputStream.read(ContentLengthInputStream.java:178)
    	at org.apache.http.conn.EofSensorInputStream.read(EofSensorInputStream.java:137)
    	at org.apache.http.conn.EofSensorInputStream.read(EofSensorInputStream.java:150)
    	at xxx.download(XXXUtil.java:xxx)
    

    其次,定位到 download 所在的方法。逐行分析下载代码,大致流程没问题,就是缺少了超时配置。

    设置超时时间

    文件下载就是将 HttpEntity 的响应流输出到本地文件,为 HttpGet 实例设置各类超时配置,代码如下:

    import org.apache.http.HttpEntity;
    import org.apache.http.HttpResponse;
    import org.apache.http.client.HttpClient;
    import org.apache.http.client.config.RequestConfig;
    
    /**
     * 根据url下载文件,保存到filepath中
      */
     public static void download(String url, String filepath) {
         if(url == null || filepath == null) {
             return ;
         }
    
         int cache = 200 * 1024;
         try {
             HttpClient client = HttpClients.createDefault();
             HttpGet httpget = new HttpGet(url);
    
             // 设置超时时间,解决读取响应数据卡死的问题,时间足够长,如一小时
             RequestConfig requestConfig = RequestConfig.custom()
                      .setConnectionRequestTimeout(3600000)
                     .setConnectTimeout(3600000)
                     .setSocketTimeout(3600000).build();
             httpget.setConfig(requestConfig);
    
             // 执行 http 请求,获取响应流
             HttpResponse response = client.execute(httpget);
             HttpEntity entity = response.getEntity();
             InputStream is = entity.getContent();
             File file = new File(filepath);
             file.getParentFile().mkdirs();
             FileOutputStream fileout = new FileOutputStream(file);
             byte[] buffer = new byte[cache];
             int ch = 0;
             
             // 读取响应内容到本地文件流
             while ((ch = is.read(buffer)) != -1) {
                 fileout.write(buffer, 0, ch);
             }
             is.close();
             fileout.flush();
             fileout.close();
    
             EntityUtils.consume(entity);
             logger.info("Finished download successfully!");
         } catch (Exception e) {
             logger.error("Download file error!",e);
         }
     }
    

    温故下三个超时的含义,根据 API 可知:

    1. setConnectionRequestTimeout:从连接管理池中获取连接的超时时间,这个是 HttpClient 连接池管理的参数,未设置且没有可用连接时将导致无限等待
    2. setConnectTimeout:与服务器建立链接的超时时间,网站不挂的话,基本OK;
    3. setSocketTimeout :从服务器获取响应数据的超时时间,a maximum period inactivity between two consecutive data packets 。这个是关键指标,指在等待读取数据时两次连续数据包之间等待的最大时间间隔。

    启示录

    为何 HttpClient 工具下载文件操作不设置超时时间,就容易出现程序假死的问题呢?堆栈显示下载线程虽处于 RUNNABLE 运行状态的,但是下载流程就没有继续了。怎么逻辑自洽地解释这个超时未设置时任务卡住的现象呢?

    可以确定的是 setSocketTimeout 数据读取超时时间未设置时,国外网站,公司网络又很慢的情况下,两次接收数据包之间,如果服务器端没有数据返回,socketRead0 这个底层方法它肯定去傻傻等待了,至于这个方法有没有阻塞逻辑,navtive 代码也不好定论。

    为什么后面服务器端一直都没有最新数据传输过来呢?推断可能是服务器断掉了这个连接请求,不再继续发送数据了,而客户端还卡在读取的地方。设置超时时间,一个小时后,如果依然无数据,该轮任务就能退出,不会影响下一轮的定时调度逻辑。

    由于目标网站位于国外,总出现 Readtimeout 异常,运气好能成功下载的次数就一两次。添加超时配置后,定时任务重新跑了十几次,但没有再出现任务卡住的问题。

    cs