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.
Contents
- What a cache is for, and what it costs
- Redis versus Memcached
- Connecting from .NET
- Connecting from Node and TypeScript
- Key design and namespacing
- Stampede protection: when the key expires under load
- Invalidation: the hard part
- Redis data structures worth knowing
- Locks, rate limiting and idempotency
- Memcached specifics
- Operating the cache in production
- Anti-patterns that show up in interviews
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 safer02Redis 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.
| Memcached | Redis | |
|---|---|---|
| Model | Strings only, multi-get | Strings, hashes, sets, sorted sets, streams |
| Persistence | None — restart loses everything | RDB snapshots and/or AOF log |
| Replication | No | Primary/replica, Sentinel, Cluster |
| Eviction | LRU by slab | Configurable policies incl. allkeys-lru, volatile-ttl |
| Atomic ops | CAS on a key | INCR, Lua scripts, transactions, locks |
| Typical role | Pure object cache in front of SQL | Cache 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 namespace06Stampede 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 deploys12Anti-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-pattern | What it looks like | Fix |
|---|---|---|
| Caching everything | Hit ratio under 60%, memory pressure | Cache only expensive, repeated reads |
| No TTL | Keys grow forever, one bad deploy poisons the data | Always expire; version the key namespace |
| Cache as source of truth | Data loss after a flush, divergent replicas | Database is authoritative; cache is derivable |
| `KEYS *` in a request path | Latency spikes to seconds under load | Known key sets, `SCAN` only in admin tooling |
| Lock without TTL | One crashed worker blocks the feature forever | Lease with expiry and a release token |
| Big values | Network saturation, fragmentation | Store 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;
}
}