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(). Useredis.log(redis.LOG_NOTICE, "Value: " .. val)to write to the Redis log file. - Always use ‘local’: If you forget the
localkeyword, 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.

