本文记录一下通过 Zuul 上传文件时,出现 Java Heap Space 报错的处理过程。
问题描述
App 端上传一个 150M
左右的文件,请求的是 Zuul
网关接口,出现了 Java heap space 的报错,如下:
搜寻了相关博客,这个问题是因为 Spring 默认会把上传的文件加载到内存中,如果文件过大就会出现这个问题。
这个问题处理起来可以很简单,直接调整 Zuul 网关的 JVM 配置,然后就能解决了。但是这样治标不治本,如果之后上传一个更大的文件,那么还是可能会出现同样的问题。
尝试解决
在 Spring 中可以添加如下几个配置,用来对文件上传进行限制,如下:Class MultipartProperties
其中 file-size-threshold
可以限制 当上传的单个文件大小超过这个值后,文件会被写入磁盘;如果文件大小小于这个值,文件会被保存到内存中。
将这个配置添加到代码中,如下:
1 | # 单个文件最大大小 |
但是添加了这个配置,仍然会报 Java heap space 错误。因此猜测这里出现的报错并不是 Spring 给出的,而是 Zuul 网关导致的,下面针对抛出的堆栈信息进行分析。
问题分析
通过打印的堆栈信息,定位到问题是在执行 DebugFilter
类的 shouldFilter()
方法时产生的,如下:
这行代码进行了一个 获取请求输入字节并复制 的操作,而在进行文件上传时,请求输入字节占用空间很大,因此导致了堆内存溢出,如下:
再回过头看下 DebugFilter
这个类,这个类主要用于 调试 Zuul 路由和请求处理的过程,它允许在 Zuul 网关的生命周期的不同阶段获取请求和响应的详细信息。在 shouldFilter()
方法中可以看到它的开启条件:
1 | private static final DynamicBooleanProperty ROUTING_DEBUG = DynamicPropertyFactory |
这里涉及两个 zuul 配置:
zuul.debug.request
。默认值为false
,配置为true
的话,那么过滤器开启。zuul.debug.parameter
。默认值为debug
,在请求地址后面追加debug=true
,或者自定义的值,那么过滤器也能开启。
而 DebugFilter
过滤器开启后,会将 DebugRouting
和 DebugRequest
两个值设置为 true
,这个两个值为 true
时,就会在特定的场景下添加一些 Debug
信息到 RequestContext
中。
DebugFilter
过滤器的功能目前用不到,因此考虑将这个过滤器中产生异常的代码移除,以此来解决问题。不过这个方法还没来得及尝试,我就意识到不可行,因为在阅读源码时,我发现除了 DebugFilter
过滤器外,还有许多其他的过滤器存在,这些过滤器也会导致 Java Heap Space
报错。
解决办法
到这里似乎卡住了,这时想到去看看官方文档,文档中有说明 Zuul 处理大文件上传的方法:Uploading Files through Zuul
在请求地址中添加 /zuul
,比如原先上传文件接口是:https://localhost:5655/api-a/fileUpload/upload
,调整之后就是 https://localhost:5655/zuul/api-a/fileUpload/upload
。增加这个参数后,可以绕过 Spring
的 DispatcherServlet
处理请求的过程,而上面出现的 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
这个 Issue
中提到的疑问并没有被解答,但是其中提到可以将 servletPath
设置为 /
来使所有经过 Zuul 的请求都绕过 DispatcherServlet。在我们的 Zuul 网关中尝试一下,如下:
1 | /zuul.servlet-path=/ = |
重启 Zuul 网关服务,直接请求文件上传接口,上传一个 150M
的文件,这次没有报错 Java Heap Space,请求成功进入了接口服务(最终也没上传成功,因为接口服务限制只能上传 100M 以内的文件)。
可以看到,配置生效了,所有经过 Zuul 网关的请求都会绕过 DispatcherServlet。增加了这个配置后的 Zuul 网关发挥的作用才是我们预想的效果,即 Zuul 网关只进行【路由请求】和【执行过滤器逻辑】的操作。
补充:Zuul 提供的过滤器
注意其中 ServletDetectionFilter
这个过滤器,它的执行顺序为-3,是最先被执行的过滤器。主要用来检测当前请求是需要通过 Spring 的 DispatcherServlet 处理运行的,还是通过 ZuulServlet 来处理运行的。上面我们配置了 zuul.servlet-path=/
,那么所有的请求都会通过 ZuulServlet 来处理,看下它的代码:
1 | /** |
重复读取请求体
配置 zuul.servlet-path=/
后,请求登录接口时会出现登录失败的情况,经过定位, 问题出现在下面一行代码处:
1 | myUserDetails = new ObjectMapper().readValue(req.getInputStream(), MyUserDetails.class); |
代码进行到这里就会出现报错,调查后发现是因为这里进行了 req.getInputStream()
的操作,而 请求体通过 getInputStream() 读取时,只能读取一次。如果请求体中 InputStream 已经被访问过了,这里就会获取不到数据。
在 Zuul 网关自定义的过滤器中,进行了打印请求体的操作,其中包含了 req.getInputStream()
的操作,如下:
1 | // 请求参数为空,那么打印请求体 |
这里打印请求体的操作并不是必要的,因此直接将这段代码移除了。
那为什么没有添加 /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
前缀是如何生效的,了解到这样处理 可以绕过 Spring
的 DispatcherServlet
处理请求的过程。其中 DispatcherServlet 是 Spring MVC 中的核心组件之一,负责处理所有的 HTTP 请求,并将请求分派给相应的控制器进行处理。默认情况下,所有经过 Zuul 网关的请求,都会经过 DispatcherServlet 进行处理。而我们的 Zuul 网关服务只需要进行【路由请求】和【执行过滤器逻辑】就可以了,DispatcherServlet 的处理过程在这里并没有什么用,只会额外占用请求资源,因此思考是否可以 将所有经过 Zuul 网关的请求都绕过 DispatcherServlet。
针对这个问题继续调查,在官方库下寻找对应的 Issue,可以通过增加 zuul.servlet-path=/ 这个配置,这样所有的请求都会绕过 DispatcherServlet。这么处理后,Zuul 网关处理请求的效率也得到了提升(文件上传请求效率提升很明显)。
至此,整个优化工作完成了。出现这个问题还是因为对 Zuul
和 Spring MVC
的工作原理理解不够深入导致的,之后还是要加强一下。
参考文档
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