Skip to main content

Redis Pipeline, Transactions & Lua Scripts

Understanding the differences and constraints of Redis's three batching mechanisms is critical for senior-level design and debugging.


The Network Round-Trip Problem

Every Redis command normally requires one full round-trip:

Client → [NETWORK: 1ms] → Redis → [NETWORK: 1ms] → Client
RTT = 2ms per command

For 1000 commands sequentially: 2000ms of network latency.


1. Pipelining

Pipelining batches multiple commands into a single network write, and reads all responses in bulk. It is not atomic — commands execute independently on the server.

Without pipeline:                 With pipeline:
→ SET k1 v1 → SET k1 v1
← OK → GET k1
→ GET k1 → INCR counter
← "v1" → SET k2 v2
→ INCR counter ← OK (SET k1)
← 1 ← "v1" (GET k1)
← 1 (INCR)
← OK (SET k2)
// Spring Data Redis pipeline example
List<Object> results = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (String key : keys) {
connection.get(key.getBytes());
}
return null;
});
# Jedis/Lettuce/redis-py pipeline
pipe = redis.pipeline(transaction=False) # No MULTI/EXEC wrapper
pipe.set("k1", "v1")
pipe.get("k1")
pipe.incr("counter")
results = pipe.execute() # Single round-trip for all 3 commands

Pipeline Performance Impact

ScenarioWithout PipelineWith PipelineImprovement
1000 SET commands (1ms RTT)~2000ms~5ms400×
100 HGETALL commands~200ms~3ms67×
Same datacenter (0.1ms RTT)~200ms~2ms100×

When to Use (and When NOT To)

Use pipeline when:

  • Sending many independent commands (bulk import, batch reads)
  • Commands don't depend on each other's results
  • High latency network (cross-region)

Don't use pipeline when:

  • Command N depends on result of command N-1 (use Lua or WATCH/MULTI instead)
  • You need atomicity (use MULTI/EXEC or Lua)
  • Working with Redis Cluster — commands must target the same slot

Redis Cluster pipeline caveat: In Cluster mode, all pipelined commands must target the same hash slot (or the same node). Multi-slot pipelines require client-side routing and multiple connections.


2. Transactions (MULTI / EXEC)

Redis transactions guarantee that a block of commands executes atomically — no other client's commands can interleave within the block.

MULTI          # Start transaction block
SET balance 100
DECRBY balance 30
EXEC # Execute all commands atomically
# Returns: ["OK", 70]
MULTI
SET k1 v1
SET k2 v2
DISCARD # Abort — transaction rolled back

Transaction Error Handling

Redis transactions have a critical difference from SQL transactions:

Error TypeBehaviorExample
Command error (compile-time)Transaction is aborted on EXECSET with wrong number of args
Runtime error (exec-time)Other commands still execute!LPUSH on a String key
MULTI
SET k1 "Alice"
INCR k1 # Runtime error: k1 is a String, not an integer
SET k2 "Bob"
EXEC
# Result: ["OK", ERR, "OK"]
# ❌ k1 is set to "Alice" AND k2 is set to "Bob" — the error on INCR doesn't abort!

Critical interview point: Redis has NO rollback. If a command in MULTI/EXEC fails at runtime, the other commands still execute. Redis transactions guarantee isolation and ordering — not SQL-style all-or-nothing semantics.

WATCH — Optimistic Locking

WATCH implements optimistic concurrency control: if a watched key changes before EXEC, the transaction is aborted (returns nil).

# Pattern: Read-Modify-Write with optimistic locking
WATCH balance

current = GET balance # Read current value

MULTI
SET balance (current - 30) # Conditional write
EXEC
# Returns: nil if balance changed by another client → retry
# Returns: ["OK"] if balance was unchanged → success
# Python redis-py with WATCH retry loop
def decrement_balance(redis_client, amount, max_retries=3):
for attempt in range(max_retries):
with redis_client.pipeline() as pipe:
try:
pipe.watch('balance')
current = int(pipe.get('balance') or 0)
if current < amount:
raise InsufficientFundsError()
pipe.multi()
pipe.set('balance', current - amount)
pipe.execute()
return current - amount # Success
except WatchError:
if attempt == max_retries - 1:
raise RetryExhaustedError()
continue # Retry

MULTI/EXEC vs Pipeline

PipelineMULTI/EXEC
Atomicity❌ No✅ Yes (ordered, no interleaving)
Rollback on errorN/A❌ No (runtime errors don't rollback)
Network efficiency✅ Yes✅ Yes (same batching benefit)
Conditional logic❌ No❌ No (use Lua for that)
Cluster supportSame-slot onlySame-slot only

3. Lua Scripting

Lua scripts execute atomically on the Redis server — no other command can interrupt them. They can contain conditional logic (unlike MULTI/EXEC) and access command results within the script.

# Basic structure
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey myvalue
# [lua script] [numkeys] [keys...] [args...]
-- Transfer balance atomically (conditional logic impossible in MULTI/EXEC)
local from_balance = tonumber(redis.call('GET', KEYS[1]))
local to_balance = tonumber(redis.call('GET', KEYS[2]))
local amount = tonumber(ARGV[1])

if from_balance < amount then
return redis.error_reply("Insufficient funds")
end

redis.call('DECRBY', KEYS[1], amount)
redis.call('INCRBY', KEYS[2], amount)
return "OK"
EVAL "..." 2 user:1:balance user:2:balance 50

EVALSHA — Production Usage

For performance, load scripts first and call by SHA1 hash:

SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# → "e0e1f9fabfa9d353a0970aa5c101c53bc0cbf1b4"

EVALSHA e0e1f9fabfa9d353a0970aa5c101c53bc0cbf1b4 1 mykey
// Spring Data Redis Lua script
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText("""
local current = tonumber(redis.call('GET', KEYS[1])) or 0
if current < tonumber(ARGV[1]) then
return 0 -- insufficient
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1 -- success
""");
script.setResultType(Long.class);

Long result = redisTemplate.execute(script,
Collections.singletonList("user:1:credits"),
"10" // deduct 10 credits
);

Production Lua Patterns

Rate limiting (exact sliding window):

-- KEYS[1]: rate limit key, ARGV[1]: limit, ARGV[2]: window (seconds)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window * 1000)
local count = redis.call('ZCARD', key)

if count < limit then
redis.call('ZADD', key, now, now)
redis.call('PEXPIRE', key, window * 1000)
return 1 -- allowed
end
return 0 -- rate limited

Conditional SET (idempotent create):

-- Set only if key doesn't exist or value matches expected
local current = redis.call('GET', KEYS[1])
if current == false or current == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
end
return 0

Lua Script Constraints

ConstraintDetail
BlockingScript blocks all other commands while running — keep scripts short
No asyncCannot call async operations inside Lua
Time limitDefault 5 seconds — exceeded scripts are killed with SCRIPT KILL
ClusterAll KEYS[] must be in the same hash slot (use hash tags)
DeterministicScripts must be deterministic — no random or time-dependent logic
DebugUse SCRIPT DEBUG for development; redis.log() for logging

Comparison Summary

FeaturePipelineMULTI/EXECLua Script
Atomic
Conditional logic
Access intermediate results
Network round-trips111
Cluster (multi-slot)
Rollback on errorN/A✅ (via error_reply)
Production recommendationBulk readsSimple atomic opsComplex atomic ops

Production Decision Guide

Need to send many independent commands?
→ Pipeline (no atomicity needed)

Need atomicity but no conditional logic?
→ MULTI/EXEC + WATCH for optimistic locking

Need conditional logic ("if X then Y else Z") atomically?
→ Lua script (EVAL/EVALSHA)

Working across multiple hash slots in Cluster?
→ Use hash tags to co-locate keys, or accept application-level orchestration