Improving performance through Lua scripts in Redis using TypeScript

How to use Lua scripts in Redis to decrease network overhead.

Improving performance through Lua scripts in Redis using TypeScript

Performance is a key characteristic of any architecture and in a microservices architecture, network overhead between services can cause performance issues.

In this post, we'll learn how to leverage a Lua script in Redis to avoid unnecessary network overhead and do evaluation and transformation of data within Redis.

We'll walk through an example Lua script and show how it improves network overhead between a microservice and Redis.

What is Lua?

Lua is a powerful, efficient, lightweight, embeddable scripting language. Its lightweight nature makes it highly applicable to embedded systems such as microcontrollers.

Redis can use scripts written in Lua to add functionality and extend its capabilities beyond a key-value store.

Why use Lua scripts?

Lua scripts allow you to evaluate data within Redis and make decisions based on the parameters you pass and the stored data. This means that logic that would have been executed within your microservice could be instead be executed inside Redis.

Usually, you would fetch all the data required for your computation from Redis and then calculate the result. This causes network overhead as you now need to transmit all that data over the network. With a Lua script you could instead do that computation within Redis, where the data is, and then only return the result.

While Lua scripts provide a performance benefit there are downsides to consider such as testability of your logic and since Redis is single-threaded, complex scripts could slow down your instance and block other calls.

You will have to judge if the use-case warrants higher performance or better testability. If you decide to use Lua scripts ensure that they are lightweight and do not cause excessive load on your Redis instance.

Advantages Disadvantages
Reduced network overhead Testability of logic
Complex scripts could block other calls

Message-based communication in microservices

Before we dive into how to create a Lua script, let's first describe a scenario where using a Lua script would be useful. This will give us a better understanding of how to apply Lua scripts and contextualize them better.

Message-based microservices scenario

In our scenario, we have a microservice that receives commands via messages on a message bus. The message contains an object car that we want to perform an operation on as shown below.

message: 
{
	"messageId": "231ad29d-b230-4c1e-9fb6-61f77385cafc"
	"body": {
    	"car": {
        	"id": "53a95943-617e-44ea-8e59-ef9982cadaf6",
                "userId": "0e7a55db-cb80-4776-b027-5b1ca4d05185",
                "hash": "5fad62a7a45fd1414fcd7553bef484585b555b0ff7251b51..."
        }
    }
}

Our microservice wants to always operate on the latest car object and therefore needs to verify that the car received in this message is the latest version. To do this it requests the car from a different bounded context.

This is an application of the Data Domain Pattern and allows one service to own the data while others can read the data directly, thereby allowing the two services to scale independently while ownership of data is still governed.

There is a case to be made around eventual consistency and whether our microservice should even care if the car it received is the latest version but that's a different discussion that is out of scope for this article.

To check whether the car in the message is the latest version of the object we can compare the hash property of the car in the message with the hash of the car stored in Redis.

In Redis, we store the car object as a Hash. The key is the car.Id and the value is a map with a hash field containing the hash value of the car and a model field containing a JSON serialized string of the model.

Car object stored in Redis as a Hash

Microservice request flow without Lua script

In the diagram below we get insights into the request flow our microservice needs to go through without using a Lua script.

Request flow without Lua script in Redis

To understand which pathway will be the most frequent we have to understand how frequently the data is updated and the chances that the object we received in the message will be the latest version.

Our assumption here is that the object is updated relatively infrequently and therefore most of the time the hash value we receive will match the one in our message, indicating we have the latest version of the object.

For the other cases, where the hash we receive does not match the one in our message, we will have to make an additional request to Redis to fetch the new version of the object. This reduces performance as we have to wait for the response from Redis and possibly face network congestion if our Redis instance is serving other requests. For this case, having a Lua script in Redis helps to optimize our performance.

Microservice request flow with Lua script

In the diagram below we get insights into the request flow our microservice needs to go through when we use a Lua script.

Request flow with Lua script in Redis

We invoke the Lua script with the object's hash value car.hash. If the hash matches the one stored in Redis we return true indicating that we have the latest version of the object, if the hash does not match we instead directly return the model. This allows us to skip the additional request to Redis in the cases where the model we have is not the latest version available. By skipping the additional network request we increase the performance of our microservice as it no longer needs to wait for the model to be received.

By reducing the network requests, we have higher performance and lower load on our Redis instance thereby improving overall scalability of our service.

Now that we understand the scenario where a Lua script can help us, let's go through the actual Lua script and how it's implemented in TypeScript.

Lua script in Redis logical flow

If you would like to test this out on your own machine, you can find the GitHub repository here.

GitHub - javaadpatel/typescript-with-redis-lua-scripting: Sample repository showing how to use LUA scripts with redis in node.js
Sample repository showing how to use LUA scripts with redis in node.js - GitHub - javaadpatel/typescript-with-redis-lua-scripting: Sample repository showing how to use LUA scripts with redis in nod...

In the diagram, we see the logic of the Lua script. It checks whether the key (`car.Id`) exists and if not returns false. If the key exists it then checks whether the hash we passed in matches the stored hash. If the hash matches, we return true indicating we have the latest version of the object. If the hash does not match, we return the latest version of the model.

Lua script logic flow

In the example below we use the Redis npm package to create a Redis client and initialize a Lua script.

Redis Lua script definition

Let's go through the script at a high level:

  1. We define the name of the script, we'll use this name later on to reference the script.
  2. NUMBER_OF_KEYS defines how many keys we will be passing to the script.
  3. SCRIPT defines the Lua script that we want to execute. We'll break this script down further in the next section.
  4. transformArguments defines all the the parameters that we will pass when invoking the script.
  5. transformReply is used to perform data manipulation on the result of the script before we send the response to our microservice.

Lua script breakdown

1. -- get the model's hash property by key (carId) and property (hash)
2. local modelHash = redis.call('HGET',KEYS[1],ARGV[1]);
3. -- check value of hash against passed in hash
4. if modelHash == ARGV[2] then
5.    -- if we found a match for the hash of the object
6.    return 1;
7. elseif modelHash == false then
8.    -- no matching key
9.    return 0;
10. else
11.    -- key found but hash mismatched
12.    return redis.call('HGET',KEYS[1],ARGV[3]);
13. end
Lua script

In Lua, indexes start at 1, which is counter-intuitive if you're used to other programming languages like C# and TypeScript.

The KEYS[1] will get the first key that we passed as an argument to the script and ARGV represents the additional arguments we passed. Since we defined NUMBER_OF_KEYS as 1, everything after the first argument will be considered ARGV parameters.

Next, we examine the Lua script line-by-line:

  1. On line 1, we use the redis.call function to get a hash field of the object using the key and the field name. In our case KEYS[1] will be the car.Id and ARGV[1] will be hash since that is the name of the field we want to retrieve.
  2. On line 4, we check if the value stored in the hash field is the same as the hash value we passed.
  3. On line 7, we check if the hash field had any value. This would happen if the key was not found in Redis ie. the object was not cached.
  4. On line 12, we use the redis.call function to get the model field (which contains the serialized object) using the key and the field name. In our case KEYS[1] will be the car.Id and ARGV[3] will be model since that is the name of the field we want to retrieve.

To invoke our Lua script we would use the function as shown below.

 const passedInHashMatchesStoredCar = await client.getModelByHash(
      carId, 
      'hash', 
      '5fad62a7a45fd1414fcd7553bef484585b555b0ff7251b519b9ae5a191c64637a820a85c33bb69488accdfd221d933ab8cf825774c2e0bd561420539527ab131',
      'model'
    );
Invoking Lua script using TypeScript Redis client

Should you use Lua scripts?

Lua scripts are extremely powerful and can significantly increase performance, especially for operations that have a very high frequency but they do have their downsides such as testability and potential delays to other Redis operations.

As with all things in software, whether you choose to use a Lua script will be highly context-based but at least now you have a better understanding of how they can help you and how you can create them.