DEV Community

Cover image for Serverless applications on AWS with Lambda using Java 25, API Gateway and Aurora DSQL - Part 4 SnapStart + DSQL request priming
Vadym Kazulkin for AWS Heroes

Posted on • Originally published at vkazulkin.com

Serverless applications on AWS with Lambda using Java 25, API Gateway and Aurora DSQL - Part 4 SnapStart + DSQL request priming

Introduction

In part 1, we introduced our sample application. In part 2, we measured the performance (cold and warm start times) of the Lambda function without any optimizations. We observed quite a large cold start time, especially if we use the Hibernate ORM framework. Using this framework also significantly increases the artifact size.

In part 3, we introduced AWS Lambda SnapStart as one of the approaches to reduce the cold start times of the Lambda function. We observed that by enabling the SnapStart on the Lambda function, the cold start time goes down significantly for both sample applications. It's especially noticeable when looking at the "last 70" measurements with the snapshot tiered cache effect. The biggest impact of just enabling the SnapStart is on the application using the Hibernate.

In this part of our article series, we'll introduce how to apply Lambda SnapStart priming techniques. We'll start with the database (in our case, Aurora DSQL) request priming. The goal is to further improve the performance of our Lambda functions.

Sample application with JDBC and Hikari connection pool and the enabled AWS Lambda SnapStart using Aurora DSQL request priming

We'll reuse the sample application from part 1 and do exactly the same performance measurement as we described in part 2.

Also, please make sure that we have enabled Lambda SnapStart in template.yaml as shown below:

Globals:
  Function:
    Handler: 
    SnapStart:
      ApplyOn: PublishedVersions 
    ....
    Environment:
      Variables:
        JAVA_TOOL_OPTIONS: "-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
Enter fullscreen mode Exit fullscreen mode

You can read more about the concepts behind the Lambda SnapStart in part 2.

SnapStart and runtime hooks offer you new possibilities to create your Lambda functions for low startup latency. With the pre-snapshot hook, we can prepare our Java application as much as possible for the first call. We load and initialize as much as possible that our Lambda function needs before the Lambda SnapStart creates the snapshot. The name for this technique is priming.

In this article, I will introduce you to the priming of database (in our case, Aurora DSQL) requests, which we implemented in the extra GetProductByIdWithDSQLPrimingHandler class.

public class GetProductByIdWithDSQLPrimingHandler
        implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent>, Resource {

private static final ObjectMapper objectMapper = new ObjectMapper();
private static final ProductDao productDao= new ProductDao();

public GetProductByIdWithDSQLPrimingHandler () {
    Core.getGlobalContext().register(this);
}

@Override
 public void beforeCheckpoint(org.crac.Context<? extends Resource> context) throws Exception {
      productDao.getProductById(0);
}

@Override
public void afterRestore(org.crac.Context<? extends Resource> context) throws Exception {       
}

@Override
public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent requestEvent, Context context) {
      String id = requestEvent.getPathParameters().get("id");
      Optional<Product> optionalProduct = productDao.getProductById(Integer.valueOf(id));
      return new APIGatewayProxyResponseEvent()
                 .withStatusCode(HttpStatusCode.OK)                                    
          .withBody(objectMapper.writeValueAsString(optionalProduct.get()));
....
}
Enter fullscreen mode Exit fullscreen mode

We use Lambda SnapStart CRaC runtime hooks here. To do this, we need to declare the following dependency in pom.xml:

<dependency>
   <groupId>io.github.crac</groupId>
   <artifactId>org-crac</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

GetProductByIdWithDSQLPrimingHandler class additionally implements org.crac.Resource interface. The class registers itself as a CRaC resource in the constructor of this class. The priming itself happens in the method where we search for the product with the ID equal to 0 in the products table of the Aurora DSQL database. beforeCheckpoint method is a CRaC runtime hook that is invoked before creating the microVM snapshot. We are not even processing the result of the call to productDao.getProductById(0). The product with the ID equal to zero might not even exist in the database. But with this invocation, Java lazily loads and instantiates all the classes that it requires for this invocation. PreparedStatement, ResultSet, and many others are among such classes. We also instantiate everything required to establish the connection to the database via JDBC and process the request and response.

We can leave the afterRestore method empty. This is because we don't need to perform any action after the SnapStart snapshot has been restored.

Measurements of cold and warm start times of the Lambda function of the sample application with JDBC and Hikari connection pool

We'll measure the performance of the GetProductByIdJava25WithDSQLAndDSQLPriming Lambda function mapped to the GetProductByIdWithDSQLPrimingHandler shown above. We will trigger it by invoking curl -H "X-API-Key: a6ZbcDefQW12BN56WEDQ25" https://{$API_GATEWAY_URL}/prod/productsWithAuroraPriming/1.

Please read part 2 for the description of how we designed the experiment.

I will present the Lambda performance measurements with SnapStart being activated for all approx. 100 cold start times (labelled as all in the table), but also for the last approx. 70 (labelled as last 70 in the table). With that, the effect of the snapshot tiered cache, which we described in part 3, becomes visible to you.

To show the impact of the SnapStart with the Aurora DSQL database request priming, we'll also present the Lambda performance measurements from part 2 and part 3.

I did the measurements with java:25.v19 Amazon Corretto version, and the deployed artifact size of this application was 17.150 KB.

Cold (c) and warm (w) start time with -XX:+TieredCompilation -XX:TieredStopAtLevel=1 compilation in ms:

Approach c p50 c p75 c p90 c p99 c p99.9 c max w p50 w p75 w p90 w p99 w p99.9 w max
No SnapStart enabled 2336 2453 2827 3026 3131 3132 4.84 5.29 5.73 8.88 195.38 531
SnapStart enabled but no priming applied, all 970 1058 1705 1726 1734 1735 4.92 5.33 5.86 9.84 198.52 1134
SnapStart enabled but no priming applied, last 70 901 960 1061 1212 1212 1212 4.84 5.29 5.77 9.54 196.94 719
SnapStart enabled and DSQL database request priming applied, all 879 980 1499 1515 1518 1518 4.84 5.25 5.77 9.54 163.96 914
SnapStart enabled and DSQL database request priming applied, last 70 803 912 996 1056 1056 1056 4.84 5.25 5.77 9.54 152.61 597

Sample application with Hibernate and Hikari connection pool and the enabled AWS Lambda SnapStart using Aurora DSQL request priming

We'll reuse the sample application from part 1 and do exactly the same performance measurement as we described in part 2.

We implemented the priming of the database (in our case, Aurora DSQL) request in the extra GetProductByIdWithDSQLPrimingHandler class.

The explanation of what we would like to achieve and how is exactly the same as in the first sample application above. The main difference is that we use the Hibernate framework on top and also preload and preinitialize its classes and abstractions.

Measurements of cold and warm start times of the Lambda function of the sample application with Hibernate and Hikari connection pool

We'll measure the performance of the GetProductByIdJava25WithHibernateAndDSQLAndDSQLPriming Lambda function mapped to the GetProductByIdWithDSQLPrimingHandler shown above. We will trigger it by invoking curl -H "X-API-Key: a6ZbcDefQW12BN56WEHADQ25" https://{$API_GATEWAY_URL}/prod/productsWithAuroraPriming/1.

Please read part 2 for the description of how we designed the experiment.

To show the impact of the SnapStart with the Aurora DSQL database request priming, we'll also present the Lambda performance measurements from part 2 and part 3.

I did the measurements with java:25.v19 Amazon Corretto version, and the deployed artifact size of this application was 42.333 KB.

Cold (c) and warm (w) start time with -XX:+TieredCompilation -XX:TieredStopAtLevel=1 compilation in ms:

Approach c p50 c p75 c p90 c p99 c p99.9 c max w p50 w p75 w p90 w p99 w p99.9 w max
No SnapStart enabled 6243 6625 7056 8480 8651 8658 5.46 5.96 6.50 9.77 200.10 707
SnapStart enabled but no priming applied, all 1277 1360 3050 3103 3200 3201 5.50 6.01 6.45 10.16 196.94 2349
SnapStart enabled but no priming applied, last 70 1258 1320 1437 1634 1634 1634 5.42 5.91 6.40 10.08 195.94 1093
SnapStart enabled and DSQL database request priming applied, all 1030 1185 2310 2341 2345 2347 5.33 5.91 6.50 11.64 201.70 1607
SnapStart enabled and DSQL database request priming applied, last 70 970 1076 1226 1511 1511 1511 5.37 5.96 6.61 12.01 203.32 670

Conclusion

In this part of our article series, we introduced how to apply the Lambda SnapStart priming technique, such as Aurora DSQL request priming. The goal was to even further improve the performance of our Lambda functions. We saw that by doing this kind of priming and writing some additional code, we could additionally reduce the Lambda cold start times compared to simply activating the SnapStart. It's especially noticeable when looking at the "last 70" measurements with the snapshot tiered cache effect. Moreover, we could also reduce the maximal value for the Lambda warm start times by preloading classes (as Java lazily loads classes when they are required for the first time) and doing some preinitialization work (by invoking the method to retrieve the product from the Aurora DSQL products table by its ID). Previously, all this happened once during the first warm execution of the Lambda function.

In the next part of our article series, we'll introduce another Lambda SnapStart priming technique. I call it API Gateway Request Event priming (or full priming). We'll then measure the Lambda performance by applying it and comparing the results with other already introduced approaches.

Please also watch out for another series where I use a NoSQL serverless Amazon DynamoDB database instead of Aurora DSQL to do the same Lambda performance measurements.

If you like my content, please follow me on GitHub and give my repositories a star!

Please also check out my website for more technical content and upcoming public speaking activities.

Top comments (0)