背景介紹
公司開發了一款Web應用,開發架構基於Spring Boot,通過jar包的方式發佈到服務器並通過命令行運行在內置的Tomcat上。
上線將近一年,一切都是那麼的風平浪靜,然而一切的平靜被上週的一次現場算法回訪打破。
我們的數據分析人員本意只是想查看一下歷史數據來確認算法的表現符合預期,結果發現歷史數據查詢頁面怎麼點都沒有反應,而其他頁面都是正常的,服務重啓後一切恢復正常。
問題重現
雖然問題通過服務重啓後成功解決,但是出錯的原因沒有定位到也就意味着再次出錯的可能性依然存在。
分析問題最直觀的方式就是從錯誤出發,通過錯誤信息來反向推導錯誤發生的場景。在這個案例中我們查看了瀏覽器控制檯和後臺錯誤日誌,最終獲取了準確的錯誤信息:
2019-08-23 14:40:47,835 [http-nio-9090-exec-8] ERROR o.a.catalina.core.ContainerBase.[Tomcat].[localhost].[/].[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [java.lang.ClassNotFoundException: org.apache.jsp.WEB_002dINF.views.report.report_005fmain_jsp] with root cause java.lang.ClassNotFoundException: org.apache.jsp.WEB_002dINF.views.report.report_005fmain_jsp at java.net.URLClassLoader.findClass(URLClassLoader.java:381) at org.apache.jasper.servlet.JasperLoader.loadClass(JasperLoader.java:129) at org.apache.jasper.servlet.JasperLoader.loadClass(JasperLoader.java:60) at org.apache.catalina.core.DefaultInstanceManager.newInstance(DefaultInstanceManager.java:159) at org.apache.jasper.servlet.JspServletWrapper.getServlet(JspServletWrapper.java:171) at org.apache.jasper.servlet.JspServletWrapper.service(JspServletWrapper.java:380) at org.apache.jasper.servlet.JspServlet.serviceJspFile(JspServlet.java:386) at org.apache.jasper.servlet.JspServlet.service(JspServlet.java:330) at javax.servlet.http.HttpServlet.service(HttpServlet.java:742) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:728) at org.apache.catalina.core.ApplicationDispatcher.proce***equest(ApplicationDispatcher.java:470) at org.apache.catalina.core.ApplicationDispatcher.doForward(ApplicationDispatcher.java:
這是一個ClassNotFoundException,通過錯誤信息我們可以在搜索引擎上找到一堆解答,甚至在Spring Boot的Github上都有類似的情況。
https://github.com/spring-projects/spring-boot/issues/5009
結果總結下來就是:
Spring Boot內置的Tomcat會在系統根目錄的/tmp下創建Tomcat開頭的臨時目錄,tmp目錄的定時清理會導致部分文件的class文件找不到,
解決辦法是指定一個work目錄不要使用默認的tmp目錄。
聽起來很有道理,官方都這麼說了那照着做就行了唄。
然而作爲一個好奇心爆棚的程序員,這樣的解釋顯得蒼白而無力,但是這個解釋倒是給我們的重現提供了很好的便利,畢竟只有充分重現了這個問題才能更好的去探究深層次的原因。
於是在官方解釋的指導下,我們進行了多次重現的嘗試,最終將問題範圍縮小如下:
在Tomcat啓動後將tmp下的ROOT目錄刪除,訪問的第一個頁面會出現無法訪問的情況,後臺出現ClassNotFoundException,之後再訪問其他頁面都是正常。
這裏補充一下背景知識:tmp目錄在Centos6以及之前是通過TmpWatch的定時任務來定時清理,而Centos7之後直接修改爲了systemd-tmpfiles-setup.service,配置文件在/usr/lib/tmpfiles.d/tmp.conf。
問題分析
在進行問題分析的時候,我們一般會使用三種方式
² 經驗法
結合自身的經驗來猜測問題發生的可能原因,然後通過驗證來定位問題具體原因
² 推導法
從問題的發生點開始倒推,沿着問題發生的路徑逐步接近問題的根源
² 分析法
分析整個流程中的每一個節點,找到和問題可能相關的節點逐個驗證從而找到導致問題的節點
經驗法往往是遇到問題時第一個使用的方法,因爲面對問題時衝在前面的往往是我們的直覺。
在這個問題中我做了以下猜測,並一一驗證
1, Class文件損壞
做出這個假設的依據是,在同一個目錄下存在兩個頁面的Class文件,一個可以訪問一個不可以訪問。
驗證方法也很簡單,首先重啓服務正常訪問頁面A獲取到正常狀態下的A.java和A.class文件;重啓服務器後刪除ROOT目錄,再訪問頁面A觸發錯誤,將目錄下的java和class文件替換成正常狀態的問題;再次訪問頁面,依然報錯。
至此我們推翻了我們關於Class文件損壞的假設。
2, Dev-tool導致ClassLoader不一致
做出這個假設的依據是我們之前遇到的一個dev-tool的問題,Spring Boot在引入了dev-tool後會進行熱加載,這時候由於jar包加載和class加載使用了不同的ClassLoader會出現ClassNotFoundException。
我們之前解決這個問題的方法是去掉dev-tool,同樣在這裏我們也可以去掉dev-tool再走一遍重現步驟,發現問題依然存在。
至此我們排除了Dev-tool導致ClassLoader不一致的假設。
3, Class文件時間戳
在我們查看正常文件和異常文件差異的時候發現,正常文件的時間戳和jar包中的jsp時間戳一致,而異常文件的時間戳是當前時間,那會不會是因爲時間戳不一致導致的呢。
爲了驗證這個假設我們從兩方面入手a) 調整正常文件的時間戳到當前時間,結果正常文件依然正常 b) 調整異常文件的時間戳爲jsp的時間,結果異常文件依然無法訪問。
於是我們也排除了Class文件時間戳的假設。
推導法是比較直觀也是可以比較快速的發現問題的方法,但是在我們這個案例中我們發現錯誤堆棧中的URLClassLoader並不是問題發生的第一現場,真正的第一現場在java自己的包中,對我們逐步跟蹤問題造成了困難。
鑑於此我們選擇分析法作爲我們解決問題的突破口。當然還有一個重要條件支持我們採用分析法解決問題,那就是在我們這個案例中我們存在OK和NOK兩種情況,在每一個分析的節點我們都可以引入兩種情況進行對比。
在開始之前,由於要每一步比較差異,我們需要配置Eclipse的遠程調試。傳送門:
https://www.cnblogs.com/east7/p/10285955.html
首先我們梳理一下Tomcat解析JSP的流程,由於我們基於類來描述流程,所以先羅列一下涉及的類以及主要的方法:
JspServlet類是主入口,接收jsp請求;
JspRuntimeContext通過add和get方法來維持一個ServletWrapper的緩存;
從JspServlet往後是加載的主要類,而從Compiler往後的類是編譯用到的類。
在大致瞭解了內部類結構後我們可以來看看Jsp加載的流程了,
從圖中可以看出我們的報錯點在獲取Servlet的class這一步,那麼我們從頁面訪問的步驟一步步比較OK和NOK表現的差異。
1, 獲取ServletWrapper
這一步的作用是爲每一個Jsp頁面構建一個代理並緩存在JspRuntimeContext中,這樣每次訪問頁面直接獲取代理即可。從調試的結果看,構建wrapper的每個參數都是一樣,而構建的wrapper結果也是一致的。
2, 編譯Java文件
我們注意到在Complier.class的generateJava這個方法中有一步是:
ctxt.checkOutputDir();
我們的重現恰恰是刪除了ROOT目錄,繼續進去看代碼
public void checkOutputDir() { if (outputDir != null) { if (!(new File(outputDir)).exists()) { makeOutputDir(); } } else { createOutputDir(); } }
由於一開始的outputDir爲空會進入createOutputDir方法,
try { File base = options.getScratchDir(); baseUrl = base.toURI().toURL(); outputDir = base.getAbsolutePath() + File.separator + path + File.separator; if (!makeOutputDir()) { throw new IllegalStateException(Localizer.getMessage("jsp.error.outputfolder")); } } catch (MalformedURLException e) { throw new IllegalStateException(Localizer.getMessage("jsp.error.outputfolder"), e); }
這裏對baseUrl進行賦值,聯想到之前看到一個關於UrlClassPath加載資源的解讀,ucp類會根據baseUrl來加載不同的loader進行資源加載。
通過debug我們發現這個地方的baseUrl在OK和NOK兩種情況下確實存在差異。
NOK:
baseURL = file:/tmp/tomcat.2612162063177545213.9090/work/Tomcat/localhost/ROOT
OK:
baseURL = file:/tmp/tomcat.2612162063177545213.9090/work/Tomcat/localhost/ROOT/
對照ucp的代碼
private Loader getLoader(final URL url) throws IOException { try { return java.security.AccessController.doPrivileged( new java.security.PrivilegedExceptionAction<Loader>() { public Loader run() throws IOException { String file = url.getFile(); if (file != null && file.endsWith("/")) { if ("file".equals(url.getProtocol())) { return new FileLoader(url); } else { return new Loader(url); } } else { return new JarLoader(url, jarHandler, lmap, acc); } } }, acc); } catch (java.security.PrivilegedActionException pae) { throw (IOException)pae.getException(); } }
我們發現當出現”/”的時候我們是通過fileLoader來加載資源,而沒有”/”的情況我們默認到jarLoader,用jarLoader去加載一個文件路徑當然會返回ClassNotFound了。
至此我們終於將這個問題的來龍去脈理清楚了,那這一個"/"的差異是怎麼來的呢,回到那段代碼片段:
baseUrl = base.toURI().toURL(); outputDir = base.getAbsolutePath() + File.separator + path + File.separator; if (!makeOutputDir()) {
OK和NOK的情況base是一樣的,唯一的區別就是OK的情況文件目錄都是存在的,而NOK的時候文件夾是沒有的,是不是這種差異導致了一個”/”的差異呢,還是看代碼吧:
base.toURI():
public URI toURI() { try { File f = getAbsoluteFile(); String sp = slashify(f.getPath(), f.isDirectory()); if (sp.startsWith("//")) sp = "//" + sp; return new URI("file", null, sp, null); } catch (URISyntaxException x) { throw new Error(x); // Can't happen } }
Slashify():
private static String slashify(String path, boolean isDirectory) { String p = path; if (File.separatorChar != '/') p = p.replace(File.separatorChar, '/'); if (!p.startsWith("/")) p = "/" + p; if (!p.endsWith("/") && isDirectory) p = p + "/"; return p; }
從上面的代碼可以看出只有滿足isDirectory的判斷纔會給URI加上"/",在我們NOK的情況下由於文件夾不存在isDirectory返回false不會加上結尾的"/”,導致了baseURI的差異,並最終導致了ClassNotFoundException的生產血案。
總結
在這個案例中我們主要使用了經驗法和分析法來定位問題,查找本源。
在經驗分析的過程中我們遇到了阻礙,轉而通過分析法分解了Tomcat對於Jsp請求的處理流程。
在分析Jsp編譯過程時發現會對baseURI進行賦值,結合我們已有的對URLClassLoader的加載過程的理解,於是我們對於baseURI的處理進行了着重分析。
最終發現由於baseURI賦值時系統環境的差異導致了生成的baseURI產生了一個”/”的差異,而這一個差異又導致資源加載的加載器選擇差異,最終導致不合適的加載器加載不到資源的錯誤。
在問題的解決上我們還是沿用官方的說法,指定一個tmp url用來存放tomcat的臨時文件,避免系統服務定時刪除。
-Djava.io.tmpdir=/xxx_web_root
參考材料:
https://github.com/spring-projects/spring-boot/issues/5009
http://www.docjar.com/html/api/sun/misc/URLClassPath.java.html
https://www.jianshu.com/p/b8e331840961
http://www.jinbuguo.com/systemd/tmpfiles.d.html#