DEV Community

Ahmad Hoseiny
Ahmad Hoseiny

Posted on

Redis and Lua scripts

Redis Lua script

Redis is the de facto go-to solution for use cases such as counters, rate limits, and distributed locks in systems operating under high load with strict performance requirements. Since Redis is super popular for being single-threaded, hence atomic operations and transactions, it's fairly easy and not uncommon to make the deceptive assumption that the application logic is free of race conditions.

What happens when we run a GET command on some key, inspect its value in the application code, and then run a SET command on the same key? This is a textbook sample case for a read-modify-write race condition. To illustrate, in the meantime between these two commands, another connection could have edited the value of that key resulting in a dirty final value that may be distinct from the expected serializable one. Let's dig into practical use cases and check out how and whether they can be handled utilizing Redis.

The Building Blocks: SET, GET, and INCR

Redis commands are simple and individually safe:

SET counter 10
GET counter
INCR counter
Enter fullscreen mode Exit fullscreen mode

INCR is really nice since it's atomic. Redis reads the existing value, increments it, and writes it back to the same key, all in a single indivisible process (i.e. The scenario in which the key read and write commands can be interleaved by some other connection is impossible here)

MULTI / EXEC transactions

To bundle a set of operations into a single atomic transaction, Redis provides MULTI / EXEC:

MULTI
SET a 1
INCR b
EXEC
Enter fullscreen mode Exit fullscreen mode

When a MULTI command is issued, Redis marks the connection as one entering a transaction. Following commands are not executed but rather queued. When EXEC is issued, and only then, Redis executes the entire queue of commands back-to-back and sequentially. Again, no interleaving scenario is possible here.

Seems problem is solved! But, is it really? Could we have lost some privilege in the process?

Digression: MySQL transactions

Comparing this with a conventional relational database such as MySQL helps clarify the picture.

In MySQL, you can open a transaction, run a query, actually have it executed, inspect its results, and then, proceed to the subsequent queries, all within the same atomic transaction:

START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- application logic decides what to do next
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
Enter fullscreen mode Exit fullscreen mode

This capability is possible because of the isolation levels (READ COMMITTED, REPEATABLE READ, SERIALIZABLE, etc.) implemented along with rollback mechanisms. A transaction can be atomic, compute intermediary results, and execute concurrently along with other transactions.

Redis, on the other hand, being single threaded, runs transactions in a single go from beginning to completion independently. It doesn't communicate any intermediate results with the calling server for the obvious performance purposes.

Use case: Rate Limiter

A naive rate limiter handling distributed web servers could look like:

GET client_x:counter        -- app reads: 99
-- app checks: 99 < RATE_LIMIT[client_x]
INCR client_x:counter       -- counter becomes 100
Enter fullscreen mode Exit fullscreen mode

Multiple concurrent calls can read a value below the rate limit, and all actually pass and increment it by one, rendering the cap practically useless.

Lua scripting

Redis allows you to send a Lua script to be executed directly on the server as a single atomic command:

-- KEYS[1] = the counter key
-- ARGV[1] = the cap
local current = tonumber(redis.call('GET', KEYS[1]) or "0")
if current < tonumber(ARGV[1]) then
  return redis.call('INCR', KEYS[1])
else
  return -1
end
Enter fullscreen mode Exit fullscreen mode

Run it as following:

EVAL "<script_path>" 1 counter 100
Enter fullscreen mode Exit fullscreen mode

Now, this gives us more powerful capabilities. A Turing-complete high-level script can be run on the Redis server atomically. This gives rise to a larger class of applications. Imagine implementing a standard full leaky bucket rate limiter on Redis's side.

One nice side effect to consider as well is the saved network round trips. Two read and write commands were replaced with a single network call carrying a script expecting the final required response back.

Production tip

Parametrize everything through KEYS and ARGV: Avoid injecting strings directly into the script (e.g. using template engines or similar tools). Redis caches scripts on its side by the SHA1 hash of their exact source text. Variables hardcoding forces new cache entries along with script recompiling causing performance hits.

Summary

Lua scripts running on Redis's server side is a powerful feature that gives rise to a large class of applications. However, it doesn't magically solve race conditions related issues. One has to be mindful of the different paths through which the data flows to be able to apply it in the appropriate scenarios.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.