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

发布于:2025-06-01 ⋅ 阅读:(22) ⋅ 点赞:(0)

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,可以帮助我们进行日志追踪和调试。

public ResponseEvent handleRequest(RequestEvent input, Context context) {
    return ResponseEvent.builder()
            .withBody("Return Something")
            .withStatusCode(200)
            .build();
}

看这个示例代码,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库提供的类型信息。缺点是,如果事件类型很多,或者需要复用一些通用逻辑,可能会导致代码重复。使用这种直接处理函数的方式,有几个关键点。

  1. 我们不需要在Lambda函数里嵌入任何HTTP服务器,比如Tomcat或者Jetty。API Gateway或者Function URL已经帮我们处理了HTTP层面的事情。我们可
  2. 以通过aws lambda java events库提供的类型安全的方式来处理事件,比如直接解析APIGatewayV2HTTPEvent对象。
  3. 也可以选择更底层的方式,比如直接处理Map或者InputOutputStream。很多流行的Java框架,比如Spring Cloud Functions、Micronaut Serverless Function、Quarkus aws lambda extension,都提供了对这种模式的良好支持,让开发者可以更方便地使用熟悉的框架来编写Lambda函数。

以Spring Cloud Function为例,它鼓励我们使用Java函数来实现业务需求。它提供了一些额外的路由和映射功能,让我们可以更方便地将不同的事件类型映射到不同的函数上。它还通过Java函数式接口来提供抽象。

@SpringBootApplication
public class FunctionConfiguration {

    public static void main(String[] args) {
        SpringApplication.run(FunctionConfiguration.class, args);
    }

    @Bean
    public Function<String, String> uppercase() {
        return value -> value.toUpperCase();
    }
}

以上例子,我们定义了一个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适配器呢?步骤很简单。

  1. 第一步,把依赖添加到你的pom.xml或者Gradle buildfile里。比如,如果你用的是Spring Boot 3,就添加aws serverless java container springboot3这个依赖。
<dependency>
 <groupId>com.amazonaws.serverless</groupId>
 <artifactId>aws-serverless-java-container-springboot3</artifactId>
 <version>2.0.0</version>
</dependency>
  1. 第二步,配置Lambda函数的Handler类。你需要指定一个类,让它继承自SpringDelegatingLambdaContainerHandler,或者类似的适配器类。这个类会告诉Lambda函数,真正的入口点在哪里。
  2. 第三步,打包和部署。打包方式和之前一样,可以是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函数的执行流程大致是这样的:

  1. Runtime API负责接收来自Lambda服务的请求
  2. 调用Runtime组件
  3. Runtime组件调用我们编写的Lambda函数代码
  4. 还可以加载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


网站公告

今日签到

点亮在社区的每一天
去签到