Stop the Race Conditions: A Guide to Redis Lua Scripting

Database tutorial - IT technology blog
Database tutorial - IT technology blog

The 2:14 AM Inventory Nightmare

My phone vibrated off the nightstand at exactly 2:14 AM. A flash sale had just launched, and 5,000 users were hitting a single SKU simultaneously. Even though we were using Redis for speed, we were seeing negative stock levels in the logs. Two processes would check the stock, see ‘1’ item left, and both would decrement it. We oversold 42 items in three minutes. It was a classic race condition that cost us hours of manual cleanup.

I’ve spent years jumping between MySQL, Postgres, and MongoDB. They all have their place. But when you need absolute speed and guaranteed atomicity, Redis is the standard. However, basic commands aren’t always enough for complex logic. That night, I realized that moving our logic from the application server into Redis using Lua scripts was the only way to sleep peacefully again.

Quick start: Your first Lua script

Redis runs Lua scripts directly on the server using the EVAL command. The most important rule? Never hardcode your keys. You pass them as parameters so Redis can manage them correctly, especially in cluster mode.

Here is the basic syntax:

EVAL "script" numkeys key1 key2 arg1 arg2

Try this in your redis-cli:

EVAL "return 'The value of ' .. KEYS[1] .. ' is ' .. ARGV[1]" 1 mykey myvalue

In Lua, table indexing starts at 1, not 0. KEYS is a global table for your keys, and ARGV holds your extra arguments. Separating them allows Redis to know exactly which keys the script will touch before it even runs.

Solving the Inventory Bug

We can fix the “check-then-set” race condition by wrapping the logic in a script. This ensures no other command can sneak in between the check and the decrement.

-- inventory.lua
local current_stock = tonumber(redis.call('GET', KEYS[1]))
if current_stock and current_stock > 0 then
    return redis.call('DECR', KEYS[1])
else
    return -1
end

Run this from your terminal like this:

redis-cli --eval inventory.lua stock_count , 0

Why bother with Lua?

Wait, why not just use a transaction with MULTI/EXEC? Transactions group commands, but they can’t use the result of step one to decide what to do in step two. You’d need WATCH, which uses optimistic locking. Under high contention, WATCH fails constantly, forcing your app to retry over and over.

1. Guaranteed Atomicity

Redis is single-threaded during command execution. When your Lua script starts, it runs to completion without interruption. No other client can modify your data until the script finishes. This effectively turns a multi-step process into one single, atomic command.

2. Slashing Network Latency

Think about a sequence requiring five GET calls and three SET calls. If your app server sits 10ms away from your Redis instance, that’s 80ms wasted just on travel time. Lua moves the logic to the data. You send one request, and the server does the heavy lifting, cutting that 80ms down to 10ms.

3. Bandwidth Efficiency

Use SCRIPT LOAD to store your script on the server. Redis returns a SHA1 hash. From then on, you only send that 40-character hash instead of the entire script body. This saves significant bandwidth on high-frequency calls.

# Load it once
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# Returns: "4e6d8fc8addea77f986083e81283fdec454c4e24"

# Run it using the hash
EVALSHA 4e6d8fc8addea77f986083e81283fdec454c4e24 1 mykey

Advanced Logic: Beyond Simple Increments

Production environments usually demand more than just basic math. I recently replaced an application-level rate limiter with Lua. The original version required three Redis round-trips per request and was struggling at 10,000 requests per second.

A Dynamic Rate Limiter

This script tracks request counts and sets an expiration for new windows in one go.

local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call('INCR', key)

if current == 1 then
    redis.call('EXPIRE', key, window)
end

if current > limit then
    return 0 -- Rejected
end

return 1 -- Authorized

Note the use of redis.call(). If a command fails, the script crashes and returns the error. If you need to handle errors gracefully, use redis.pcall() instead. It returns a Lua table containing the error message rather than stopping execution.

Updating JSON on the Fly

If you store JSON strings, you can use the built-in cjson library. This is a lifesaver for updating a single field in a large object without downloading and re-uploading the whole string.

local data = redis.call('GET', KEYS[1])
local obj = cjson.decode(data)
obj["last_login"] = ARGV[1]
redis.call('SET', KEYS[1], cjson.encode(obj))

Lessons from the Trenches

Deploying dozens of Lua scripts taught me a few painful lessons. Follow these rules to avoid 2 AM pages.

  • Watch the clock: A long-running script blocks the entire Redis server. If a script exceeds 5 seconds (the default lua-time-limit), Redis will start rejecting other commands. Keep your logic O(1) or O(N) with small N.
  • Stay Deterministic: Redis replicas run scripts to stay in sync. Avoid math.random() or fetching the system time inside the script. If the master and replica generate different values, your data will drift. Pass timestamps as arguments instead.
  • Log for Sanity: You can’t use print(). Use redis.log(redis.LOG_NOTICE, "Value: " .. val) to write to the Redis log file.
  • Always use ‘local’: If you forget the local keyword, variables become global. This causes memory leaks and bizarre bugs where one script execution leaks data into the next.

Testing Without Fear

Don’t guess if your script works. Use the Redis Lua debugger (LDB). It lets you step through code and inspect variables in real-time.

redis-cli --ldb --eval myscript.lua key1 , arg1

Lua scripting transforms Redis from a simple store into a programmable database engine. It bridges the gap between raw speed and complex safety. Once you move your atomic logic into Lua, you’ll stop worrying about race conditions and start focusing on building features.

Share: