DEV Community

Nicola Cremaschini for AWS Community Builders

Posted on • Originally published at haveyoutriedrestarting.com on

Building Atomic Counters with Elasticache Redis

When working with high-throughput, low-latency applications, Redis an in-memory data storestands out as an excellent choice for implementing the atomic counter pattern.

With its atomic operations and simple APIs, Redis offers a straightforward approach to incrementing counters while ensuring high performance.

In this article, well explore how to build an atomic counter using AWS ElastiCache Redis.

Youll gain a practical understanding of Redis concepts like atomic operations , its replication model , and how to implement counters with the code from this GitHub repository.

Serializability, Linearizability, and Redis

Before we can dive deep into code, we need to recall few concepts (please refer to the first article of this series for a detailed explanation ):

  • Serializability : Operations appear in a consistent sequential order, ensuring correctness.

  • Linearizability : Writes are immediately visible for subsequent reads, ensuring real-time consistency.

Redis processes commands in a single-threaded event loop , ensuring that each command is executed in the order its received. This guarantees atomicity at the command level for operations like INCR.

While Redis operations on a single node can be considered linearizable , in a distributed Redis setup (e.g., with clustering or replicas), this strict ordering can break. Writes to replicas are propagated asynchronously, so they may lag behind the primary node.

Replication and Leader election in Redis

Redis employs a primary-replica architecture , where:

  • The primary node handles all writes and propagates updates to replicas asynchronously.

  • Redis Sentinel handles failover, promoting a replica to primary in case of failure.

For atomic counters, a single primary node is typically sufficient. If clustering is used, counter keys should be kept on a single shard to maintain atomicity.

The Atomic Counter Pattern

The atomic counter pattern allows you to increment a value reliably, even in distributed systems, by ensuring operations are conflict-free and consistent.

Redis supports this pattern natively through the INCR command, which atomically increments a keys value by 1.

However, if it is necessary to carry out the increment depending on the current status of the counter, race conditions may be possible.

Hands-on! Walkthrough of the Deployable Example

Lets dive into the example provided in the GitHub repository.

This example demonstrates how to implement an atomic counter using AWS Lambda , API Gateway , and ElastiCache Redis.

  1. API Gateway : Provides HTTP endpoints for interacting with the counter.

  2. Lambda Functions : Implements the business logic for incrementing the counter.

  3. ElastiCache Redis Cluster: Stores the counters with atomicity guarantees.

In my example project you can decide wheter to use a maximum value for the counter or not: this determine if use or not conditional writes.

Lets focus on lambda business logic, from the redisAtomicCounter Lambda code:

const redisClient = await buildRedisClient();const result = await redisClient.eval(getLuaScript(useConditionalWrites), 1, id, maxCounterValue);
Enter fullscreen mode Exit fullscreen mode

This code snippet simply sends an eval command and gets the new updated counter value, using the ioredis client.

Let see how the getLuaScript() works:

const getLuaScript = (useConditionalWrites: boolean) => { const unconditionalIncrementScript = ` redis.call('INCR', KEYS[1]) local counter = redis.call('GET', KEYS[1]) return counter `; const conditionalIncrementScript = ` local counter = redis.call('GET', KEYS[1]) local maxValue = tonumber(ARGV[1]) if not counter then counter = 0 end counter = tonumber(counter) if counter < maxValue then redis.call('INCR', KEYS[1]) counter = redis.call('GET', KEYS[1]) return counter else return 'Counter has reached its maximum value of: ' .. maxValue end `; return useConditionalWrites ? conditionalIncrementScript : unconditionalIncrementScript;}
Enter fullscreen mode Exit fullscreen mode

Unconditional writes don't require actually to be executed inside a LUA Script: we could use the incr() method provided by ioredis client.

But for conditional writes, it is required to check the counter value before incrementing it to avoid race conditions: if we perform the check on client side, another client might increment the counter between the get() and the incr() instructions execution on the first client.

Let's see an example: assuming the maximum value for the counter is 10, Alice and Bob perform a GET for the same key 1, when the counter value is 9.

They check if the current value is below the maximum value, and then they both send an INC command to redis.

Since INC command is executed unconditionally on server side, the counter is incremented by two and exceeds the maximum value.

Redis secret sauce: LUA Script

The solution is to check the counter value on server side, executing a LUA Script.

It gets the counter value, check if it is below the maximum value and eventually increment it:

local counter = redis.call('GET', KEYS[1])local maxValue = tonumber(ARGV[1])if not counter then counter = 0endcounter = tonumber(counter)if counter < maxValue then redis.call('INCR', KEYS[1]) counter = redis.call('GET', KEYS[1]) return counterelse return 'Counter has reached its maximum value of: ' .. maxValueend
Enter fullscreen mode Exit fullscreen mode

If you look the code, you might think this code is not safe too: another script could increment the counter between the GET and the INCR command execution.

The magic of LUA Script is that just one script can be executed at the same time, as reported in the documentation:

Redis guarantees the script's atomic execution. While executing the script, all server activities are blocked during its entire runtime. These semantics mean that all of the script's effects either have yet to happen or had already happened.

and that is exactly what we need when dealing with atomic counters: Since only one script is executed, there is no concurrency and no race conditions, as serializability is guaranteed.

Moreover, we have better performance, which is good:

Because scripts execute in the server, reading and writing data from scripts is very efficient.

Trade-Offs and Conclusion

Strengths :

  • Redis provides low-latency atomic operations.

  • The INCR command is inherently atomic, simplifying counter implementation.

  • LUA Script execution prevent race conditions on conditional writes, achieving serializability.

Limitations :

  • Asynchronous Replication : Updates may not immediately reflect on replicas.

  • Durability Risks : Without persistence, counters may reset after a failure or restart.

Conclusion:

Redis is ideal for high-performance, in-memory atomic counters where latency is a top priority, and simplifies building atomic counters with its native support for atomic operations and low-latency access.

However, consider its replication and durability trade-offs for production use.

Check out the deployable example here, and stay tuned for the next article in the series, where well explore Momento as an alternative for serverless caching.

Top comments (0)