0%

Zuul大文件上传失败

本文记录一下通过 Zuul 上传文件时,出现 Java Heap Space 报错的处理过程。

问题描述

App 端上传一个 150M 左右的文件,请求的是 Zuul 网关接口,出现了 Java heap space 的报错,如下:

picture 0

搜寻了相关博客,这个问题是因为 Spring 默认会把上传的文件加载到内存中,如果文件过大就会出现这个问题。

这个问题处理起来可以很简单,直接调整 Zuul 网关的 JVM 配置,然后就能解决了。但是这样治标不治本,如果之后上传一个更大的文件,那么还是可能会出现同样的问题。

尝试解决

在 Spring 中可以添加如下几个配置,用来对文件上传进行限制,如下:Class MultipartProperties

picture 1

其中 file-size-threshold 可以限制 当上传的单个文件大小超过这个值后,文件会被写入磁盘;如果文件大小小于这个值,文件会被保存到内存中。

将这个配置添加到代码中,如下:

1
2
3
4
5
6
# 单个文件最大大小
spring.servlet.multipart.max-file-size=1024MB
# 单次上传文件请求最大大小
spring.servlet.multipart.max-request-size=1024MB
# 当单个文件大小超过这个值后,文件会被写入磁盘
spring.servlet.multipart.file-size-threshold=1MB

但是添加了这个配置,仍然会报 Java heap space 错误。因此猜测这里出现的报错并不是 Spring 给出的,而是 Zuul 网关导致的,下面针对抛出的堆栈信息进行分析。

问题分析

通过打印的堆栈信息,定位到问题是在执行 DebugFilter 类的 shouldFilter() 方法时产生的,如下:

picture 2

这行代码进行了一个 获取请求输入字节并复制 的操作,而在进行文件上传时,请求输入字节占用空间很大,因此导致了堆内存溢出,如下:

picture 3

再回过头看下 DebugFilter 这个类,这个类主要用于 调试 Zuul 路由和请求处理的过程,它允许在 Zuul 网关的生命周期的不同阶段获取请求和响应的详细信息。在 shouldFilter() 方法中可以看到它的开启条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static final DynamicBooleanProperty ROUTING_DEBUG = DynamicPropertyFactory
.getInstance().getBooleanProperty(ZuulConstants.ZUUL_DEBUG_REQUEST, false);

private static final DynamicStringProperty DEBUG_PARAMETER = DynamicPropertyFactory
.getInstance().getStringProperty(ZuulConstants.ZUUL_DEBUG_PARAMETER, "debug");

@Override
public boolean shouldFilter() {
HttpServletRequest request = RequestContext.getCurrentContext().getRequest();
// 如果请求的地址中携带了 `debug=true` 这个配置,那么就能开启这个过滤器
if ("true".equals(request.getParameter(DEBUG_PARAMETER.get()))) {
return true;
}
// 如果配置了 `zuul.debug.request=true`,那么过滤器也能开启
return ROUTING_DEBUG.get();
}

这里涉及两个 zuul 配置:

  • zuul.debug.request。默认值为 false,配置为 true 的话,那么过滤器开启。
  • zuul.debug.parameter。默认值为 debug,在请求地址后面追加 debug=true,或者自定义的值,那么过滤器也能开启。

DebugFilter 过滤器开启后,会将 DebugRoutingDebugRequest 两个值设置为 true,这个两个值为 true 时,就会在特定的场景下添加一些 Debug 信息到 RequestContext 中。

DebugFilter 过滤器的功能目前用不到,因此考虑将这个过滤器中产生异常的代码移除,以此来解决问题。不过这个方法还没来得及尝试,我就意识到不可行,因为在阅读源码时,我发现除了 DebugFilter 过滤器外,还有许多其他的过滤器存在,这些过滤器也会导致 Java Heap Space 报错。

解决办法

到这里似乎卡住了,这时想到去看看官方文档,文档中有说明 Zuul 处理大文件上传的方法:Uploading Files through Zuul

picture 4

在请求地址中添加 /zuul,比如原先上传文件接口是:https://localhost:5655/api-a/fileUpload/upload,调整之后就是 https://localhost:5655/zuul/api-a/fileUpload/upload。增加这个参数后,可以绕过 SpringDispatcherServlet 处理请求的过程,而上面出现的 Java Heap Space 错误就是在这个 DispatcherServlet 中出现的

这里的 /zuul 实在 zuul.servlet-path 中进行配置的,默认值为 /zuul,也可以自定义为其他的值。

因此,针对 Java Heap Space 的处理办法就是,之后 App 端在请求上传文件接口时,在请求路径中添加 /zuul 前缀。

延伸思考

在 Zuul 官方文档中提到,在请求地址中增加 /zuul 后,可以绕过 Spring 的 DispatcherServlet。在看到这个描述后,产生了几个疑问:

  • Spring 中的 DispatcherServlet 是什么?
  • 常规请求和添加了 /zuul 的请求在处理过程上有什么区别?

首先来看下 DispatcherServlet 是什么:

DispatcherServlet 是 Spring MVC 中的核心组件之一,负责处理所有的 HTTP 请求,并将请求分派给相应的控制器进行处理。

再来看下 DispatcherServlet 的工作流程:

  • 收到请求:当 Web 服务器接收到 HTTP 请求时,它会将请求转发给 DispatcherServlet。
  • 解析请求:通过 URL 信息查找合适的处理器(即 Controller),这是通过 HandlerMapping(处理器映射) 完成的。
  • 创建请求和响应对象:一旦找到处理器,DispatcherServlet 会创建一个新的 Request 和 Response 对象,通常是 HttpServletRequestWrapper 和 HttpServletResponseWrapper,这些对象被传递给 Controller 进行处理。
  • 调用控制器处理请求:DispatcherServlet 通过 HandlerAdapter 调用找到的 Controller,控制器处理请求并返回一个 ModelAndView 对象。
  • 视图解析和渲染:返回的 ModelAndView 包含视图名称和模型数据。DispatcherServlet 通过 ViewResolver(视图解析器) 找到正确的视图,并将模型数据传递给视图进行渲染。
  • 返回响应:最后,视图渲染完成后,DispatcherServlet 将响应发送回客户端。

接着看第二个问题:常规请求和添加了 /zuul 的请求在处理过程上有什么区别?来源:chatgpt

带有 /zuul 前缀的请求绕过了 Spring MVC 的 DispatcherServlet,直接由 Zuul 的路由和过滤器处理。常规请求在处理过程中可能会经过 Spring MVC 的 DispatcherServlet 进行处理,包括请求映射、控制器处理、异常处理等机制。

DispatcherServlet 是 Web 服务器的请求处理,而我们项目中的 Zuul 充当的角色是网关服务,进行【路由请求】和【执行过滤器逻辑】操作即可,DispatcherServlet 对请求的处理过程在这里似乎有些多余。而添加了 /zuul 前缀的请求,可以绕过 DispatcherServlet 处理请求的过程,那么是否可以将所有经过 Zuul 网关的请求都绕过 DispatcherServlet 呢?

关闭 Zuul 网关中 DispatcherServlet 请求处理

带着问题去寻找答案,没有找到相关的处理办法,但是在 zuul 的官方库下找到了其他用户提交的 Issue,这个 Issue 中提到的问题与我想的问题一样:What is the relationship between DispatcherServlet and ZuulServlet? #311

picture 5

这个 Issue 中提到的疑问并没有被解答,但是其中提到可以将 servletPath 设置为 / 来使所有经过 Zuul 的请求都绕过 DispatcherServlet。在我们的 Zuul 网关中尝试一下,如下:

1
zuul.servlet-path=/zuul.servlet-path=/

重启 Zuul 网关服务,直接请求文件上传接口,上传一个 150M 的文件,这次没有报错 Java Heap Space,请求成功进入了接口服务(最终也没上传成功,因为接口服务限制只能上传 100M 以内的文件)。

可以看到,配置生效了,所有经过 Zuul 网关的请求都会绕过 DispatcherServlet。增加了这个配置后的 Zuul 网关发挥的作用才是我们预想的效果,即 Zuul 网关只进行【路由请求】和【执行过滤器逻辑】的操作。

补充:Zuul 提供的过滤器

picture 6

注意其中 ServletDetectionFilter 这个过滤器,它的执行顺序为-3,是最先被执行的过滤器。主要用来检测当前请求是需要通过 Spring 的 DispatcherServlet 处理运行的,还是通过 ZuulServlet 来处理运行的。上面我们配置了 zuul.servlet-path=/,那么所有的请求都会通过 ZuulServlet 来处理,看下它的代码:

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
43
44
45
46
47
48
49
50
51
52
53
54
/**
* Detects whether a request is ran through the {@link DispatcherServlet} or
* {@link ZuulServlet}. The purpose was to detect this up-front at the very beginning of
* Zuul filter processing and rely on this information in all filters. RequestContext is
* used such that the information is accessible to classes which do not have a request
* reference.
*
* @author Adrian Ivan
*/
public class ServletDetectionFilter extends ZuulFilter {

public ServletDetectionFilter() {
}

@Override
public String filterType() {
return PRE_TYPE;
}

/**
* Must run before other filters that rely on the difference between DispatcherServlet
* and ZuulServlet.
*/
@Override
public int filterOrder() {
return SERVLET_DETECTION_FILTER_ORDER;
}

@Override
public boolean shouldFilter() {
return true;
}

@Override
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
if (!(request instanceof HttpServletRequestWrapper)
&& isDispatcherServletRequest(request)) {
ctx.set(IS_DISPATCHER_SERVLET_REQUEST_KEY, true);
}
else {
ctx.set(IS_DISPATCHER_SERVLET_REQUEST_KEY, false);
}

return null;
}

private boolean isDispatcherServletRequest(HttpServletRequest request) {
return request.getAttribute(
DispatcherServlet.WEB_APPLICATION_CONTEXT_ATTRIBUTE) != null;
}

}

重复读取请求体

配置 zuul.servlet-path=/ 后,请求登录接口时会出现登录失败的情况,经过定位, 问题出现在下面一行代码处:

1
myUserDetails = new ObjectMapper().readValue(req.getInputStream(), MyUserDetails.class);

代码进行到这里就会出现报错,调查后发现是因为这里进行了 req.getInputStream() 的操作,而 请求体通过 getInputStream() 读取时,只能读取一次。如果请求体中 InputStream 已经被访问过了,这里就会获取不到数据。

在 Zuul 网关自定义的过滤器中,进行了打印请求体的操作,其中包含了 req.getInputStream() 的操作,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 请求参数为空,那么打印请求体
if (parameterMap == null || parameterMap.isEmpty()) {
try {
// 打印 URL 编码表单数据,包含 multipart 类型的请求体无需打印
if (request.getContentType() == null || !request.getContentType().startsWith("multipart/")) {
String requestBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
if (StringUtils.isNotBlank(requestBody)) {
Map<String, String> formData = Arrays.stream(requestBody.split("&"))
.map(part -> part.split("="))
.collect(Collectors.toMap(
arr -> arr[0],
arr -> arr.length > 1 ? arr[1] : ""
));
log.info("请求体:{}", URLDecoder.decode(JSON.toJSONString(formData), StandardCharsets.UTF_8.name()));
}
}
} catch (IOException e) {
log.error("请求体信息获取失败!", e);
}
}

这里打印请求体的操作并不是必要的,因此直接将这段代码移除了。

那为什么没有添加 /zuul 前缀的请求不存在这个问题呢?来源:chatgpt

因为没有添加 /zuul 前缀的请求是完全交由 Spring MVC 处理的,默认的 DispatcherServlet 处理流程能够确保在控制器或中间件逻辑中多次访问请求体。此外,Spring 的 HttpServletRequestWrapper 机制可以缓存请求体数据,使得同一个请求的请求体可以被多次读取。

总结

App 端通过 Zuul 网关上传大文件时,出现了 Java heap space 内存溢出错误。这个问题可以通过调整 JVM 配置来解决,但是考虑这样调整只能治标不治本,之后如果上传更大的文件仍然会产生问题,因此尝试找到问题产生的原因并解决。

初步猜测问题是 Spring 文件配置的原因,因此增加了 file-size-threshold 配置。Spring 默认将上传的文件加载到内存中,而这个配置的作用是 当单个文件大小超过这个值后,文件会被写入磁盘,但是添加之后并没有生效。

之后通过报错打印的堆栈信息定位到报错是在 DebugFilter 过滤器中产生的,这个过滤器进行了 读取请求字节流 的操作,因此将 DebugFilter 过滤器禁用,但是没有实施,因为意识到可能还有其他的过滤器也进行了 读取请求字节流 的操作。

之后继续寻找解决办法,在 Spring Cloud Zuul 官方文档中找到了处理办法,在请求地址中增加 /zuul 前缀,尝试之后成功了,没有出现 Java Heap Space 报错。

问题虽然解决了,但是其背后的原理并没有搞懂。因此继续调查 /zuul 前缀是如何生效的,了解到这样处理 可以绕过 SpringDispatcherServlet 处理请求的过程。其中 DispatcherServlet 是 Spring MVC 中的核心组件之一,负责处理所有的 HTTP 请求,并将请求分派给相应的控制器进行处理。默认情况下,所有经过 Zuul 网关的请求,都会经过 DispatcherServlet 进行处理。而我们的 Zuul 网关服务只需要进行【路由请求】和【执行过滤器逻辑】就可以了,DispatcherServlet 的处理过程在这里并没有什么用,只会额外占用请求资源,因此思考是否可以 将所有经过 Zuul 网关的请求都绕过 DispatcherServlet

针对这个问题继续调查,在官方库下寻找对应的 Issue,可以通过增加 zuul.servlet-path=/ 这个配置,这样所有的请求都会绕过 DispatcherServlet。这么处理后,Zuul 网关处理请求的效率也得到了提升(文件上传请求效率提升很明显)。

至此,整个优化工作完成了。出现这个问题还是因为对 ZuulSpring MVC 的工作原理理解不够深入导致的,之后还是要加强一下。

参考文档

SpringBoot文件上传解析

Servlet 3.0笔记之超方便的文件上传支持

Class MultipartProperties

Uploading Files through Zuul

What is Dispatcher Servlet in Spring?

What is the relationship between DispatcherServlet and ZuulServlet? #311

Problem with the alternative “/zuul” path bypassing DispatcherServlet in Zuul #546