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

Spring Data Redis: Transactionsโ€‹

@Service
public class BankService {

@Autowired
private RedisTemplate<String, Long> redisTemplate;

public boolean transfer(String from, String to, long amount) {
List<Object> results = redisTemplate.execute(new SessionCallback<List<Object>>() {
@Override
public List<Object> execute(RedisOperations operations) throws DataAccessException {
operations.watch("balance:" + from);

Long balance = (Long) operations.opsForValue().get("balance:" + from);
if (balance == null || balance < amount) {
operations.unwatch();
return Collections.emptyList();
}

operations.multi();
operations.opsForValue().decrement("balance:" + from, amount);
operations.opsForValue().increment("balance:" + to, amount);
return operations.exec();
}
});

return results != null && !results.isEmpty();
}
}

Method 2: @Transactional Integrationโ€‹

@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setEnableTransactionSupport(true); // โ† required for @Transactional
return template;
}
}

Warning: When enableTransactionSupport = true, all get operations within a @Transactional method return null immediately (they are queued). Use SessionCallback if you need to read values mid-transaction.

Retry Pattern for Optimistic Lockingโ€‹

public boolean transferWithRetry(String from, String to, long amount) {
int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; attempt++) {
if (transfer(from, to, amount)) return true;

try {
Thread.sleep(10 * (attempt + 1)); // back-off
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}

ReactiveRedisTemplate Pipeliningโ€‹

For reactive/non-blocking applications:

@Service
public class ReactiveBulkService {

@Autowired
private ReactiveRedisTemplate<String, String> reactiveTemplate;

public Mono<List<Boolean>> bulkSet(Map<String, String> data) {
return reactiveTemplate.executePipelined(operations ->
Flux.fromIterable(data.entrySet())
.flatMap(entry ->
operations.opsForValue()
.set(entry.getKey(), entry.getValue())
)
.then()
).collectList();
}
}

Practical Example: Warm Up Cache in Bulkโ€‹

@Service
public class CacheWarmupService {

@Autowired
private RedisTemplate<String, Product> redisTemplate;

@Autowired
private ProductRepository productRepository;

private static final int BATCH_SIZE = 500;

@EventListener(ApplicationReadyEvent.class)
public void warmUp() {
List<Product> allProducts = productRepository.findAll();

// Process in batches to avoid memory spikes
for (int i = 0; i < allProducts.size(); i += BATCH_SIZE) {
List<Product> batch = allProducts.subList(i, Math.min(i + BATCH_SIZE, allProducts.size()));

redisTemplate.executePipelined((RedisCallback<Object>) conn -> {
batch.forEach(product -> {
byte[] k = ("product:" + product.getId()).getBytes();
conn.stringCommands().setEx(k, 3600, serialize(product));
});
return null;
});
}
}
}

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