1.背景
之前组内遇到的一个 ShallowEtagHeaderFilter 导致流式导出失效的问题, 具体的问题分析不是我做的, 这里记录下问题及原理.
现象就是一个数据导出接口, 用户的频繁调用导致线上内存吃满, 进而频繁 GC 导致 CPU 过高最终影响用户请求.
2.Mysql流式读取
先交代一下导出接口的实现方式, 考虑到导出数据可能比较大导致撑爆内存, 数据导出是流式导出的.
默认情况下, 我们的select从服务器一次取出所有数据放在客户端内存中, 当一条SQL返回数据量较大时可能会出现OOM.
这里首先涉及到Mysql如何配置流式读取数据, 涉及到两个配置(第一个配置很重要很容易被忘记)
1.Mysql连接串中配置上参数useCursorFetch=true;
2.Sql中配置上FetchSize等参数
下面以MyBatis为例子, 通过设置Mapper文件中select的属性来启用这一特性
1 2 3 |
|
然后, 配合 ResultHandler 就可以实现逐行流式处理数据库中的记录.
我们就是采用这种方式, 流式获取数据写到 Response 的输出流中.
3.ShallowEtagHeaderFilter
3.1 Content-Length 和 Transfer-Encoding
在描述问题之前, 我们先普及一下 Http 请求中 Content-Length 和 Transfer-Encoding 的知识.
Content-Length 用于描述HTTP消息实体的传输长度(the transfer-length of the message-body), 需要注意消息实体长度和消息实体传输长度是有区别, 比如说gzip压缩下, 消息实体长度是压缩前的长度, 消息实体的传输长度是gzip压缩后的长度.
通过读取 Content-Length 这个信息, 服务器/客户端可以预先知道数据量的大小, 以便预先分配空间.
Transfer-Encoding 所描述的是消息请求(request)和响应(response)所附带的实体对象(entity)的传输形式. 可选值有: chunked 和 identity, 其中chunked指把要发送传输的数据切割成一系列的块数据传输, identity指传输时不做任何处理, 自身的本质数据形式传输. 举个例子, 如果我们要传输一本小说到服务器, chunked方式就会先把这本小说分成一章一章的, 然后逐个章节上传, 而identity方式则是从小说的第一个字按顺序传输到最后一个字结束.
HTTP/1.1 规定, 所有服务器必须支持 Chunked 类型的 Transfer-Encoding, 通过这个头部, 服务器不必在返回回应报文的时候预先知道返回 Body 的大小, 而是将数据分割到一个一个的 Chunk 中(每个 Chunk 通过特定的头部和尾部区分, 最后一个 Chunk 大小为 0, 以表示报文结束), 然后持续不断地写入 TCP 流, 通过这个机制, 可以实现服务器数据到客户端的流式输出.
注意关于 Content-Length 和 Transfer-Encoding 一个准则: 有了Transfer-Encoding, 则不能有Content-Length
参考: https://tools.ietf.org/html/rfc2616#section-4.4. http://www.cnblogs.com/jcli/archive/2012/10/19/2730440.html http://blog.csdn.net/pud_zha/article/details/8809878
3.2 问题分析
按照我们上面的分析, 我们的 Response 的 Transfer-Encoding 肯定是 chunked, 并且没有设置 Content-Length.但是我们发现请求的 Response 中存在 Content-Length 字段.
我们将请求的 Response 分为三个阶段: (1).从数据库读取数据; (2).服务器端整理数据发往浏览器端; (3).浏览器端接受数据;
现在已知从 Mysql 中读取数据是流式的, 而浏览器接收到的数据不是流式的, 因此问题审核出在服务器端.
最终定位到的罪魁祸首是一个 ShallowEtagHeaderFilter 的 Filter.
3.3 ShallowEtagHeaderFilter原理
ShallowEtagHeaderFilter 继承了 OncePerRequestFilter, 我们可以分析一下 ShallowEtagHeaderFilter 的主要代码逻辑:
1 2 3 4 5 6 7 8 9 10 |
|
看一下包装 ShallowEtagResponseWrapper 做了什么: (其实看注释就已经知道, 将所有写到 output stream 和 writer 的内容缓冲起来放到, 并提供 oByteArray() 方法用于读取缓冲的数据):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
|
最后我们看看 updateResponse 做了什么事:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
|
总结 ShallowEtagHeaderFilter 作用: 1.ShallowEtagHeaderFilter 缓冲了写到 response 的所有数据; 2.如果请求的 If-None-Match 头部信息和当前根据内容生成的 ETag 相同,则请求为 304; 3.最后 ShallowEtagHeaderFilter 将缓冲的所有内容一次性写到 response 中;
需要注意的是, 使用这个 ShallowEtagHeaderFilter, 服务端的性能并没有得到提高, 因为请求逻辑还是执行了, 只是没往客户端发送, 因此节省了一定的网络带宽.
另外, 这个 ShallowEtagHeaderFilter 会让 Transfer-Encoding=Chunked 失效(我们这里就是这种情况).
3.4 解决方案
根据上面的分析, 我们这里的问题就很好解释了, ShallowEtagHeaderFilter 将我们的流式写缓冲起来, 最后一次性写给客户端并且设置上了 Content-Length 字段.
关于这个问题, 其实很多人都遇到坑了, 有人给 Spring 提 Bug 了, 但是官网认为只是误用(我赞同这种观点). 参考: https://jira.spring.io/browse/SPR-10855
解决很简单, 去掉 ShallowEtagHeaderFilter 这个过滤器, 或者给 ShallowEtagHeaderFilter 配置 url pattern 的时候排除掉流式的接口.