Transactions and Lua Scripts

Introduction

Redis commands are atomic individually, but real-world operations often require multiple steps. This chapter covers two mechanisms for grouping commands: transactions queue a batch of operations and execute them atomically, while Lua scripts run server-side logic with the full power of a programming language. Both let you reduce network round trips and avoid race conditions, though they differ in flexibility and error-handling behavior.

Prerequisites

  • Comfort with basic Redis commands and data types
  • Familiarity with the concept of atomicity and concurrency
  • No prior Lua experience is required; syntax is explained as it appears

Transactions

A Redis transaction groups multiple commands so they execute sequentially without interruption from other clients. It is not an ACID transaction in the relational database sense—there is no rollback—but the commands do execute atomically with respect to other clients.

MULTI, EXEC, and DISCARD

bash
# Start a transaction
MULTI
 
# Queue commands (they are not executed yet)
SET balance:alice 900
INCRBY balance:bob 100
 
# Execute the queued commands atomically
EXEC

Between MULTI and EXEC, Redis buffers your commands. Other clients cannot see the intermediate state. When EXEC arrives, Redis runs all queued commands in order.

If you change your mind before EXEC:

bash
MULTI
SET temp:flag "processing"
INCR temp:counter
DISCARD
# The transaction is canceled; nothing is executed

What Happens When a Command Fails?

Redis distinguishes two failure modes inside a transaction:

  1. Command queueing failure: You send a syntactically invalid command or use it against the wrong data type. Redis rejects it immediately during MULTI, and EXEC is aborted.
  2. Runtime failure: A command is valid but fails during execution (for example, INCR on a String that is not numeric). Redis continues executing the remaining commands in the transaction.

Warning

No Rollback

If the third command in a five-command transaction fails, the first two have already executed and the last two will still execute. Your application must handle partial failure explicitly, usually by designing idempotent operations or compensating later.

Optimistic Locking with WATCH

WATCH implements optimistic locking. You monitor one or more keys; if any of them change before your EXEC, the transaction is aborted and returns (nil).

bash
# Watch Alice's balance
WATCH balance:alice
 
# Read the current value
GET balance:alice
# "1000"
 
# Start the transaction
MULTI
DECRBY balance:alice 100
INCRBY balance:bob 100
EXEC

If another client modifies balance:alice after your WATCH but before your EXEC, your EXEC returns (nil) and you must retry the entire read-modify-write sequence.

UNWATCH clears the watched keys without executing a transaction. It is called automatically after EXEC or DISCARD.

Lua Scripts

When transactions are not expressive enough, Lua scripts give you variables, conditionals, loops, and the ability to read intermediate results before deciding what to write.

EVAL

EVAL sends a Lua script to the Redis server. The script executes atomically: no other command runs while it is active.

bash
# Atomically transfer credits if sufficient balance exists
EVAL "local bal = redis.call('GET', KEYS[1]); if tonumber(bal) >= tonumber(ARGV[1]) then redis.call('DECRBY', KEYS[1], ARGV[1]); redis.call('INCRBY', KEYS[2], ARGV[1]); return 1; else return 0; end;" 2 balance:alice balance:bob 100
  • KEYS[1] is balance:alice
  • KEYS[2] is balance:bob
  • ARGV[1] is 100
  • The number 2 tells Redis how many arguments are keys (needed for cluster routing)

Tip

KEYS vs ARGV

Always pass key names through KEYS so that Redis Cluster can route the script to the correct node. Pass literal values through ARGV. Failing to do this breaks cluster compatibility.

redis.call vs redis.pcall

  • redis.call() executes a Redis command. If the command errors, the Lua error propagates and the script aborts.
  • redis.pcall() executes a command but traps errors, returning them as tables you can inspect.
lua
local result = redis.pcall('INCR', KEYS[1])
if type(result) == 'table' and result.err then
    return {err = "Increment failed: " .. result.err}
else
    return result
end

Script Caching with EVALSHA

Sending the full script body on every request wastes bandwidth. Redis caches scripts by their SHA1 digest. After the first EVAL, use EVALSHA with the digest.

bash
# Load the script without executing it
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# "a1b2c3d4..." (SHA1 digest)
 
# Execute by SHA1
EVALSHA a1b2c3d4... 1 mykey

If the server restarts or script eviction occurs, EVALSHA returns a NOSCRIPT error. Client libraries typically fall back to EVAL automatically.

bash
# Check if a script is cached
SCRIPT EXISTS a1b2c3d4...
 
# Flush all cached scripts (use with caution)
SCRIPT FLUSH

A Practical Example: Rate Limiter

This Lua script implements a sliding-window rate limiter atomically:

The script returns 1 if the request is allowed and 0 if the user has exceeded the limit. Because it runs on the server, there is no race window between reading the count and adding the new entry.

When to Use Transactions vs Lua Scripts

SituationUse
Simple batch of independent commandsMULTI / EXEC
Read-modify-write with optimistic lockingWATCH + MULTI / EXEC
Conditional logic, loops, or computed valuesLua script
Need to return intermediate results to the clientLua script
Complex atomic operation that would otherwise need multiple round tripsLua script

FAQ

Can I call blocking commands like BLPOP inside a Lua script?

No. Blocking commands, commands with non-deterministic output (like RANDOMKEY), and commands that modify the replication state are forbidden inside Lua scripts to preserve consistency.

Does Lua scripting work on Redis Cluster?

Yes, but all keys accessed by the script must hash to the same slot. Use hash tags ({user:42}:data and {user:42}:meta) to guarantee locality. If keys span multiple slots, Redis rejects the script.

What happens if a Lua script runs for too long?

Redis logs a slow-script warning and allows other clients to run SCRIPT KILL (if the script has not yet performed any writes). A script that has already written data cannot be killed safely; you must wait or restart the server. Keep scripts short.

Is there a size limit for Lua scripts?

Scripts are limited by the Lua interpreter memory inside Redis (typically 5 MB of compiled bytecode). In practice, if your script is longer than a few hundred lines, you should reconsider whether it belongs in Redis or in your application server.

Why does my transaction return an array of results?

EXEC returns the results of every queued command in order. For example, if you queue SET, INCR, and GET, the return value is ["OK", 1, "value"].

Can I nest MULTI inside another MULTI?

No. Redis rejects a second MULTI while already inside a transaction and returns an error.