Skip to content

Cache Batch Operations: Efficient Bulk Read and Write with MSET, MGET, and Pipelines

DodaTech Updated 2026-06-28 7 min read

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

  1. How does MGET improve performance compared to individual GET commands?
  2. What is the difference between pipelining and transactions in Redis?
  3. What is the optimal pipeline batch size for a typical Redis workload?
  4. Why should you avoid putting too many commands in a single pipeline?
  5. 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

What is the difference between MGET and pipelining?

MGET is a single Redis command that returns multiple values atomically. Pipelining sends multiple commands without waiting for responses. Use MGET for simple multi-key reads. Use pipelining for mixed operations (reads + writes) or when you need non-atomic execution.

How many commands should I batch in a pipeline?

Start with 50-100 commands per pipeline. Monitor the latency and adjust up to 500-1000. Beyond 1000, memory usage on both client and server increases without significant throughput gains.

Does pipelining guarantee atomicity?

No. Pipelining sends commands sequentially but other clients' commands can interleave. For atomic execution, use MULTI/EXEC (transactions) or Lua scripts.

Can I use MGET in Redis Cluster?

Yes, but all keys in MGET must belong to the same hash slot. If keys are on different nodes, use parallel MGET calls per node or use hash tags to co-locate keys.

What happens if one command in a pipeline fails?

Other commands still execute. Pipeline.execute() returns an array where each element corresponds to a command's result or error. Check the array to identify failed commands.

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