xiaobaoqiu Blog

Think More, Code Less

ShallowEtagHeaderFilter

1.背景

之前组内遇到的一个 ShallowEtagHeaderFilter 导致流式导出失效的问题, 具体的问题分析不是我做的, 这里记录下问题及原理.

现象就是一个数据导出接口, 用户的频繁调用导致线上内存吃满, 进而频繁 GC 导致 CPU 过高最终影响用户请求.

2.Mysql流式读取

先交代一下导出接口的实现方式, 考虑到导出数据可能比较大导致撑爆内存, 数据导出是流式导出的.

默认情况下, 我们的select从服务器一次取出所有数据放在客户端内存中, 当一条SQL返回数据量较大时可能会出现OOM.

这里首先涉及到Mysql如何配置流式读取数据, 涉及到两个配置(第一个配置很重要很容易被忘记)

1.Mysql连接串中配置上参数useCursorFetch=true;
2.Sql中配置上FetchSize等参数

下面以MyBatis为例子, 通过设置Mapper文件中select的属性来启用这一特性

1
2
3
<select id="xxx" fetchSize="100" resultSetType="FORWARD_ONLY">
  ...
</select>

然后, 配合 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
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {

  response = new ShallowEtagResponseWrapper(response);    //1.将所有的请求包装成 ShallowEtagResponseWrapper

  filterChain.doFilter(request, response);        //2. 其他 filter

  updateResponse(request, response);      //3.返回给客户端之前处理一次 response
}

看一下包装 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
/**
 * {@link HttpServletRequest} wrapper that buffers all content written to the
 * {@linkplain #getOutputStream() output stream} and {@linkplain #getWriter() writer},
 * and allows this content to be retrieved via a {@link #toByteArray() byte array}.
 */
private static class ShallowEtagResponseWrapper extends HttpServletResponseWrapper {

  private final ByteArrayOutputStream content = new ByteArrayOutputStream();

  private final ServletOutputStream outputStream = new ResponseServletOutputStream();

  private PrintWriter writer;
  ...
  public PrintWriter getWriter() throws IOException { //获取 writer, 采用自定义的 ResponsePrintWriter, 后续的写会将内容写到 content 中
      if (this.writer == null) {
          String characterEncoding = getCharacterEncoding();
          this.writer = (characterEncoding != null ? new ResponsePrintWriter(characterEncoding) :
                  new ResponsePrintWriter(WebUtils.DEFAULT_CHARACTER_ENCODING));
      }
      return this.writer;
  }
  private byte[] toByteArray() {  // 读取 content 内容
      return this.content.toByteArray();
  }

  // 自定义 ServletOutputStream, write操作将内容写入到 content 中
  private class ResponseServletOutputStream extends ServletOutputStream {
      @Override
      public void write(int b) throws IOException {
          content.write(b);   //写到 content 中
      }
      ...
  }

  // 自定义 PrintWriter, 写入到 content 中
  private class ResponsePrintWriter extends PrintWriter {
      private ResponsePrintWriter(String characterEncoding) throws UnsupportedEncodingException {
          super(new OutputStreamWriter(content, characterEncoding));
      }
      ...
  }
}

最后我们看看 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
private void updateResponse(HttpServletRequest request, HttpServletResponse response) throws IOException {
  ...
  byte[] body = responseWrapper.toByteArray();        //缓冲起来的数据
  int statusCode = responseWrapper.getStatusCode();

  if (isEligibleForEtag(request, responseWrapper, statusCode, body)) {
      String responseETag = generateETagHeaderValue(body);    //基于内容生成 ETag
      response.setHeader(HEADER_ETAG, responseETag);      //设置 ETag Header

      String requestETag = request.getHeader(HEADER_IF_NONE_MATCH);   //If-None-Match 
      if (responseETag.equals(requestETag)) {                 //请求的If-None-Match和ETag相同, 则304
          response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);    //304
      } else {
          copyBodyToResponse(body, response);     //将缓冲的内容写入到 response 中
      }
  } else {
      ...
      copyBodyToResponse(body, response);
  }
}

private void copyBodyToResponse(byte[] body, HttpServletResponse response) throws IOException {
  if (body.length > 0) {
      response.setContentLength(body.length);         //设置 Content-Length
      FileCopyUtils.copy(body, response.getOutputStream());       //ShallowEtagResponseWrapper数据拷贝到response中
  }
}

总结 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 的时候排除掉流式的接口.