【SpringMvc】後臺系統超大報表下載超時的處理

B端業務常常要提供下載報表的功能,通常的方法是先查詢出全部數據,而後在內存中組裝成報表(如XLS/XLSX格式)後統一輸出。可是若是生成報表須要查詢的數據量很大,遠程服務的調用時間之和遠遠超過了鏈路上某節點(好比代理服務器Nginx、瀏覽器Chrome)的等待時間,所以該次Http鏈接就會被強制關閉,致使下載失敗。css

下面的示例代碼調用了Thread.sleep,將處理線程掛起3分鐘,模擬耗時的數據查詢操做。html

@GetMapping("/trade/income/excel")
public HttpEntity<byte[]> downloadTradeIncome() {
    ServletOutputStream stream = response.getOutputStream();
    response.setContentType("application/octet-stream;charset=UTF-8");
    response.setHeader("Content-Disposition", "attachment;fileName=test.csv");
    stream.write("start".getBytes(Charsets.UTF_8));
    response.flushBuffer();
    Thread.sleep((long) (3 * 64 * 1000));//chrome 2min超時會主動斷開鏈接
    stream.write("finish".getBytes(Charsets.UTF_8));
}

常見超時緣由和優化思路

大型的Web應用通常都不是單純的Client/Server模型。一次Http請求會在網絡鏈路上通過多於2個的節點。前端

Chrome(用戶端的瀏覽器) <=> 四層負載均衡(工做在傳輸層,如LVS和MGW) <=> 七層負載均衡(工做在應用層,如反向代理用的Nginx) <=> Tomcat(後端應用服務器)java

鏈路上的每一個節點都有可能會產生超時,所以具體的超時緣由也能夠分爲:git

  1. Chrome發起請求後,等待響應超時。該值爲120秒,且用戶不可更改,超時後頁面上會提示EmptyResponse
  2. 四層負載均衡不會引發超時。LVS動態修改TCP包的目標IP地址,並轉發數據包使其到達不一樣的機器上來實現負載均衡的目的,所以LVS節點不會引發超時。我的理解,不必定準確。
  3. 七層負載均衡等待上游響應超時。Nginx代理了客戶端瀏覽器對後端服務器的Http請求,做爲反向代理服務器須要「同時」維護與瀏覽器和後端服務器的Http鏈接,所以也會產生相應的超時,例如Nginx等待上游響應超時就會產生504 Gateway Timeout
  4. Tomcat/Servlet處理超時。這層對應本地環境產生的超時,如Socket超時、InputStream/OutputStream超時。

對應的超時優化有3種思路。github

1. 縮短後端查詢數據的時間。

例如使用多線程併發減小遠程查詢的整體時間(如需數據有序,可使用Fork/Join方案)。web

該方案的優勢是減小了對外的總體查詢的時間。缺點是多線程增長了開發和維護的難度;高併發壓力轉移到內部的查詢服務上,對其QPS響應提出了更高的要求。ajax

2. 將數據查詢和下載的流程異步化。

瀏覽器請求下載後,服務端當即返回報表的惟一標識Key同時開始遠程查詢數據,客戶端能夠憑藉該Key查詢報表的生成進度,報表完成後就能夠下載;或者使用另外一種方案,服務器在報表生成完成後經過一些渠道(如Long-PollingWebSocket、即時通訊軟件、郵件等)通知客戶端下載。chrome

該方案的優勢是併發能力強,不會阻塞服務器的Web鏈接池。缺點是須要開發Key的CRUD操做和相應的UI;須要公有文件雲的支持用於存儲生成的報表文件。json

3. 服務端邊生成報表,瀏覽器邊下載報表。

就像下載大文件同樣,瀏覽器不斷開和服務器的Http鏈接,同時服務器不斷向瀏覽器追加Http體數據直到報表生成結束。

該方案的優勢是開發難度低、速度快。缺點是數據查詢是單線程的,速度較慢;並且文件下載會一直佔用服務器的Web鏈接池,若是併發下載量較大可能會阻塞其餘的Http請求。

由於在實際的業務開發中,前2種思路作的比較多,因此後文再也不贅述。

方案3的具體實現

該方案的關鍵在於業務方法返回後SpringMvc/Servlet不能主動關閉Http鏈接,而是要像日常下載文件同樣保持Http的長鏈接(注意Http長鏈接要和Http 1.1協議默認採用的Tcp長鏈接相區分),惟一不一樣的是此次瀏覽器沒法提早知道文件的大小。所以對於技術方案我考慮有幾種選擇:

  1. 服務器邊查詢數據並生成,瀏覽器邊下載,像日常下載文件同樣。
  2. 分屢次查詢/推送數據,瀏覽器最後把數據組裝爲報表。

    1. 輪詢(Polling)。客戶端輪詢服務器,每次查詢報表數據的一部分,查詢結束後再組裝成報表文件。
    2. 長輪詢(Long-Polling)。客戶端輪詢服務器,服務器在收到請求後Hold住Http鏈接,等待另外一部分的數據查詢完成才釋放鏈接並返回Response。
    3. WebSocket。支持Html5特性的瀏覽器和服務器之間創建Socket管道,能夠雙向傳遞任意類型的消息。

第一種方案的優勢是不須要前端參與開發,缺點是沒法支持二進制格式的報表文件(如XLS/XLSX),只能用文本格式(如CSV/TSV),這會帶來格式的損失,好比CSV格式裏位數超過10位的數字會被Excel自動顯示成科學記數法。第二種方案正好相反,須要前端開發人力,可是能夠支持組裝二進制格式的報表。

PS:除了經典的Apache POI庫,聽說Java世界還有流式生成XLS/XLSX的庫,這點有待確認。

由於搞不到前端人力,實際上仍是用方案1實現。下面的代碼模擬了用SpringMvc實現異步下載報表的功能。handle7()結束後會當即返回Http頭,告訴瀏覽器將返回一個長度未知且格式未知1的二進制文件,並推薦執行文件下載操做。

private ExecutorService pool = Executors.newFixedThreadPool(5);
@GetMapping("events7")
public ResponseEntity<ResponseBodyEmitter> handle7() throws IOException {
    ResponseBodyEmitter emitter = new ResponseBodyEmitter();
    emitter.send("start,");
    pool.execute(() -> {
      try {
          Thread.sleep((long) (3 * 64 * 1000));
          emitter.send("finish\r\n");
          emitter.complete();
      } catch (IOException | InterruptedException e) {}
    });
    HttpHeaders headers = new HttpHeaders();
    headers.set("Content-Type", "application/octet-stream;charset=UTF-8");
    headers.set("Transfer-Encoding", "chunked");
    headers.setContentDispositionFormData("attachment", "test.csv", Charsets.UTF_8);
    return new ResponseEntity<>(emitter, headers, HttpStatus.OK);
}

SpringMvcResponseBodyEmitter實際上利用了Servlet3+的異步特性,耗時較長的請求無需一直佔用Web請求處理的線程池,大大提升了服務器的併發能力。啓動Tomcat後訪問http://localhost:8080/events7便可查看效果。

但實際上上面的代碼沒法在Webkit核心下的Chrome/Safari瀏覽器上獲得預期的結果。測試中Chrome沒法自動開始下載,而是會阻塞在Loading階段,直到超過了2分鐘的最大等待時間後告訴用戶發生了EmptyResponse

在Inspector界面上不顯示Response的Http頭和部分Http體數據(即"start"字符串)。可是經過Charles抓包發現,Response的Http頭和"start"字符串已經發出,這是一個奇怪的地方。

幾回嘗試後發現,問題出如今MIME(即Content-Type)上,Chrome對application/octet-stream類型彷佛採起了接受到完整的Http包纔開始下載文件的邏輯,換成application/csv後Chrome順利的開始自動下載,下方狀態欄出現Loading圓圈,文案提示即將開始下載,而後文件大小開始逐漸增加,最終完成下載過程。

兩個未解之謎

1. MIME對Chrome下載行爲的影響

我嘗試了幾種Chrome會馬上觸發下載的MIME

  • text/csv
  • text/css
  • text/markdown
  • text/event-stream
  • text/html
  • application/csv
  • application/pdf
  • application/json
  • application/xhtml+xml
  • application/x-www-form-urlencoded
  • application/atom+xml
  • multipart/form-data

還有一些Chrome不會自動觸發下載並最終致使超時的MIME

  • application/octet-stream
  • application/xml
  • text/xml
  • text/plain

要解釋這個問題可能須要查看Webkit源碼,可是我沒有找到相關邏輯,也有可能我找錯了方向,但願熟悉這塊的朋友不吝賜教。

2. Nginx引發的502問題

解決了上面的問題後,代碼在Beta環境出現了新的問題。Nginx代理提示502 Bad Gateway The proxy server received an invalid response from an upstream server。查看Nginx日誌,具體的錯誤信息以下。應該是Transfer-Encoding設置爲chunked,致使Nginx認爲該Http頭非法。這個問題也是使人摸不到頭腦,但願熟悉Http1.1規範分塊傳輸編碼的朋友不吝賜教。

2017/10/19 15:14:17 [error] 30016#0: *409143 upstream sent invalid chunked response while reading upstream, client: 10.72.227.11, server: www.dianping.com, request: "GET /s/ajax/shop/finance/trade/income/excel?shopIdList[]=&startDate=2017-09-20%2000:00&endDate=2017-10-19%2023:59 HTTP/1.1", upstream: "http://127.0.0.1:8080/s/ajax/shop/finance/trade/income/excel?shopIdList[]=&startDate=2017-09-20%2000:00&endDate=2017-10-19%2023:59", host: "dev.orderdish.ecom.web.meituan.com"

Reference

  1. MIME (Multipurpose Internet Mail Extensions) Part One: Mechanisms for Specifying and Describing the Format of Internet Message Bodies
  2. Returning Values from Forms: multipart/form-data
  3. Multipurpose Internet Mail Extensions (MIME) Part Two: Media Types
  4. Content-Disposition
  5. webkit-2.18.0
  6. Use of the Content-Disposition Header Field in the Hypertext Transfer Protocol (HTTP)
  7. Communicating Presentation Information in Internet Messages: The Content-Disposition Header Field
  8. Returning Values from Forms: multipart/form-data
  9. 深刻剖析 WebKit
  10. HTTP 協議中的 Transfer-Encoding
  11. 分塊傳輸編碼
  12. Webkit學習 ----網頁資源的構建加載流程
  13. WebKit內核源代碼分析(四)
  14. Nginx中502和504錯誤詳解

  1. RFC1521規定application/octet-stream表明未知格式的二進制流。
相關文章
相關標籤/搜索