Redis and Memcached — Distributed Caching Reference

What a cache is actually for, how the two stores differ, and the patterns that survive production: cache-aside, invalidation, stampede protection, locks and rate limiting — with the failure paths written down.

Scope: Redis 7 · Memcached 1.6 · StackExchange.Redis · ioredis · .NET 8 · Node 20

01What a cache is for, and what it costs

A cache trades memory and staleness for latency and load. It only pays off when the read is frequent, expensive and tolerant of being a few seconds old.

The three questions to answer before adding one: what is the key, how long may the value be wrong, and what happens when the cache is empty or the store is down.

  • Never cache what must be exact: balances, stock decrements, permission decisions at write time.
  • A cache is an optimisation, not a source of truth — the code must still work with it empty.
  • Measure the hit ratio. Below roughly 80% the cache is mostly overhead.
// Cache-aside, the default pattern
value = cache.get(key)
if value is null:
    value = database.load(key)      // authoritative source
    cache.set(key, value, ttl)      // absolute expiry, never infinite
return value

// On write: update the database first, then invalidate the key
database.save(entity)
cache.delete(key)                    // delete, do not update: simpler and safer

02Redis versus Memcached

Memcached is a pure key/value store: strings only, LRU eviction, no persistence, no replication. It is fast, simple and predictable.

Redis is a data-structure server — hashes, sets, sorted sets, streams, pub/sub — with optional persistence, replication and clustering. Most new designs choose Redis because the extra structures replace a lot of application code.

MemcachedRedis
ModelStrings only, multi-getStrings, hashes, sets, sorted sets, streams
PersistenceNone — restart loses everythingRDB snapshots and/or AOF log
ReplicationNoPrimary/replica, Sentinel, Cluster
EvictionLRU by slabConfigurable policies incl. allkeys-lru, volatile-ttl
Atomic opsCAS on a keyINCR, Lua scripts, transactions, locks
Typical rolePure object cache in front of SQLCache plus queues, counters, rate limits, sessions
  • Say the trade-off out loud: Memcached is simpler and horizontally trivial; Redis is richer but another stateful service to operate.
  • Choose Memcached when the workload is genuinely 'get a blob by key' at very large scale.
  • Choose Redis when you also need counters, locks, rate limiting or pub/sub — very common in enterprise apps.
// Same cache-aside shape, different client
// Memcached: multi-get is the idiom that makes it competitive
var values = memcached.Get(new[] { "user:1", "user:2", "user:3" });
// Redis: one round trip with a pipeline does the same job
var batch = db.CreateBatch();
var tasks = ids.Select(id => batch.StringGetAsync($"user:{id}")).ToArray();
batch.Execute();

03Connecting from .NET

`StackExchange.Redis` is the standard client. The connection multiplexer must be a singleton: it manages the socket, the protocol and the reconnect logic, and it is thread-safe by design.

Recreating it per request is the classic production incident — connection storms and timeouts under load.

  • `AbortOnConnectFail = false` lets the app boot while Redis is still starting.
  • Set `SyncTimeout` low and prefer the async API; the sync API blocks a thread per call.
  • One multiplexer per process, registered as a singleton, disposed on shutdown.
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
    ConnectionMultiplexer.Connect(new ConfigurationOptions
    {
        EndPoints = { "redis:6379" },
        AbortOnConnectFail = false,        // survive a cold start
        ConnectRetry = 3,
        ConnectTimeout = 5000,
        SyncTimeout = 2000,
        Ssl = true,
        DefaultDatabase = 0
    }));

// In the repository
public sealed class UserCache(IConnectionMultiplexer mux)
{
    private readonly IDatabase _db = mux.GetDatabase();

    public Task<string?> GetAsync(Guid id) => _db.StringGetAsync($"user:v1:{id}");
}

04Connecting from Node and TypeScript

`ioredis` and `node-redis` are both fine. The same rule applies: one client per process, reconnect handled by the library, and errors must have a listener or Node will crash the process.

  • An `error` listener is not optional: an unhandled error event terminates the Node process.
  • `maxRetriesPerRequest` bounds the latency a failing Redis can add to a request.
  • Serialise explicitly — JSON is fine; for large payloads prefer msgpack or store less.
import Redis from 'ioredis';

export const redis = new Redis(process.env.REDIS_URL!, {
  maxRetriesPerRequest: 2,
  enableReadyCheck: true,
  retryStrategy: (times) => Math.min(times * 200, 2000)
});

redis.on('error', (err) => logger.error({ err }, 'redis error'));   // mandatory

export async function cached<T>(key: string, ttl: number, load: () => Promise<T>): Promise<T> {
  const hit = await redis.get(key);
  if (hit) { return JSON.parse(hit) as T; }
  const value = await load();
  await redis.set(key, JSON.stringify(value), 'EX', ttl);
  return value;
}

05Key design and namespacing

Keys are the schema of a cache. Without a convention you get collisions between features, unreadable `MONITOR` output and no way to invalidate by group.

Pattern that holds up: `app:entity:v{version}:id[:field]`. The version segment is what makes a shape change a deploy instead of a migration.

  • `KEYS *` and `SCAN` on a production instance are incidents waiting to happen; design so you never need them.
  • Keep values small: a few KB, not a whole table.
  • Hash tags `{...}` keep related keys in the same cluster slot when you use Redis Cluster.
app:user:v1:8f14e45f          -> JSON blob of a user
app:user:v1:8f14e45f:roles    -> a separate, smaller value
app:catalog:v2:category:12    -> a list, version bumped when the DTO changed
app:lock:invoice:7781         -> a lock key, short TTL

// Group invalidation without SCAN in the hot path
app:user:v1:{id}              // delete one key on write
app:catalog:v2:{category}     // bump v2 -> v3 to invalidate the whole namespace

06Stampede protection: when the key expires under load

A popular key that expires while thousands of requests arrive sends every one of them to the database. The cache made the problem worse, not better.

Two standard answers: a short lock so one caller recomputes, or a slightly stale value served while a single refresh runs in the background.

  • Also add TTL jitter (±10%) so a whole namespace does not expire in the same second.
  • Serving stale while revalidating (SWR) is better UX than a latency spike — if the data tolerates it.
  • The lock must always be released in a `finally`, with a token so you never delete someone else's lock.
// Single-flight with a lock and a short TTL
public async Task<Catalog> GetCatalogAsync(int id, CancellationToken ct)
{
    var key = $"app:catalog:v2:{id}";
    var hit = await _db.StringGetAsync(key);
    if (hit.HasValue) { return JsonSerializer.Deserialize<Catalog>(hit!)!; }

    var lockKey = $"app:lock:catalog:{id}";
    var token = Guid.NewGuid().ToString();
    if (await _db.LockTakeAsync(lockKey, token, TimeSpan.FromSeconds(10)))
    {
        try
        {
            var value = await _source.LoadAsync(id, ct);          // authoritative
            await _db.StringSetAsync(key, JsonSerializer.Serialize(value), TimeSpan.FromMinutes(5));
            return value;
        }
        finally { await _db.LockReleaseAsync(lockKey, token); }
    }

    await Task.Delay(50, ct);                                     // someone else is loading
    return await GetCatalogAsync(id, ct);
}

07Invalidation: the hard part

There are only a few honest strategies: TTL only, delete on write, write-through, or versioned keys. Pick one per entity and write it down.

Delete rather than update: an update writes a value that a concurrent reader may overwrite with older data.

  • Delete-on-write plus a TTL covers most cases; versioned keys handle schema changes elegantly.
  • Never dual-write without a transaction boundary — you will cache a value the database rejected.
  • Document the TTL per entity; a number nobody remembers is a number nobody can defend.
// 1. Delete on write (simplest, safest)
await _repo.SaveAsync(user, ct);
await _cache.KeyDeleteAsync($"app:user:v1:{user.Id}");

// 2. Version segment: bump the prefix, old keys expire on their own
//    app:catalog:v2:*  ->  app:catalog:v3:*   (no mass delete needed)

// 3. Pub/sub for multi-instance invalidation
await _sub.SubscribeAsync("cache:invalidate", (_, msg) =>
    _cache.KeyDelete(msg.ToString()));

// 4. Write-through: cache and store in the same use case
await _repo.SaveAsync(user, ct);
await _cache.StringSetAsync(key, JsonSerializer.Serialize(user), TimeSpan.FromMinutes(10));

08Redis data structures worth knowing

Half of Redis' value is doing in one command what the application would otherwise do with a read-modify-write.

Counters, leaderboards, deduplication sets, queues and streams all have first-class support.

  • `INCR` and `HINCRBY` are atomic across clients — no lock required.
  • Sorted sets are the cheapest leaderboard you will ever build.
  • Streams can replace a queue for moderate throughput; know when to move to a real broker.
// Counter with atomic increment
await db.StringIncrementAsync($"app:view:v1:{articleId}");

// Leaderboard
await db.SortedSetIncrementAsync("app:scores:weekly", playerId, points);
var top = await db.SortedSetRangeByRankWithScoresAsync("app:scores:weekly", 0, 9, Order.Descending);

// Deduplication / membership
await db.SetAddAsync("app:seen:2025-05-12", userId);
var unique = await db.SetLengthAsync("app:seen:2025-05-12");

// Hash for a partial update without rewriting the blob
await db.HashSetAsync($"app:session:v1:{sid}", [new("lastSeen", now), new("route", path)]);

09Locks, rate limiting and idempotency

Redis is often used for coordination as much as caching. The important caveat: a Redis lock is not a distributed consensus protocol — it is a lease with a TTL.

For rate limiting it is genuinely good: a counter with expiry is exactly the sliding-window primitive.

  • A lock without a TTL is an outage waiting for a crash; always expire it.
  • Assume the lock can be lost mid-operation — design the critical section to be safe anyway.
  • Fixed windows allow bursts at the boundary; use a sliding window or token bucket if that matters.
// Fixed-window rate limiter: 100 requests per minute per API key
public async Task<bool> AllowAsync(string apiKey)
{
    var key = $"app:rl:v1:{apiKey}:{DateTime.UtcNow:yyyyMMddHHmm}";
    var count = await _db.StringIncrementAsync(key);
    if (count == 1) { await _db.KeyExpireAsync(key, TimeSpan.FromMinutes(2)); }
    return count <= 100;
}

// Idempotency key for a payment endpoint
var claimed = await _db.StringSetAsync($"app:idem:v1:{requestId}", "in-flight",
    TimeSpan.FromMinutes(10), When.NotExists);
if (!claimed) { return Results.Conflict("Duplicate request"); }

10Memcached specifics

Memcached is a hash table across machines. What matters operationally: slab allocation, LRU per slab class, no persistence and no replication.

Its multi-get is the reason it still wins for certain read patterns — one network round trip for many keys.

  • Items over 1 MB are rejected; chunk or store a reference instead.
  • No persistence: a restart is a cold cache. Size the database for the cold-start load.
  • Slab imbalance causes eviction while memory is technically free — monitor `evictions`, not just `bytes`.
// One round trip for many objects: the pattern Memcached is built around
var keys = new[] { "app:user:v1:1", "app:user:v1:2", "app:user:v1:3" };
var results = client.Get(keys);            // IDictionary<string, object>

// CAS when two writers may collide
var item = client.Get("app:config:v1");
if (item != null && client.TryGet("app:config:v1", out var current))
{
    current.Cas(++item.Cas);               // store only if nobody changed it
    client.Store(StoreMode.Replace, "app:config:v1", current);
}

11Operating the cache in production

The cache becomes part of the availability story the moment it is on the request path. Two decisions make the difference: what happens when it is unreachable, and how memory behaves when it fills.

Treat configuration as code: `maxmemory` and `maxmemory-policy` are the two settings to review first.

  • Hit ratio = hits / (hits + misses). A ratio falling after a deploy usually means key names changed.
  • `evicted_keys` climbing steadily means the dataset does not fit — raise memory or shorten TTLs.
  • A cache outage should degrade latency, not availability: wrap calls in try/catch and fall back to the source.
# redis.conf essentials
maxmemory 2gb
maxmemory-policy allkeys-lru        # evict keys, never fail writes
save 900 1                          # snapshot policy; or AOF for durability
appendonly no                       # AOF costs IO; enable only if you need it

# What to watch, in order of usefulness
INFO stats        -> keyspace_hits, keyspace_misses, evicted_keys
INFO memory       -> used_memory_peak, mem_fragmentation_ratio
slowlog get 10    -> commands above slowlog-log-slower-than
latency monitor   -> spikes correlated with application deploys

12Anti-patterns that show up in interviews

These are the answers that reveal whether someone has operated a cache or only read about it.

If you have a war story for any of them, that is what to tell — the failure and what you changed.

Anti-patternWhat it looks likeFix
Caching everythingHit ratio under 60%, memory pressureCache only expensive, repeated reads
No TTLKeys grow forever, one bad deploy poisons the dataAlways expire; version the key namespace
Cache as source of truthData loss after a flush, divergent replicasDatabase is authoritative; cache is derivable
`KEYS *` in a request pathLatency spikes to seconds under loadKnown key sets, `SCAN` only in admin tooling
Lock without TTLOne crashed worker blocks the feature foreverLease with expiry and a release token
Big valuesNetwork saturation, fragmentationStore ids or small projections, not whole graphs
  • The failure path returns correct data slightly slower — that is the whole design.
  • Log cache failures at Warning: they should be visible, not fatal.
  • Be ready to justify the TTL and the key version, not just the pattern.
// The shape that survives review
public sealed class CachedCatalogService(
    ICatalogService inner, IDistributedCache cache, ILogger<CachedCatalogService> log)
{
    public async Task<Catalog?> GetAsync(int id, CancellationToken ct)
    {
        var key = $"app:catalog:v2:{id}";
        try
        {
            var hit = await cache.GetStringAsync(key, ct);
            if (hit is not null) { return JsonSerializer.Deserialize<Catalog>(hit); }
        }
        catch (Exception ex) { log.LogWarning(ex, "Cache read failed for {Key}", key); }

        var value = await inner.GetAsync(id, ct);          // always works
        if (value is not null)
        {
            try { await cache.SetStringAsync(key, JsonSerializer.Serialize(value), ct); }
            catch (Exception ex) { log.LogWarning(ex, "Cache write failed for {Key}", key); }
        }
        return value;
    }
}
← Back to the knowledge base