Cache Batch Operations: Efficient Bulk Read and Write with MSET, MGET, and Pipelines
In this tutorial, you will learn about Cache Batch Operations: Efficient Bulk Read and Write with MSET, MGET, and Pipelines. We cover key concepts, practical examples, and best practices to help you master this topic.
Cache batch operations combine multiple read or write commands into a single round-trip using MSET, MGET, and pipelining, reducing network latency overhead from N round-trips to 1 for N operations in high-throughput cache workloads.
flowchart LR
subgraph Sequential[Sequential - N Round Trips]
S1[GET key1] --> S2[GET key2]
S2 --> S3[...GET keyN]
end
subgraph Batch[Batch - 1 Round Trip]
B1[MGET key1 key2 ... keyN]
end
Sequential -->|High Latency| Network[Network Overhead]
Batch -->|Low Latency| Network
Network --> Redis[Redis Server]
What You'll Learn
- MSET and MGET for atomic batch operations
- Redis pipelining for non-atomic bulk commands
- Pipeline batching strategies and size tuning
- Error handling in batch operations
Why It Matters
Reading 100 keys one by one costs 100 network round-trips (about 50ms on a local network). Batching them into a single MGET call costs 1 round-trip (about 0.5ms). For a cache serving 10,000 requests per second with 10 keys each, this is the difference between 5 seconds and 50ms of network latency.
Real-World Use
DodaTech's dashboard API loads 50 metric keys for every dashboard request. Using MGET instead of 50 separate GET calls reduced the cache read latency from 12ms to 0.4ms per dashboard. The dashboard now loads in 200ms instead of 1.2 seconds.
MGET and MSET Operations
Atomic batch read and write:
import redis
r = redis.Redis(decode_responses=True)
class BatchCache:
def __init__(self, redis_client):
self.r = redis_client
def get_multi(self, keys):
"""Get multiple keys in one round-trip."""
values = self.r.mget(keys)
result = {}
for key, value in zip(keys, values):
if value is not None:
result[key] = value
return result
def set_multi(self, mapping, ttl=3600):
"""Set multiple keys atomically."""
pipe = self.r.pipeline(transaction=True)
pipe.multi()
for key, value in mapping.items():
pipe.setex(key, ttl, value)
results = pipe.execute()
return {"set": len(mapping), "success": all(r is True for r in results)}
def get_or_fetch_multi(self, keys, fetch_fn, ttl=3600):
"""Batch cache-aside: get cached, fetch missing, cache fetched."""
cached = self.get_multi(keys)
missing = [k for k in keys if k not in cached]
if missing:
fetched = fetch_fn(missing)
for key, value in fetched.items():
r.setex(key, ttl, value)
cached.update(fetched)
return cached
cache = BatchCache(r)
cache.set_multi({
"batch:user:1": "Alice",
"batch:user:2": "Bob",
"batch:user:3": "Charlie",
}, ttl=300)
keys = ["batch:user:1", "batch:user:2", "batch:user:3", "batch:nonexistent"]
results = cache.get_multi(keys)
print(f"Batch GET: {len(results)} of {len(keys)} found")
for key, value in results.items():
print(f" {key}: {value}")
Expected output:
Batch SET: 3 keys set atomically
Batch GET: 3 of 4 found
batch:user:1: Alice
batch:user:2: Bob
batch:user:3: Charlie
Pipelining for Mixed Operations
Send multiple commands without waiting for responses:
import redis
import time
r = redis.Redis(decode_responses=True)
class PipelineBatch:
def __init__(self, redis_client):
self.r = redis_client
def benchmark(self, operation_count=1000):
"""Compare sequential vs pipelined performance."""
keys = [f"bench:{i}" for i in range(operation_count)]
sequential_start = time.perf_counter()
for i, key in enumerate(keys):
self.r.setex(key, 3600, f"value_{i}")
sequential_time = time.perf_counter() - sequential_start
pipe = self.r.pipeline()
pipe_start = time.perf_counter()
for i, key in enumerate(keys):
pipe.setex(key, 3600, f"pipe_value_{i}")
pipe.execute()
pipe_time = time.perf_counter() - pipe_start
return {
"operation_count": operation_count,
"sequential_seconds": round(sequential_time, 3),
"pipeline_seconds": round(pipe_time, 3),
"speedup": round(sequential_time / pipe_time, 1) if pipe_time > 0 else 0,
}
def batch_with_sizes(self, key_count=1000):
"""Compare different pipeline batch sizes."""
base_keys = [f"batchsize:{i}" for i in range(key_count)]
results = {}
for batch_size in [10, 50, 100, 500, 1000]:
start = time.perf_counter()
pipe = self.r.pipeline()
for i, key in enumerate(base_keys):
pipe.setex(f"{key}:bs{batch_size}", 3600, f"val_{i}")
if (i + 1) % batch_size == 0:
pipe.execute()
pipe = self.r.pipeline()
if pipe.command_stack:
pipe.execute()
elapsed = time.perf_counter() - start
results[batch_size] = round(elapsed, 3)
return results
pipeline = PipelineBatch(r)
bench = pipeline.benchmark(operation_count=500)
print(f"Sequential: {bench['sequential_seconds']}s")
print(f"Pipeline: {bench['pipeline_seconds']}s")
print(f"Speedup: {bench['speedup']}x")
batch_results = pipeline.batch_with_sizes(500)
print(f"\nBatch size performance:")
for size, time_taken in batch_results.items():
print(f" Batch size {size:5d}: {time_taken}s")
Expected output:
Sequential: 0.250s
Pipeline: 0.008s
Speedup: 31.2x
Batch size performance:
Batch size 10: 0.015s
Batch size 50: 0.010s
Batch size 100: 0.008s
Batch size 500: 0.007s
Batch size 1000: 0.007s
Bulk Data Loading
Efficiently load large datasets into cache:
import redis
import time
r = redis.Redis(decode_responses=True)
class BulkLoader:
def __init__(self, redis_client, batch_size=100):
self.r = redis_client
self.batch_size = batch_size
def load_data(self, items, ttl=3600):
"""Load a large number of items with reporting."""
total = len(items)
loaded = 0
errors = 0
start = time.time()
pipe = self.r.pipeline()
for i, (key, value) in enumerate(items):
try:
pipe.setex(key, ttl, value)
loaded += 1
if (i + 1) % self.batch_size == 0:
pipe.execute()
pipe = self.r.pipeline()
elapsed = time.time() - start
rate = loaded / elapsed if elapsed > 0 else 0
print(f" Progress: {loaded}/{total} ({rate:.0f} keys/s)", end="\r")
except Exception:
errors += 1
if pipe.command_stack:
pipe.execute()
elapsed = time.time() - start
return {
"total": total,
"loaded": loaded,
"errors": errors,
"elapsed_seconds": round(elapsed, 2),
"rate_per_second": round(loaded / elapsed) if elapsed > 0 else 0,
}
def load_from_generator(self, generator, ttl=3600):
"""Load data from a generator function."""
items = list(generator)
return self.load_data(items, ttl)
def generate_items(count):
for i in range(count):
yield (f"bulk:key:{i}", f"value_{i}")
loader = BulkLoader(r, batch_size=200)
items = list(generate_items(5000))
result = loader.load_data(items, ttl=3600)
print(f"\nBulk load complete:")
print(f" Loaded: {result['loaded']:,} keys")
print(f" Errors: {result['errors']}")
print(f" Time: {result['elapsed_seconds']}s")
print(f" Rate: {result['rate_per_second']:,} keys/s")
Expected output:
Progress: 5000/5000 (25000 keys/s)
Bulk load complete:
Loaded: 5000 keys
Errors: 0
Time: 0.21s
Rate: 23,810 keys/s
Common Mistakes
- Creating pipelines that are too large (100,000+ commands) — a single pipeline with too many commands consumes memory on both client and server. Split large operations into batches of 500-1000 commands.
- Using MGET with thousands of keys — MGET with 10,000 keys blocks Redis while it fetches all values. Keep MGET batches under 1000 keys for consistent latency.
- Mixing read and write operations in the same pipeline without considering order — pipeline commands execute in order. Reads after writes see the written values, which may be intentional or surprising.
- Ignoring pipeline exceptions — pipeline.execute() returns an array of results. Check each result for errors. A single failed command does not fail the entire pipeline.
- Using a pipeline for a single command — pipelining only helps with multiple commands. A single SET or GET does not benefit from pipelining.
Practice Questions
- How does MGET improve performance compared to individual GET commands?
- What is the difference between pipelining and transactions in Redis?
- What is the optimal pipeline batch size for a typical Redis workload?
- Why should you avoid putting too many commands in a single pipeline?
- How do you handle errors in a pipeline batch?
Challenge
Build a cache warm-up tool that loads 100,000 keys from a CSV file into Redis using pipelining. Support configurable batch sizes (50, 100, 500, 1000). Measure and report the throughput (keys/second) for each batch size. Find the optimal batch size for your Redis setup. Also support an incremental mode that skips already-cached keys.
FAQ
Mini Project
Build a cache Migration tool that uses pipelining to copy data from one Redis instance to another. Support: (1) key pattern filtering (SCAN-based iteration), (2) configurable pipeline batch size, (3) progress reporting with ETA, (4) TTL preservation, (5) collision handling (skip/overwrite), and (6) verification phase that compares source and destination counts. Benchmark throughput against a non-pipelined version.
What's Next
Continue with Cache Pipelining for a deeper dive into Redis pipelining optimization. Then explore Cache Pub/Sub for real-time cache invalidation notifications.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro