[spring-projects/spring-boot]默认切换到 Apache EL 实现

2024-07-08 782 views
5

我们注意到,从 Spring Boot 2.3.0 开始,访问不存在的属性时,JSP EL 表达式求值变得明显变慢。这个问题似乎影响 Spring Boot 2.3.x(2.3.0 至 2.3.7)和 2.4.x(2.4.0 和 2.4.1)的所有当前版本,但不影响 2.2.x。

从 Spring Boot 2.3.0 开始,会发生以下情况:对于在 JSP 上进行 Spring EL 表达式评估期间访问的每个不存在的属性(例如,当使用${myProperty}<c:if test="${myProperty}">...</c:if>并且视图模型中未定义此类属性时),都会进行类加载器调用来查找类“ java.lang.<attribute>”、“ java.servlet.<attribute>”、“ java.servlet.http.<attribute>”和“ java.servlet.jsp.<attribute>”。

每次请求(或每个 JSP/标签上下文)的每个唯一属性都会发生一次这种情况;在相同上下文中再次访问相同属性不会导致额外的类查找。这显著减慢了 EL 表达式评估的速度,因为在空闲机器上,每个属性的 4 次查找需要几毫秒。当访问确实存在的属性时,不同的 Spring Boot 版本之间没有性能差异。至少在我们的 HTML 前端中,我们通常只在某些情况下设置模型属性,然后 JSP/标签检查它们是否已设置,因此对不存在的属性的访问非常频繁,这会导致明显的性能问题。

根本原因似乎是ExpressionFactory使用了不同的版本(com.sun.el.ExpressionFactoryImpl在 2.3.0+ 中而不是org.apache.el.ExpressionFactoryImpl在 2.2.x 中)。后者将某个上下文属性设置为 true(见下文),但前者没有。

包含ScopedAttributeELResolver以下代码:

   boolean resolveClass = true;
    // Performance short-cut available when running on Tomcat
    if (AST_IDENTIFIER_KEY != null) {
        // Tomcat will set this key to Boolean.TRUE if the
        // identifier is a stand-alone identifier (i.e.
        // identifier) rather than part of an AstValue (i.e.
        // identifier.something). Imports do not need to be
        // checked if this is a stand-alone identifier
        Boolean value = (Boolean) context.getContext(AST_IDENTIFIER_KEY);
        if (value != null && value.booleanValue()) {
            resolveClass = false;
        }
    }
    // This might be the name of an imported class
    ImportHandler importHandler = context.getImportHandler();
    if (importHandler != null) {
        Class<?> clazz = null;
        if (resolveClass) {
            clazz = importHandler.resolveClass(key);
        }
      ... 

AST_IDENTIFIER_KEY 是类org.apache.el.parser.AstIdentifier

在 Spring Boot 2.2.x 中,由于上下文布尔值为,因此resolveClass设置为,而在 2.3.0 及更高版本中,它仍为。当调用时会出现性能问题,它会执行四个类查找。falsetruetrueimportHandler.resolveClass(key)

通常应该设置此属性的类是org.apache.el.parser.AstIdentifier(在 Spring Boot 2.3.0+ 中没有使用,有com.sun.el.parser.AstIdentifier使用),使用以下代码:

  /* Putting the Boolean into the ELContext is part of a performance
   * optimisation for ScopedAttributeELResolver. When looking up "foo",
   * the resolver can't differentiate between ${ foo } and ${ foo.bar }.
   * This is important because the expensive class lookup only needs to
   * be performed in the later case. This flag tells the resolver if the
   * lookup can be skipped.
   */
  if (parent instanceof AstValue) {
      ctx.putContext(this.getClass(), Boolean.FALSE);
  } else {
      ctx.putContext(this.getClass(), Boolean.TRUE);
  }

设置时类加载变得可见 logging.level.org.apache.catalina.loader=DEBUG

在 Spring Boot 2.3.0 及更高版本中,访问时每个请求都会记录以下内容${myProperty}

o.a.c.loader.WebappClassLoaderBase       :     findClass(java.lang.myProperty)
o.a.c.loader.WebappClassLoaderBase       :     --> Returning ClassNotFoundException
o.a.c.loader.WebappClassLoaderBase       :     findClass(javax.servlet.myProperty)
o.a.c.loader.WebappClassLoaderBase       :     --> Returning ClassNotFoundException
o.a.c.loader.WebappClassLoaderBase       :     findClass(javax.servlet.http.myProperty)
o.a.c.loader.WebappClassLoaderBase       :     --> Returning ClassNotFoundException
o.a.c.loader.WebappClassLoaderBase       :     findClass(javax.servlet.jsp.myProperty)
o.a.c.loader.WebappClassLoaderBase       :     --> Returning ClassNotFoundException

这在极简的 Spring Boot 应用程序中很容易重现。在本地,禁用类加载器日志记录后,在 Spring Boot 2.2.12 中,当我敲击一个简单的 JSP 时,该 JSP 访问 30 个不同的不存在的模型属性时,我每秒收到大约 4000 个请求。在所有后续的 Spring Boot 版本中,我每秒只收到大约 33 个请求。

您可以在此处找到测试应用程序:http://www.schuerger.com/spring-boot/test-app.tar.gz

运行它

mvn clean compile spring-boot:run

然后使用ApacheBench它来进行 URL 攻击:

ab -n 800 -c 8 http://localhost:8080/

之后,编辑 pom.xml 以切换到版本 2.2.12.RELEASE,重复并比较。禁用类加载日志记录以获得更真实的结果。

将此与将TestController所有 30 个属性设置为虚拟值(而不是保留它们未定义)进行比较。之后,Spring Boot 版本之间的性能相似。

请注意,这很可能不是 Tomcat 的问题。我已经在本地使用不同的 Tomcat 版本(9.0.34、9.0.35 和 9.0.41)和不同的 Spring Boot 组合进行了测试。请注意,Spring Boot 2.2.7 默认使用版本 9.0.34,2.2.12 使用 9.0.41,2.3.0 使用 9.0.35。无论使用哪个 Tomcat 版本,此问题都会出现在 2.3.x 和 2.4.x 中,而无论使用哪个 Tomcat 版本,此问题都不会出现在 2.2.x 中。

回答

9

非常感谢 @TranceTip 的详细分析。在 2.3.0 中,我们进行了更改,这意味着我们对所有支持的嵌入式容器使用相同的 EL 实现 (RI)(https://github.com/spring-projects/spring-boot/issues/19550)。这就是表达式工厂发生变化的原因。

也许我们可以将 RI 配置为像 Apache EL 实现那样运行,我们需要研究这种可能性。与此同时,您应该能够排除org.glassfish:jakarta.el并添加依赖项以org.apache.tomcat.embed:tomcat-embed-el恢复您在 2.2.x 中看到的行为。您能否尝试一下并告诉我们结果如何?

4

确认,添加时

                   <exclusions>
                            <exclusion>
                                    <groupId>org.glassfish</groupId>
                                    <artifactId>jakarta.el</artifactId>
                            </exclusion>
                   </exclusions>

依赖项spring-boot-starter-tomcat,我获得了与以前相同的性能。谢谢!

但请注意,无论是否排除,org.apache.tomcat.embed:tomcat-embed-el都已包含在依赖项中。和应该/允许共存还是相互排斥?org.apache.tomcat.embed:tomcat-embed-jasperjakarta.eltomcat-embed-eljakarta.el

7

tomcat-embed-el和都jakarta.el带有各自不同的 版本javax.el.ExpressionFactory。这听起来似乎不应该允许它们共存。jakarta 版本始终返回com.sun.el.ExpressionFactoryImplnewInstance(),而 Tomcat 版本有一种更复杂的方式来确定要使用的 ExpressionFactory 实现。

4

感谢您尝试 Apache EL 实现。

EL 规范要求javax.el.ExpressionFactory使用以下算法加载实现:

  • 使用服务 API(详见 JAR 规范)。如果存在名为 META-INF/services/javax.el.E​​xpressionFactory 的资源,则其第一行(如果存在)将用作实现类的 UTF-8 编码名称。
  • 使用 JRE 目录中的属性文件“lib/el.properties”。如果此文件存在且可被 java.util.Properties.load(InputStream) 方法读取,并且它包含一个键为“javax.el.E​​xpressionFactory”的条目,则该条目的值将用作实现类的名称。
  • 使用 javax.el.E​​xpressionFactory 系统属性。如果定义了具有此名称的系统属性,则其值将用作实现类的名称。
  • 使用平台默认实现。

在这种情况下,如果类路径上有两个实现(每个实现都有一个META-INF/services/javax.el.ExpressionFactory文件),则类路径上第一个实现将获胜。一般来说,为了避免类路径排序决定使用哪个实现,建议只存在一个实现。对于您来说,排除 Glassfish 参考实现是正确的做法,因为您更喜欢 Apache 实现的性能特征。

9

谢谢,那么我现在就选择排除。

无论如何,javax.el.ExpressionFactory需要在 Spring Boot 中修复相关的依赖冲突。当使用spring-boot-starter-tomcatorg.apache.tomcat.embed:tomcat-embed-jasper而没有明确排除时,同时包含 EL 实现会导致出现问题。

7

有关 Tomcat 的 EL 实现中进行的性能优化的一些背景信息,请参阅https://bz.apache.org/bugzilla/show_bug.cgi?id=57583 。

当使用 spring-boot-starter-tomcat 和 org.apache.tomcat.embed:tomcat-embed-jasper 时,如果同时包含这两种 EL 实现,而没有明确排除,则极有可能出现问题

我们可以考虑在依赖管理中排除tomcat-embed-eltomcat-embed-jasper但这可能会破坏不使用我们的启动程序的任何人。我会标记此问题,以便团队的其他成员可以发表意见。一般来说,当您直接添加第三方依赖项而不是通过 Spring Boot 启动程序添加时,您有责任确保类路径上没有重复的类。当您使用 Maven 时,您可能需要考虑使用重复查找器插件来帮助解决此问题。

0

对于这个问题,有几点需要考虑:

  1. 我们是否应该全面转向 Apache EL 实现,而不是 RI,因为它似乎更快
  2. 我们是否应该在依赖管理中设置排除项,以减少类路径上出现两个 EL 实现的机会

对 2 的需求可能会根据我们决定对 1 做什么而改变。

6

仅供参考:在我们测试环境中的 Web 应用程序中,在排除后,登陆页面的吞吐量现在比以前快 6 倍,因此我们恢复了以前的 2.2.x 性能。

1

此修复是否也会移植到 2.3.x 和 2.4.x?

0

@TranceTip 这被标记为增强,所以不会。

8

在我们从 spring boot 1.5.x 迁移到 2.3.6 后,我的团队遇到了完全相同的问题,看起来排除jakarta.el也对我们有帮助。非常感谢您的调查和解决方案!

这个月我经常在谷歌上搜索“spring boot jsp render slow”(以及类似的),但这个帖子从来没有出现过。写下这些,希望更多的人能够找到它。

3

我们也遇到过同样的问题,排除jakarta.el也对我们有帮助。对于长期解决方案,我们决定通过添加相应的范围来定义每个变量(如 tomcat 8 迁移版本所建议的那样)。

这使我们进一步提高性能并摆脱任何特定的实现。

5

昨天遇到了这个问题,花了几个小时进行调试。打包的 (大型) Spring Boot 应用程序 (使用 jstl 的 jsps) 的速度会大大降低,因为当类加载器搜索类时会扫描 war 文件中的 jar 文件java.lang.<attribute>,...如所述 - 如果使用 glassfish EL。

对于长期解决方案,我们决定通过添加相应的范围来定义每个变量(按照 tomcat 8 迁移版本的建议)。

乍一看我不明白这个说法,但这里是长版本:https://tomcat.apache.org/migration-8.html#JavaServer_Pages_2.3