java程序从服务器端到Lambda函数的迁移与优化

[复制链接]
发表于 2025-7-5 01:12:41 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
source:https://www.jfokus.se/jfokus24-preso/From-Serverful-to-Serverless-Java.pdf
从传统的服务器端Java应用,到如今的无服务器架构。这不仅仅是技术名词的改变,更是开辟模式和运维理念的一次深刻变革。先快速回顾一下我们认识的“服务器端”Java天下。

大家看这张图,是不是很眼熟?一个典型的Web应用部署,通常会有一个负载均衡器站在最前面,像个门卫一样,把哀求分发给后面的一群服务器实例。这些服务器实例呢,可能还组成了一个自动伸缩组,流量大了就多加几台,流量小了就少留几台,保证服务稳定。每个实例内里,又包含了HTTP服务器、各种框架,以及我们核心的应用代码。这套架构,稳定可靠,但也意味着我们须要管理服务器、配置网络、处理各种运维问题。
那为什么我们要拥抱“无服务器”呢?利益太多了!


  • 运营开销大大淘汰。不消自己买服务器、装系统、打补丁、监控监控状态,能让你把精力会合在写代码上,更快地把产品推向市场。
  • 按需自动扩展。应用忽然流量暴增,传统架构下得赶紧扩容,手动操纵或者依赖自动伸缩策略,但总归有点滞后。而在无服务器环境下,它能根据实际斲丧的资源量自动伸缩,真正做到高性能和可扩展性。
  • 按代价付费。从前是按实例付费,如今是按实际执行的盘算量、存储量来付费,用多少付多少,非常经济。
  • 安全、集成和高可用性。云服务商通常会提供成熟的安全机制和高可用保障,而且各种服务之间集成度很高,用起来方便。
变乱驱动

那么,无服务器架构长什么样呢?核心思想就是变乱驱动。

各种各样的变乱,比如数据状态变了,有人发起了哀求,或者某个资源状态更新了,这些变乱就像一个个触发器,它们会触发AWS Lambda函数。这个Lambda函数内里,就包含了我们的应用代码和框架。它吸取到变乱,处理完,然后可能还会触发其他操纵,形成一个自动化的链条。整个过程,不须要我们显式地去管理服务器,统统都是由云平台来搞定。这就是所谓的“无服务器”,但并非真的没有服务器,而是我们不再须要关心底层的服务器细节了。
深入到AWS Lambda函数内部,它主要由三部分组成:Handler函数、Event和Context。Handler函数就是我们真正要执行的代码,每次调用函数时,都会运行这个Handler。目前支持Java 8、11、17和最新的21版本。Event是调用函数时转达给它的数据,通常是一个JSON格式的负载。这个负载的内容取决于变乱的泉源,比如


  • 如果是API Gateway触发的,那负载里可能包含HTTP哀求头、路径参数等;
  • 如果是S3文件上传变乱,那负载里可能包含文件元数据。
Context对象则提供了关于当前函数执行环境的一些额外信息,比如哀求ID,可以帮助我们进行日志日志追踪和调试。
  1. public ResponseEvent handleRequest(RequestEvent input, Context context) {
  2.     return ResponseEvent.builder()
  3.             .withBody("Return Something")
  4.             .withStatusCode(200)
  5.             .build();
  6. }
复制代码
看这个示例代码,handleRequest方法就是我们的Handler,它吸取一个Event范例和一个Context对象,处理完返回一个ResponseEvent。为了方便我们开辟和测试Lambda函数,AWS提供了一系列Java库


  • aws-lambda-java-core,它界说了Handler接口和Context对象。
  • aws-lambda-java-events,这个库提供了各种AWS原生集成对应的变乱范例,比如APIGatewayV2HTTPEvent、SQSEvent、S3Event等等,这样我们就能方便地解析不同泉源的变乱数据。
  • aws-lambda-java-tests,它提供了一些Junit扩展,简化了Lambda函数的本地测试。
这些库都在GitHub上公开,大家可以去查看和利用。有了这些工具,我们就可以更专注于业务逻辑,而不是底层的变乱处理细节。在Lambda里,内存配置非常重要。它不仅仅决定了你能用多少内存,还直接影响到分配给你的CPU和网络带宽。通常情况下,内存越大,分配的CPU和网络资源也越多。

   https://github.com/alexcasalboni/aws-lambda-power-tuning
  上图展示了调解内存大小对函数性能的影响。横轴是内存大小,纵轴分别是执行时间和成本。可以看到,随着内存增加,执行时间通常会下降,由于有更多资源可用。但同时,成本也会相应增加。以是,找到一个平衡点很关键。比如,对于某些盘算密集型使命,可能须要更大的内存才能到达最佳性能;而对于I/O密集型使命,可能较小的内存就足够了。我们须要根据具体的应用场景和性能要求,来选择符合的内存配置。
准备好代码后,怎样把它打包并部署到AWS Lambda上呢?其实很简朴。

我们可以利用认识的构建工具,比如Maven或Gradle。Maven可以通过Shade Plugin或者Assembly Plugin来创建一个包含全部依赖的Uber Jar,也就是一个胖JAR包。Gradle也有类似的功能,比如zip使命和shadow插件。这样,我们就得到了一个包含了全部须要库和资源的归档文件。接下来,就可以通过多种方式上传到Lambda了:可以直接在AWS控制台上传,也可以利用AWS CLI命令行工具,还可以通过Amazon S3存储桶上传,乃至许多框架自己就提供了自动化部署的集成。
执行模子

Lambda函数的执行模子主要有三种:同步、异步和轮询。



  • 同步模式,就像我们平时调用一个API一样,客户端发起哀求,等待Lambda函数处理完并返回结果。API Gateway或者Function URL就是常见的同步入口。
  • 异步模式(区别lambda的异步调用模式)。当某个变乱发生时,比如一个文件被上传到S3,Lambda函数会被触发去处理,但客户端不须要等待结果,也不须要关心结果。这种模式实用于处理配景使命、数据转换等场景。
  • 轮询模式,通常用于处理队列或流中的消息。Lambda函数会定期从队列或流中拉取消息进行处理。这种模式得当处理大量、持续不断的数据流,比如IoT设备产生的数据。

以上是一个简朴的同步示例,利用Function URL。Function URL是API Gateway的一个简化版本,可以直接暴露一个Lambda函数的URL。用户可以直接通过这个URL访问函数。这个例子中,handleRequest方法吸取一个APIGatewayV2HTTPEvent对象,它包含了哀求的全部信息,比如哀求体、查询参数、哀求头等。我们在这里简朴地打印出哀求ID和哀求体,然后返回一个200 OK的状态码和一个简朴的JSON响应。这就是一个最基础的同步函数处理流程。

再来看一个稍微复杂点的例子,利用REST API。这里我们用到了API Gateway来托管一个RESTful API。API Gateway会根据哀求的路径和方法,将哀求路由到不同的Lambda函数。比如,GET /unicorns 路径的哀求会触发一个Lambda函数,用来获取独角兽信息;POST /unicorns 路径的哀求则会触发另一个Lambda函数,用来创建新的独角兽。这两个Lambda函数都实现了handleRequest方法,吸取APIGatewayV2HTTPEvent变乱。

最后看一个处理队列的例子。这里我们利用了Amazon SQS队列。当有消息被推送到SQS队列时,可以配置Lambda函数来监听这个队列。Lambda函数会自动从队列中取出消息进行处理。这个例子中,handleRequest方法吸取一个SQSEvent对象。这个变乱包含了从SQS队列中取出的全部消息纪录。我们在这里提取出全部消息的正文内容,然后调用一个processMessages方法进行处理。SQSBatchResponse是返回给SQS的响应,用来告知哪些消息处理乐成,哪些失败。
变乱接口

无论是哪种触发方式,无论是API哀求、S3变乱还是SQS消息,最终都会被封装成一个变乱,转达给Lambda函数。Lambda函数的核心职责就是处理这个变乱,并产生相应的结果。
Lambda函数是通过变乱被调用的。这些变乱可以来自各种各样的源,比如API Gateway、S3对象存储、SQS队列等等。每个变乱都有其特定的结构和内容。这与我们传统Web应用中直接接受HTTP连接并处理哀求的方式有所不同。在传统应用中,我们通常须要自己处理HTTP协议、路由、解析哀求体等。而在Lambda中,这些底层的网络和协议细节都被抽象掉了,我们只须要关注怎样处理传入的变乱对象即可。

这张图展示了API Gateway怎样将一个HTTP哀求转换成APIGatewayV2HTTPEvent变乱,再转达给Lambda函数。处理变乱的第一种方法,也是最直接的方法,就是通过函数自己来处理。也就是说,我们为每种范例的变乱编写一个专门的Handler类。比如,处理API Gateway哀求的,就实现RequestHandler<APIGatewayV2HTTPEvent, APIGatewayV2HTTPResponse>接口;处理SQS消息的,就实现RequestHandler<SQSEvent, SQSBatchResponse>接口;处理S3变乱的,就实现RequestHandler<S3Event, String>接口。这样做的利益是,我们可以非常正确地控制怎样处理每种变乱,充分利用aws-lambda-java-events库提供的范例信息。缺点是,如果变乱范例许多,或者须要复用一些通用逻辑,可能会导致代码重复。利用这种直接处理函数的方式,有几个关键点。

  • 我们不须要在Lambda函数里嵌入任何HTTP服务器,比如Tomcat或者Jetty。API Gateway或者Function URL已经帮我们处理了HTTP层面的事变。我们可
  • 以通过aws lambda java events库提供的范例安全的方式来处理变乱,比如直接解析APIGatewayV2HTTPEvent对象。
  • 也可以选择更底层的方式,比如直接处理Map或者InputOutputStream。许多流行的Java框架,比如Spring Cloud Functions、Micronaut Serverless Function、Quarkus aws lambda extension,都提供了对这种模式的良好支持,让开辟者可以更方便地利用认识的框架来编写Lambda函数。
以Spring Cloud Function为例,它鼓励我们利用Java函数来实现业务需求。它提供了一些额外的路由和映射功能,让我们可以更方便地将不同的变乱范例映射到不同的函数上。它还通过Java函数式接口来提供抽象。
  1. @SpringBootApplication
  2. public class FunctionConfiguration {
  3.     public static void main(String[] args) {
  4.         SpringApplication.run(FunctionConfiguration.class, args);
  5.     }
  6.     @Bean
  7.     public Function<String, String> uppercase() {
  8.         return value -> value.toUpperCase();
  9.     }
  10. }
复制代码
以上例子,我们界说了一个FunctionConfiguration类,它是一个Spring Boot应用。在其中,我们界说了一个uppercase函数,它吸取一个String范例的输入,返回一个String范例的输出。这个函数就是一个简朴的字符串转大写操纵。我们可以通过配置,将这个函数暴暴露去,让外部系统可以通过特定的方式调用它。这种方式非常得当构建微服务或者函数式编程风格的应用。

上图展示了利用Spring Cloud Function结合Lambda的架构。API Gateway吸取到哀求,将其转达给Lambda函数。这个Lambda函数内部,实际上是一个Spring Cloud Function应用。它吸取变乱,将其转换为Spring Framework能够明确的哀求对象,然后调用我们编写的Application code,也就是具体的业务逻辑。Spring Cloud Function提供了许多便利,比如自动配置、依赖注入、以及一些开箱即用的功能,让我们可以更专注于业务逻辑,而不消过多关注底层的变乱处理细节。
适配器

但是,如果我们已经有一个成熟的Spring Boot应用,内里有许多RestController,我们还能直接用吗?直接用好像不太行。由于Lambda函数的入口是handleRequest方法,它吸取的是变乱对象,而不是我们认识的HttpServletRequest和HttpServletResponse。如果我们想复用现有的RestController代码,就须要一种桥梁来连接它们。这就是第二种方法:利用HTTP适配器。

这种方法的核心思想是,我们在Lambda函数的入口处,添加一层适配逻辑。这层逻辑负责将Lambda函数吸取到的变乱,转换成我们认识的HTTP哀求对象,然后调用我们现有的Web应用框架,比如Spring Boot。框架处理完哀求后,再将响应转换回Lambda函数能够返回的格式。这样,我们就可以在无服务器环境下,继续利用我们认识的Web开辟模式和框架。AWS官方提供了一个非常流行的HTTP适配器,叫做AWS Serverless Java Container。这个库的作用就是把Lambda吸取到的各种变乱,转换成看起来像是一个尺度的HTTP哀求,然后交给Spring、Spring Boot、Jersey等框架去处理。框架处理完之后,再把响应转换返来。这样,我们就可以像平时开辟Web应用一样,利用RestController、POJO序列化、HTTP状态码等等。利用起来也很简朴,只须要把这个库添加到你的项目依赖里,而且提供一个简朴的配置类就可以了。
最新的AWS Serverless Java Container 2.0.0版本,对新版本的Spring Framework 6.x、Spring Boot 3.x以及JAX RS Jersey 3.x都提供了很好的支持。更锋利的是,它还支持GraalVM的native image特性。这意味着,我们可以将我们的Spring Boot应用打包成一个原生的二进制文件,而不是传统的JAR包。原生镜像启动速度更快,内存占用更小,这对于追求极致性能和成本的无服务器应用来说,是非常有吸引力的。

这张图展示了AWS Serverless Java Container和Spring Cloud Function的关系。当利用Serverless Java Container时,Lambda吸取到的变乱会被映射成一个框架哀求,比如Spring MVC的HttpServletRequest。然后,这个哀求会被转达给我们编写的Spring Boot应用代码。应用代码处理完哀求后,会产生一个框架响应,比如Spring MVC的HttpServletResponse。最后,Serverless Java Container会将这个框架响应转换成Lambda函数能够返回的格式。这样,我们就实现了在Lambda环境下,利用Spring Boot的编程模子。
具体怎么用这个HTTP适配器呢?步调很简朴。

  • 第一步,把依赖添加到你的pom.xml或者Gradle buildfile里。比如,如果你用的是Spring Boot 3,就添加aws serverless java container springboot3这个依赖。
  1. <dependency>
  2. <groupId>com.amazonaws.serverless</groupId>
  3. <artifactId>aws-serverless-java-container-springboot3</artifactId>
  4. <version>2.0.0</version>
  5. </dependency>
复制代码

  • 第二步,配置Lambda函数的Handler类。你须要指定一个类,让它继续自SpringDelegatingLambdaContainerHandler,或者类似的适配器类。这个类会告诉Lambda函数,真正的入口点在那里。
  • 第三步,打包和部署。打包方式和之前一样,可以是JAR包或者ZIP包,然后通过控制台、CLI或者其他方式部署到Lambda。
利用HTTP适配器有什么利益呢?最显着的利益就是,我们可以继续利用我们认识的Controller编程风格。如果你已经有一个基于Spring Boot的Web应用,想把它迁移到Lambda上,利用适配器是最简朴直接的方式之一。许多主流的Java框架,比如Spring Boot、Micronaut、Quarkus,都有相应的适配器实现。比如,Spring Boot可以用Serverless Java Container,Micronaut可以用micronaut function aws api proxy,Quarkus则有专门的AWS Lambda HTTP extension。这样,我们就可以最大限度地复用现有的代码和框架,低落迁移成本。缺点是,由于多了一层转换,可能会有一些额外的开销。
性能和扩展性

前面我们聊了怎么用Java写Lambda函数,如今我们来聊聊性能和扩展性。这是无服务器架构中非常关键的两个方面。先回顾一下传统的Java应用在容器或者假造机里的运行方式。

通常,应用启动一次,初始化环境,比如加载配置、建立数据库连接等。然后,这个实例可以同时处理多个哀求,也就是并发处理。哀求处理完后,实例通常会一直运行着,等待后续的哀求。我们须要根据监控监控指标或者手动干预来进行扩展。这种方式的长处是,哀求处理效率高,由于实例是持续运行的。缺点是,资源利用率不高,纵然没有哀求,实例也在斲丧资源。

再来看AWS Lambda的哀求处理方式。Lambda函数的启动有两种情况:冷启动和热启动。


  • 冷启动发生在第一次调用函数,或者函数长时间未被利用后。这时,Lambda须要创建一个新的执行环境,包括下载代码、启动运行时、初始化函数代码等等,这个过程会比较耗时。
  • 热启动则发生在函数已经被调用过,执行环境仍然存在的情况下。这时,Lambda可以直接复用已有的执行环境,直接执行代码,速度很快。
以是,冷启动延迟是影响Lambda性能的一个重要因素。Java在AWS Lambda上的运行方式,与传统的容器或VM有所不同。

Lambda函数在第一次被调用时,会初始化执行环境。但一个Lambda执行环境在同一时间只处理一个哀求。如果一个环境正在处理哀求,新的哀求来了,Lambda会启动一个新的执行环境来处理,而不是让同一个环境去并发处理。这简化了编程模子,我们不须要考虑并发控制。如果没有哀求进来,Lambda函数会自动缩放到零,完全不斲丧资源。
既然冷启动是个问题,那我们有哪些优化本领呢?

上图展示了不同优化策略的效果。横轴代表实现这些优化须要付出的努力,纵轴代表冷启动时间。可以看到,无优化的情况下,冷启动时间最长,但实现起来最简朴。随着我们投入更多努力,比如利用轻量级依赖、优化函数处理器、启用分层编译等,冷启动时间会逐渐收缩。而GraalVM Native Image,虽然实现起来可能须要一些学习成本,但可以带来最显著的性能提升,冷启动时间可以大幅收缩。我们须要根据自己的应用场景和对性能的要求,来权衡选择符合的优化策略。
AWS Lambda提供了一个叫做预置并发的功能,可以用来缓解冷启动问题。预置并发允许我们预先启动并保持肯定数量的Lambda函数执行环境处于待命状态。这样,当有哀求来的时间,可以直接从这些预热好的环境中分配一个来处理,避免了冷启动的延迟。固然,预置并发会带来肯定的成本,由于它会持续占用资源。但在某些对延迟敏感、哀求量颠簸较大的场景下,预置并发可以显著提升用户体验,乃至在某些情况下还能节流成本,比如通过淘汰因冷启动导致的超时重试。
除了预置并发,AWS还推出了一个叫做SnapStart的功能。这个功能号称可以将Lambda函数的启动性能提升高达10倍。听起来是不是很诱人?那它到底是怎么做到的呢?SnapStart的核心思想是快照。它会在函数初始化阶段,对内存和磁盘状态做一个快照。这个快照会被加密并缓存起来,以便快速访问。当有新的哀求来时,Lambda会直接从这个缓存的快照中恢复出一个新的执行环境,而不是从零开始创建。由于大部分初始化工作已经通过快照完成了,以是启动速度就大大加快了。整个过程是完全托管的,我们不须要做任何额外的配置。

上图展示了SnapStart的工作流程。在部署阶段,Lambda会先进行初始化,然后创建一个快照。在后续的哀求处理阶段,如果是第一次哀求,或者快照过期了,就须要重新初始化并创建新的快照。如果是后续的哀求,如果快照有用,就可以直接从快照恢复,然后执行代码。
在恢复和执行代码之间,还可以插入一些钩子函数,比如beforeCheckpoint和afterRestore,让我们可以在快照创建前后做一些自界说的操纵。
SnapStart有哪些利益呢?


  • 它不须要额外的成本,只须要开启这个功能即可。
  • 它几乎不须要或者只须要少少的代码修改,就能享受到性能提升。
  • 我们可以通过Runtime Hooks来自界说一些行为。
对于Runtime Hooks,这里我们看一个具体的例子。

CRaC(CheckPoint Restart)接口提供了beforeCheckpoint和afterRestore这两个钩子方法。


  • 在beforeCheckpoint钩子里,我们可以做一些准备工作,比如预加载一些类、进行依赖注入、或者建立一些连接。
  • 在afterRestore钩子里,我们可以做一些恢复后的操纵,比如获取当前执行环境的唯一ID,或者重新建立一些连接。
看这个代码示例,HelloHandler类实现了RequestHandler接口,而且注册了自己作为CRaC的监听器。它重写了beforeCheckpoint和afterRestore方法,在这些方法中打印了一些日志日志信息。通过这种方式,我们就可以在快照的生命周期中插入自界说的逻辑。
除了SnapStart,还有没有其他方法可以进一步提升Lambda的性能呢?答案是肯定的。我们可以考虑利用自界说运行时或者GraalVM的原生镜像。

在传统的托管运行时模式下,Lambda函数的执行流程大致是这样的:

  • Runtime API负责吸取来自Lambda服务的哀求
  • 调用Runtime组件
  • Runtime组件调用我们编写的Lambda函数代码
  • 还可以加载Lambda Extensions Layers来扩展功能。
这个架构是Lambda默认提供的,对于我们来说,是开箱即用的。如果我们想利用自界说运行时,比如GraalVM的原生镜像,流程会稍微不同。

在这种模式下,我们须要自己提供一个bootstrap文件,这个文件会作为Lambda的启动程序。Lambda服务会调用这个bootstrap文件,由它来负责启动我们的自界说运行时,比如GraalVM的原生镜像。自界说运行时再去加载和执行我们的代码。同样,如果须要,也可以加载Lambda Extensions Layers。这种方式给了我们更大的机动性,但也意味着我们须要自己负责更多底层的细节。GraalVM的原生镜像,就是将我们的Java应用编译成一个原生的二进制文件,而不是传统的JAR包。然后,我们可以将这个原生镜像部署到Lambda的自界说运行时环境中。原生镜像最大的长处是启动速度快,内存占用小。
在不同的内存配置下,GraalVM自界说运行时的冷启动时间(p90)都显着低于Java 17的托管运行时。尤其是在低内存配置下,优势更加显着。许多现代的Java框架,比如Micronaut、Quarkus、Spring,都提供了对GraalVM原生镜像的内置支持,使得构建和部署变得更加容易。
如果你想更深入地了解Java在AWS Lambda上的应用,强烈保举参加这个AWS官方的Java on AWS Lambda Workshop。
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表