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
EmptyResponse
。LVS
动态修改TCP包的目标IP地址,并转发数据包使其到达不一样的机器上来实现负载均衡的目的,所以LVS
节点不会引发超时。我的理解,不必定准确。504 Gateway Timeout
。Tomcat/Servlet
处理超时。这层对应本地环境产生的超时,如Socket
超时、InputStream/OutputStream
超时。对应的超时优化有3种思路。github
例如使用多线程并发减小远程查询的整体时间(如需数据有序,可使用
Fork/Join
方案)。web
该方案的优势是减小了对外的总体查询的时间。缺点是多线程增长了开发和维护的难度;高并发压力转移到内部的查询服务上,对其QPS响应提出了更高的要求。ajax
浏览器请求下载后,服务端当即返回报表的惟一标识
Key
同时开始远程查询数据,客户端能够凭借该Key
查询报表的生成进度,报表完成后就能够下载;或者使用另外一种方案,服务器在报表生成完成后经过一些渠道(如Long-Polling
、WebSocket
、即时通讯软件、邮件等)通知客户端下载。chrome
该方案的优势是并发能力强,不会阻塞服务器的Web链接池。缺点是须要开发Key
的CRUD操做和相应的UI;须要公有文件云的支持用于存储生成的报表文件。json
就像下载大文件同样,浏览器不断开和服务器的Http链接,同时服务器不断向浏览器追加Http体数据直到报表生成结束。
该方案的优势是开发难度低、速度快。缺点是数据查询是单线程的,速度较慢;并且文件下载会一直占用服务器的Web链接池,若是并发下载量较大可能会阻塞其余的Http请求。
由于在实际的业务开发中,前2种思路作的比较多,因此后文再也不赘述。
该方案的关键在于业务方法返回后SpringMvc/Servlet
不能主动关闭Http链接,而是要像日常下载文件同样保持Http的长链接(注意Http长链接要和Http 1.1
协议默认采用的Tcp长链接相区分),惟一不一样的是此次浏览器没法提早知道文件的大小。所以对于技术方案我考虑有几种选择:
分屡次查询/推送数据,浏览器最后把数据组装为报表。
Polling
)。客户端轮询服务器,每次查询报表数据的一部分,查询结束后再组装成报表文件。Long-Polling
)。客户端轮询服务器,服务器在收到请求后Hold住Http链接,等待另外一部分的数据查询完成才释放链接并返回Response。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); }
SpringMvc
的ResponseBodyEmitter
实际上利用了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圆圈,文案提示即将开始下载,而后文件大小开始逐渐增加,最终完成下载过程。
我尝试了几种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
源码,可是我没有找到相关逻辑,也有可能我找错了方向,但愿熟悉这块的朋友不吝赐教。
解决了上面的问题后,代码在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"
application/octet-stream
表明未知格式的二进制流。 ↩