Azure Cache for Redis
Key Points
- Managed Redis. Tiers: Basic (no SLA), Standard (HA pair), Premium (clustering, persistence, vnet), Enterprise (Redis Enterprise; modules), Enterprise Flash (NVMe; cheap big cache).
StackExchange.Redisis the .NET client. SingletonConnectionMultiplexer; thread-safe.- Idiomatic uses: distributed cache (
IDistributedCache), session, SignalR backplane, pub/sub, distributed locks (carefully), rate limiting, leaderboards. - Avoid Redis as primary store unless using Enterprise with persistence.
- Connection mgmt is the #1 pitfall — connection multiplexer is meant to be reused; many apps create per-request connections and exhaust.
Concepts (deep dive)
What Redis is
Redis is an in-memory data structure server. The entire dataset lives in RAM (with optional disk persistence for durability), and the server speaks a small text protocol over TCP. Unlike a relational database, Redis isn't a place you query — it's a place you put already-shaped values: strings, hashes, lists, sets, sorted sets, streams, bitmaps. Reads are typically sub-millisecond because there's no disk seek, no query planner, no join.
application Redis server (single process)
┌──────────┐ SET k v ┌────────────────────────────────────────┐
│ client │ ────────────►│ single-threaded event loop │
│ (multi- │ │ ┌────────┐ ┌────────┐ ┌────────┐ │
│ threaded)│ ◄────────────│ │ string │ │ hash │ │ zset │ … │
│ │ OK / val │ └────────┘ └────────┘ └────────┘ │
└──────────┘ │ RAM-resident data │
│ ──────────────────────────────── │
│ optional: RDB snapshots + AOF log │
└────────────────────────────────────────┘
Why Redis exists vs a "real" database:
- Latency — RAM access is ~100ns; SSD ~100μs; a SQL query against a B-tree is milliseconds. For hot reads (sessions, tokens, lookup caches), the ratio is 1000–10,000× faster.
- Right-shaped data — counters, leaderboards, queues, locks, pub/sub channels, geo indexes are primitives in Redis, not awkward emulations on top of tables.
- Throughput — a single Redis node sustains 100k+ ops/sec because the event loop never blocks on I/O.
The trade-off: RAM is expensive and finite, and Basic/Standard tiers can lose data on restart. Redis is a cache, a coordination layer, or a real-time index — not a system of record for anything you can't reconstruct.
Single-threaded event loop (the consequence)
Each Redis server process handles one command at a time on a single CPU thread. This is the source of two seemingly contradictory properties: every command is atomic with respect to every other command (no race conditions between operations), and one slow command (KEYS *, a giant SORT, a malformed Lua script) blocks every other client until it finishes.
This is also why ConnectionMultiplexer is built the way it is: the client side is multi-threaded, so it pipelines many commands over a few long-lived TCP connections rather than opening a connection per call. One multiplexer can serve a whole app pool.
💡 The corollary for production: avoid O(N) commands on the whole keyspace (KEYS, FLUSHDB, large HGETALLs). Use SCAN for iteration; cap value sizes.
Tiers
The Azure tier you pick controls what kind of Redis you get — single node vs replicated, vanilla open-source Redis vs Redis Enterprise (with modules), and whether features like clustering, persistence, VNet integration, and geo-replication are on the table. Pick by what failure modes you can tolerate, not by price alone.
| Tier | Notes |
|---|---|
| Basic | Single node; no SLA; dev only |
| Standard | Primary+replica; 99.9% SLA |
| Premium | Clustering, persistence, vnet, geo-replication |
| Enterprise | Redis Enterprise; modules (Search, JSON, TimeSeries) |
| Enterprise Flash | Big caches at lower cost (NVMe) |
Connection
ConnectionMultiplexer is the client's permanent, thread-safe pipe to the Redis server: it owns the TCP connection(s), the reconnect logic, the command pipeline, and the response demultiplexer. Creating one is expensive (DNS, TLS handshake, AUTH, topology discovery); using one is cheap. The whole library is designed around the assumption that you create exactly one and share it.
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
ConnectionMultiplexer.Connect("myredis.redis.cache.windows.net:6380,password=...,ssl=True"));
public class C(IConnectionMultiplexer mux)
{
private readonly IDatabase _db = mux.GetDatabase();
public Task SetAsync(string k, string v) => _db.StringSetAsync(k, v);
}
Critical: ConnectionMultiplexer is heavy; create once. GetDatabase() is cheap.
Microsoft.Extensions.Caching.StackExchangeRedis
IDistributedCache is ASP.NET Core's abstraction for "a cache that survives a single process" — same shape as IMemoryCache, but every operation is async and crosses the network. The StackExchangeRedis package implements that interface against a Redis backend, so any framework code that already takes IDistributedCache (output caching, response caching, session state) immediately works against Redis.
builder.Services.AddStackExchangeRedisCache(o =>
{
o.Configuration = "myredis...";
o.InstanceName = "myapp:";
});
// Use IDistributedCache
public class C(IDistributedCache cache) { /* ... */ }
For HybridCache integration, see Caching Strategies.
Common patterns
Cache-aside
The application owns the cache: read from cache first, on miss read from source and populate cache. Redis is dumb storage; the app decides when to refresh, when to expire, what to serialize. This is the most common Redis pattern by far and the one HybridCache automates.
var json = await db.StringGetAsync(key);
if (!json.HasValue)
{
var fresh = await Load();
await db.StringSetAsync(key, JsonSerializer.Serialize(fresh), TimeSpan.FromHours(1));
return fresh;
}
return JsonSerializer.Deserialize<T>(json!);
Pub/Sub
A fire-and-forget broadcast channel: publishers send messages to a named channel, all currently-subscribed subscribers receive them, and there is no buffering — messages sent while a subscriber is offline are lost forever. Useful for cache invalidation fanout, SignalR backplane, and other "tell everyone right now" flows; useless when you need durability.
var sub = mux.GetSubscriber();
await sub.SubscribeAsync(RedisChannel.Literal("orders"), (ch, msg) => Process(msg));
await sub.PublishAsync(RedisChannel.Literal("orders"), "payload");
Lightweight; no persistence. For durable, use Service Bus.
Distributed lock
The pattern: SET key value NX EX 30 — atomic "set only if not exists, with TTL." Whoever wins the SET holds the lock; everyone else retries or gives up. The TTL is essential — if the holder crashes without releasing, the lock unblocks automatically. The Lua script on release is also essential: it deletes the lock only if you still own it, preventing one client from accidentally releasing another's lock after their TTL expired.
⚠️ Even this pattern has known correctness gaps under network partitions and process pauses. For anything truly safety-critical, use a vetted library (RedLock.net) or push the consensus problem to a real coordination service.
// Set with NX (only if not exists) + EX (TTL)
var acquired = await db.StringSetAsync(lockKey, requestId, TimeSpan.FromSeconds(30), When.NotExists);
if (!acquired) return /* already locked */;
try { /* critical section */ }
finally
{
// Lua-script-released to ensure we own it
await db.ScriptEvaluateAsync("if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end",
new RedisKey[] { lockKey }, new RedisValue[] { requestId });
}
Use RedLock for distributed correctness across multiple Redis nodes. Locks have edge cases — use Service Bus sessions or DB locks for critical correctness.
Rate limiter
Atomic INCR on a per-key counter, set a TTL the first time it's bumped, reject when the counter exceeds the budget. Because Redis is single-threaded, the INCR+EXPIRE pair is race-free even under concurrent requests from many app instances.
// Token bucket via INCR + EXPIRE
var current = await db.StringIncrementAsync(rateKey);
if (current == 1) await db.KeyExpireAsync(rateKey, TimeSpan.FromMinutes(1));
if (current > 100) return TooManyRequests();
ASP.NET Core's built-in rate limiter is per-instance; Redis-backed shares across.
Persistence (Premium+)
By default Redis is pure RAM — a restart wipes everything. Persistence makes a copy on disk so the cache survives planned and unplanned restarts. Two mechanisms, often combined:
- RDB: snapshots periodically.
- AOF: append every write to log.
For Premium, enable persistence to survive restarts. Without, data is volatile.
Clustering (Premium+)
Beyond a single node's RAM, Redis shards the keyspace across multiple primary nodes using a fixed 16,384-slot hash ring; each key hashes to a slot and lives on whichever node owns that slot. The client (via ConnectionMultiplexer) learns the topology and routes commands to the right node automatically. Multi-key operations (MGET, transactions, scripts touching multiple keys) only work when all involved keys live on the same node — the {...} hashtag forces them to.
Sharded; single key = one shard. Multi-key ops require keys on same shard via hashtag: {user:123}:profile, {user:123}:settings.
Geo-replication
Premium tier: passive geo-replica in another region. Failover via API.
Encryption
In-transit: TLS (port 6380). At-rest: encryption enabled by default in Azure.
Authentication
Two ways to prove you're allowed to talk to Redis. Access keys are the legacy default — long random strings, rotated manually, often stashed in app config. Entra ID uses the same managed-identity flow as the rest of Azure: no shared secret to leak, no rotation to schedule, RBAC controls who can connect. For new work, prefer Entra ID.
- Access keys (default): primary + secondary; rotate.
- Microsoft Entra ID (preview/GA): managed identity authentication.
// Entra ID auth
var options = ConfigurationOptions.Parse("redis...");
options.ConfigureForAzureWithTokenCredentialAsync(new DefaultAzureCredential());
var mux = await ConnectionMultiplexer.ConnectAsync(options);
TTL strategies
Every cache entry needs an expiration policy because RAM is finite. Absolute TTL expires N seconds after the value was written — predictable, simple. Sliding TTL resets the expiration on every access — keeps hot items warm, lets cold items decay. Pick absolute for "data is stale after N minutes regardless"; pick sliding for "evict if nobody's asked recently."
await db.StringSetAsync(key, value, TimeSpan.FromHours(1)); // absolute
await db.KeyExpireAsync(key, TimeSpan.FromMinutes(5)); // sliding (call on access)
Memory pressure
When Redis hits maxmemory, it has to choose: refuse new writes, or evict existing keys. The eviction policy picks which. For caches, you almost always want allkeys-lru (kick the least-recently-used key on every write); noeviction (errors when full) is what you get out of the box and is rarely what you want.
When full: eviction policy (maxmemory-policy): - noeviction (errors). - allkeys-lru (best for cache). - volatile-lru (only TTL keys). - allkeys-random.
For caches, allkeys-lru is the default.
Common pitfalls
- Per-call ConnectionMultiplexer — expensive; exhausts.
- No timeout — hung calls block.
- Non-async — blocks threads.
- Treating Redis as primary store — lose data on restart (Basic/Standard).
- Distributed lock incorrectness — many subtle bugs; use a library.
.NET 8+ HybridCache
HybridCache is the modern .NET caching abstraction: a two-tier cache that keeps a fast in-process L1 (memory) in front of a slower distributed L2 (Redis), with stampede protection (only one caller fills the cache on a miss; the others await) and tag-based invalidation built in. It replaces the hand-rolled cache-aside loops everyone was writing.
Two-tier (local memory + Redis) with stampede protection. Recommended over manual cache-aside.
Code: correct vs wrong
❌ Wrong: per-call connection
✅ Correct: singleton
❌ Wrong: no TTL
✅ Correct: TTL
Design patterns for this topic
Pattern 1 — "Singleton ConnectionMultiplexer"
- Intent: reuse; thread-safe.
Pattern 2 — "HybridCache with Redis L2"
- Intent: two-level cache.
Pattern 3 — "Pub/Sub for SignalR backplane"
- Intent: scale-out real-time.
Pattern 4 — "RedLock library for distributed lock"
- Intent: correct distributed locking.
Pattern 5 — "Rate limiter via INCR+EXPIRE"
- Intent: distributed rate limiting.
Pros & cons / trade-offs
| Tier | Pros | Cons |
|---|---|---|
| Basic | Cheap | No SLA |
| Standard | HA | No clustering |
| Premium | Cluster + persistence | Cost |
| Enterprise | Modules | $$$ |
When to use / when to avoid
- Use for cache, sessions, SignalR backplane, pub/sub.
- Use Premium for production with persistence.
- Avoid as primary store on Basic/Standard.
- Avoid distributed locks for safety-critical without RedLock.
Interview Q&A
Q1. Tier choice? Standard for prod default; Premium for cluster/persistence/vnet; Enterprise for modules.
Q2. ConnectionMultiplexer lifetime? Singleton. Heavy connection; thread-safe; reuse.
Q3. Idiomatic use cases? Cache, sessions, pub/sub, rate limiter, SignalR backplane.
Q4. Persistence? Premium tier. RDB snapshots + AOF append log.
Q5. Clustering? Sharded. Multi-key ops need same shard via hashtag {...}.
Q6. Distributed lock? Set NX EX pattern. Use RedLock library for correctness across nodes.
Q7. Pub/Sub vs Service Bus? Pub/Sub: lightweight; no persistence. Service Bus: durable; rich features.
Q8. Eviction policy? allkeys-lru default for caches. Noeviction errors when full.
Q9. Geo-replication? Premium passive replica in another region. API failover.
Q10. Entra ID auth? Managed identity instead of access keys. Preferred.
Q11. Memory limits? Tier-dependent. Monitor; expand or evict.
Q12. HybridCache? .NET 9 two-tier (memory + Redis); stampede protection; preferred.
Gotchas / common mistakes
- ⚠️ Per-call multiplexer — exhaustion.
- ⚠️ Sync calls — thread blocking.
- ⚠️ No TTL — memory grows.
- ⚠️ Treating Redis as DB on Basic/Standard.
- ⚠️ Distributed lock without library — bugs.